From 26704831e4d29558a3527407b190a11852828216 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 12:26:12 +0200 Subject: [PATCH 001/205] chore: arborescence du monorepo EnerVision Pose les dossiers des sept domaines de la stack (backend, frontend, base, ETL, infra, CI/CD, monitoring) avec un README de cadrage par domaine. Seul apps/backend est initialise, les autres font l'objet d'un ticket dedie. Le squelette Angular n'est pas versionne a la main : apps/frontend porte la commande ng new a lancer. --- .editorconfig | 22 +++++++ .github/ISSUE_TEMPLATE/.gitkeep | 0 .github/workflows/.gitkeep | 0 .gitignore | 61 +++++++++++++++++ README.md | 65 ++++++++++++++++++- apps/frontend/README.md | 26 ++++++++ db/README.md | 10 +++ db/init/.gitkeep | 0 db/migrations/.gitkeep | 0 db/seeds/.gitkeep | 0 docs/README.md | 4 ++ docs/adr/.gitkeep | 0 docs/architecture/.gitkeep | 0 etl/README.md | 9 +++ etl/airflow/dags/.gitkeep | 0 etl/airflow/include/.gitkeep | 0 etl/airflow/plugins/.gitkeep | 0 etl/airflow/tests/.gitkeep | 0 infra/README.md | 6 ++ infra/terraform/environments/dev/.gitkeep | 0 infra/terraform/environments/prod/.gitkeep | 0 infra/terraform/modules/.gitkeep | 0 monitoring/README.md | 10 +++ monitoring/alertmanager/.gitkeep | 0 monitoring/grafana/dashboards/.gitkeep | 0 .../grafana/provisioning/dashboards/.gitkeep | 0 .../grafana/provisioning/datasources/.gitkeep | 0 monitoring/prometheus/rules/.gitkeep | 0 scripts/.gitkeep | 0 scripts/README.md | 3 + 30 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/.gitkeep create mode 100644 .github/workflows/.gitkeep create mode 100644 .gitignore create mode 100644 apps/frontend/README.md create mode 100644 db/README.md create mode 100644 db/init/.gitkeep create mode 100644 db/migrations/.gitkeep create mode 100644 db/seeds/.gitkeep create mode 100644 docs/README.md create mode 100644 docs/adr/.gitkeep create mode 100644 docs/architecture/.gitkeep create mode 100644 etl/README.md create mode 100644 etl/airflow/dags/.gitkeep create mode 100644 etl/airflow/include/.gitkeep create mode 100644 etl/airflow/plugins/.gitkeep create mode 100644 etl/airflow/tests/.gitkeep create mode 100644 infra/README.md create mode 100644 infra/terraform/environments/dev/.gitkeep create mode 100644 infra/terraform/environments/prod/.gitkeep create mode 100644 infra/terraform/modules/.gitkeep create mode 100644 monitoring/README.md create mode 100644 monitoring/alertmanager/.gitkeep create mode 100644 monitoring/grafana/dashboards/.gitkeep create mode 100644 monitoring/grafana/provisioning/dashboards/.gitkeep create mode 100644 monitoring/grafana/provisioning/datasources/.gitkeep create mode 100644 monitoring/prometheus/rules/.gitkeep create mode 100644 scripts/.gitkeep create mode 100644 scripts/README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..87dcfe0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 +max_line_length = 100 + +[*.{tf,tfvars}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/.gitkeep b/.github/ISSUE_TEMPLATE/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af1ea90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +dist/ +build/ +*.egg-info/ + +# Node / Angular +node_modules/ +.angular/ +apps/frontend/dist/ +apps/frontend/.angular/ +npm-debug.log* +yarn-error.log* + +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfplan +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Airflow +etl/airflow/logs/ +airflow.db +airflow-webserver.pid +standalone_admin_password.txt + +# Environnement et secrets +.env +.env.* +!.env.example +*.pem +*.key +secrets/ + +# Donnees locales +data/ +*.sqlite3 +monitoring/grafana/data/ +monitoring/prometheus/data/ + +# IDE et OS +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 6ee4d96..b6ab9c8 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# ProjetPiscine_EnerVision \ No newline at end of file +# EnerVision + +Monorepo de la plateforme EnerVision : collecte, stockage, analyse et restitution de +series temporelles energetiques, deployee sur une machine on-premise. + +## Stack cible + +| Domaine | Technologie | Emplacement | Etat | +|------------|-------------------------------------|---------------------|---------------| +| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | +| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser | +| Base | PostgreSQL + TimescaleDB | `db` | A initialiser | +| ETL | Apache Airflow | `etl/airflow` | A initialiser | +| Infra | Terraform | `infra/terraform` | A initialiser | +| CI/CD | GitHub Actions | `.github/workflows` | A initialiser | +| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | + +Seul le backend est initialise a ce stade. Les autres dossiers portent l'arborescence et +un README de cadrage, leur contenu fait l'objet d'un ticket dedie. + +## Arborescence + +``` +. +├── apps/ +│ ├── backend/ API FastAPI +│ └── frontend/ Application Angular +├── db/ +│ ├── init/ Bootstrap PostgreSQL + TimescaleDB +│ ├── migrations/ Migrations SQL versionnees +│ └── seeds/ Jeux de donnees de reference +├── etl/airflow/ +│ ├── dags/ DAGs d'ingestion et d'agregation +│ ├── plugins/ Operateurs et hooks maison +│ ├── include/ Requetes SQL et ressources des DAGs +│ └── tests/ Tests d'integrite des DAGs +├── infra/terraform/ +│ ├── modules/ Modules reutilisables +│ └── environments/ Racines Terraform, une par environnement +├── monitoring/ +│ ├── prometheus/ Collecte et regles d'alerte +│ ├── grafana/ Provisioning et dashboards +│ └── alertmanager/ Routage des alertes +├── docs/ ADR et vues d'architecture +└── scripts/ Outillage local +``` + +## Demarrage + +Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul. + +```bash +make install # dependances du backend +make dev # API sur http://localhost:8000, docs sur /docs +make check # lint + typage + tests +``` + +`make help` liste les cibles disponibles. + +## Conventions + +- Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court. +- Commits : Conventional Commits, portee = dossier de premier niveau concerne. +- Toute decision structurante donne lieu a un ADR dans `docs/adr`. diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 0000000..36b31b4 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,26 @@ +# Frontend EnerVision + +Le squelette applicatif n'est pas versionne a la main : il est genere par Angular CLI. + +## Initialisation + +Depuis `apps/` : + +```bash +npx --yes @angular/cli@latest new frontend \ + --directory frontend \ + --style=scss \ + --routing \ + --ssr=false \ + --package-manager=npm \ + --skip-git +``` + +Le dossier `apps/frontend` doit etre vide (hors ce README) avant de lancer la commande. + +## Apres generation + +1. Pointer l'API dans `src/environments/` sur `http://localhost:8000/api/v1`. +2. Ajouter le proxy de developpement (`proxy.conf.json`) vers le backend. +3. Verifier que `npm start` sert bien sur le port 4200 attendu par `docker-compose.yml`. +4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..e80cb3c --- /dev/null +++ b/db/README.md @@ -0,0 +1,10 @@ +# Base de donnees + +PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie. + +- `init` : scripts de bootstrap joues au premier demarrage du conteneur. +- `migrations` : migrations SQL versionnees. +- `seeds` : jeux de donnees de reference. + +Les migrations du schema applicatif expose par l'API vivent dans +`apps/backend/alembic`, pas ici. diff --git a/db/init/.gitkeep b/db/init/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/migrations/.gitkeep b/db/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/seeds/.gitkeep b/db/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b4ad74d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,4 @@ +# Documentation + +- `adr` : decisions d'architecture, une par fichier, numerotees et immuables. +- `architecture` : schemas et vues d'ensemble. diff --git a/docs/adr/.gitkeep b/docs/adr/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture/.gitkeep b/docs/architecture/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/etl/README.md b/etl/README.md new file mode 100644 index 0000000..cac0f3d --- /dev/null +++ b/etl/README.md @@ -0,0 +1,9 @@ +# ETL + +Orchestration Apache Airflow : ingestion des mesures, agregations continues, +controles de qualite. Non initialise, voir le ticket dedie. + +- `airflow/dags` : DAGs. +- `airflow/plugins` : operateurs et hooks maison. +- `airflow/include` : requetes SQL et ressources referencees par les DAGs. +- `airflow/tests` : tests d'integrite des DAGs. diff --git a/etl/airflow/dags/.gitkeep b/etl/airflow/dags/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/etl/airflow/include/.gitkeep b/etl/airflow/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/etl/airflow/plugins/.gitkeep b/etl/airflow/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/etl/airflow/tests/.gitkeep b/etl/airflow/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..bff8fbe --- /dev/null +++ b/infra/README.md @@ -0,0 +1,6 @@ +# Infrastructure + +Provisionnement Terraform de la machine on-premise. Non initialise, voir le ticket dedie. + +- `terraform/modules` : modules reutilisables. +- `terraform/environments/` : racines Terraform, une par environnement. diff --git a/infra/terraform/environments/dev/.gitkeep b/infra/terraform/environments/dev/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/terraform/environments/prod/.gitkeep b/infra/terraform/environments/prod/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/terraform/modules/.gitkeep b/infra/terraform/modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 0000000..d1b5254 --- /dev/null +++ b/monitoring/README.md @@ -0,0 +1,10 @@ +# Monitoring + +Prometheus, Grafana et Alertmanager. Non initialise, voir le ticket dedie. + +- `prometheus` : configuration de collecte et regles d'alerte. +- `grafana/provisioning` : sources de donnees et fournisseurs de dashboards. +- `grafana/dashboards` : dashboards versionnes au format JSON. +- `alertmanager` : routage et inhibition des alertes. + +Le backend expose deja ses metriques sur `/metrics` au format Prometheus. diff --git a/monitoring/alertmanager/.gitkeep b/monitoring/alertmanager/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/monitoring/grafana/dashboards/.gitkeep b/monitoring/grafana/dashboards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/monitoring/grafana/provisioning/dashboards/.gitkeep b/monitoring/grafana/provisioning/dashboards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/monitoring/grafana/provisioning/datasources/.gitkeep b/monitoring/grafana/provisioning/datasources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/monitoring/prometheus/rules/.gitkeep b/monitoring/prometheus/rules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9395406 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,3 @@ +# Scripts + +Outillage local du monorepo. Les taches courantes passent par le `Makefile` racine. From 6161a432c33a7606b43496cf5f4bc150af3df80a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 12:26:20 +0200 Subject: [PATCH 002/205] feat(backend): initialisation du projet FastAPI Structure en couches api / services / repositories / models, sens de dependance unique, une session SQLAlchemy async injectee par dependance. - Python 3.14, dependances gerees par uv et verrouillees dans uv.lock - FastAPI expose par une factory : aucune configuration lue a l'import, ce qui rend tests et migrations independants de l'environnement - Settings Pydantic, APP_SECRET_KEY et DATABASE_URL sans valeur par defaut - Sondes /health/live et /health/ready, metriques Prometheus sur /metrics - Lint et format ruff, mypy strict, pytest avec couverture - Alembic branche sur DATABASE_URL et non sur alembic.ini - Image Docker multi-stage, utilisateur non root, sonde de sante integree --- Makefile | 30 + apps/backend/.dockerignore | 16 + apps/backend/.env.example | 6 + apps/backend/.python-version | 1 + apps/backend/Dockerfile | 42 + apps/backend/README.md | 97 ++ apps/backend/alembic.ini | 150 +++ apps/backend/alembic/README | 1 + apps/backend/alembic/env.py | 90 ++ apps/backend/alembic/script.py.mako | 28 + apps/backend/alembic/versions/.gitkeep | 0 apps/backend/app/__init__.py | 0 apps/backend/app/api/__init__.py | 0 apps/backend/app/api/deps.py | 10 + apps/backend/app/api/v1/__init__.py | 0 apps/backend/app/api/v1/endpoints/__init__.py | 0 apps/backend/app/api/v1/endpoints/health.py | 33 + apps/backend/app/api/v1/router.py | 6 + apps/backend/app/core/__init__.py | 0 apps/backend/app/core/config.py | 41 + apps/backend/app/core/logging.py | 48 + apps/backend/app/db/__init__.py | 0 apps/backend/app/db/base.py | 5 + apps/backend/app/db/session.py | 33 + apps/backend/app/main.py | 54 ++ apps/backend/app/models/__init__.py | 2 + apps/backend/app/repositories/__init__.py | 0 apps/backend/app/schemas/__init__.py | 3 + apps/backend/app/schemas/health.py | 15 + apps/backend/app/services/__init__.py | 0 apps/backend/pyproject.toml | 86 ++ apps/backend/tests/__init__.py | 0 apps/backend/tests/api/__init__.py | 0 apps/backend/tests/api/test_health.py | 51 + apps/backend/tests/conftest.py | 32 + apps/backend/uv.lock | 915 ++++++++++++++++++ 36 files changed, 1795 insertions(+) create mode 100644 Makefile create mode 100644 apps/backend/.dockerignore create mode 100644 apps/backend/.env.example create mode 100644 apps/backend/.python-version create mode 100644 apps/backend/Dockerfile create mode 100644 apps/backend/README.md create mode 100644 apps/backend/alembic.ini create mode 100644 apps/backend/alembic/README create mode 100644 apps/backend/alembic/env.py create mode 100644 apps/backend/alembic/script.py.mako create mode 100644 apps/backend/alembic/versions/.gitkeep create mode 100644 apps/backend/app/__init__.py create mode 100644 apps/backend/app/api/__init__.py create mode 100644 apps/backend/app/api/deps.py create mode 100644 apps/backend/app/api/v1/__init__.py create mode 100644 apps/backend/app/api/v1/endpoints/__init__.py create mode 100644 apps/backend/app/api/v1/endpoints/health.py create mode 100644 apps/backend/app/api/v1/router.py create mode 100644 apps/backend/app/core/__init__.py create mode 100644 apps/backend/app/core/config.py create mode 100644 apps/backend/app/core/logging.py create mode 100644 apps/backend/app/db/__init__.py create mode 100644 apps/backend/app/db/base.py create mode 100644 apps/backend/app/db/session.py create mode 100644 apps/backend/app/main.py create mode 100644 apps/backend/app/models/__init__.py create mode 100644 apps/backend/app/repositories/__init__.py create mode 100644 apps/backend/app/schemas/__init__.py create mode 100644 apps/backend/app/schemas/health.py create mode 100644 apps/backend/app/services/__init__.py create mode 100644 apps/backend/pyproject.toml create mode 100644 apps/backend/tests/__init__.py create mode 100644 apps/backend/tests/api/__init__.py create mode 100644 apps/backend/tests/api/test_health.py create mode 100644 apps/backend/tests/conftest.py create mode 100644 apps/backend/uv.lock diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..71dea97 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +BACKEND := apps/backend + +.DEFAULT_GOAL := help +.PHONY: help install dev lint format typecheck test check docker-build + +help: ## Liste les cibles disponibles + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +install: ## Installe les dependances du backend + cd $(BACKEND) && uv sync --all-groups + +dev: ## Lance l'API en rechargement a chaud + cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 + +lint: ## Analyse statique du backend + cd $(BACKEND) && uv run ruff check . + +format: ## Formate et corrige le backend + cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix . + +typecheck: ## Verifie le typage du backend + cd $(BACKEND) && uv run mypy app + +test: ## Execute les tests backend + cd $(BACKEND) && uv run pytest + +check: lint typecheck test ## Chaine de verification complete + +docker-build: ## Construit l'image du backend + docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/.dockerignore b/apps/backend/.dockerignore new file mode 100644 index 0000000..888cbcc --- /dev/null +++ b/apps/backend/.dockerignore @@ -0,0 +1,16 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +.env +.env.* +!.env.example +tests/ +Dockerfile +.dockerignore +README.md diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..258db03 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,6 @@ +APP_ENV=local +APP_DEBUG=true +APP_LOG_LEVEL=INFO +APP_SECRET_KEY=change_me +APP_CORS_ORIGINS=http://localhost:4200 +DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5432/enervision diff --git a/apps/backend/.python-version b/apps/backend/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/apps/backend/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 0000000..e152c5e --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.14-slim AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-dev + +COPY . /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + + +FROM python:3.14-slim AS runtime + +RUN groupadd --system --gid 1001 app \ + && useradd --system --uid 1001 --gid app --create-home app + +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY --from=builder --chown=app:app /app /app + +USER app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live')" + +CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..bd1c6fa --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,97 @@ +# Backend EnerVision + +API FastAPI exposant les series temporelles energetiques. + +| Element | Choix | +|-------------|--------------------------------------------| +| Python | 3.14 | +| Gestionnaire| uv (`uv.lock` fait foi) | +| Framework | FastAPI + Uvicorn | +| Persistance | SQLAlchemy 2 async + asyncpg + Alembic | +| Lint/format | ruff | +| Typage | mypy en mode strict | +| Tests | pytest + pytest-asyncio + httpx | + +## Installation + +```bash +cp .env.example .env +uv sync --all-groups +``` + +`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse +de demarrer sans elles. + +## Commandes + +Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`, +`make format`, `make typecheck`, `make test`, `make check`, `make docker-build`. + +Directement depuis ce dossier : + +```bash +uv run uvicorn app.main:create_app --factory --reload --port 8000 +uv run ruff check . # lint +uv run ruff format . # format +uv run mypy app # typage strict +uv run pytest # tests + couverture +``` + +L'application est exposee par une factory (`create_app`) et non par un objet module : +aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations +independants de l'environnement. + +## Structure + +``` +app/ +├── api/ +│ ├── deps.py Dependances FastAPI partagees (session, settings) +│ └── v1/ +│ ├── router.py Agregation des routes de la version 1 +│ └── endpoints/ Un module par ressource exposee +├── core/ +│ ├── config.py Settings Pydantic, source unique de configuration +│ └── logging.py Journalisation console en local, JSON en production +├── db/ +│ ├── base.py Base declarative SQLAlchemy +│ └── session.py Engine et sessions asynchrones +├── models/ Modeles SQLAlchemy +├── schemas/ Modeles Pydantic d'entree et de sortie +├── repositories/ Acces aux donnees, une classe par agregat +├── services/ Regles metier, orchestrent les repositories +└── main.py Factory applicative +tests/ Miroir de app/ +alembic/ Migrations du schema applicatif +``` + +Le sens de dependance est unique : `endpoints` vers `services` vers `repositories` vers +`models`. Un endpoint ne touche jamais une session directement. + +## Routes + +| Route | Role | +|------------------------|-------------------------------------------------| +| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | +| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base | +| `/metrics` | Metriques au format Prometheus | +| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | + +## Migrations + +```bash +uv run alembic revision --autogenerate -m "libelle" +uv run alembic upgrade head +``` + +L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`. + +## Image Docker + +Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un +utilisateur non root, sonde de sante integree. + +```bash +docker build -t enervision-backend:local . +docker run --rm -p 8000:8000 --env-file .env enervision-backend:local +``` diff --git a/apps/backend/alembic.ini b/apps/backend/alembic.ini new file mode 100644 index 0000000..05889cd --- /dev/null +++ b/apps/backend/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# L'URL est injectee par alembic/env.py depuis app.core.config. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/backend/alembic/README b/apps/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/apps/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py new file mode 100644 index 0000000..7b09cae --- /dev/null +++ b/apps/backend/alembic/env.py @@ -0,0 +1,90 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +import app.models # noqa: F401 +from app.core.config import get_settings +from app.db.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/backend/alembic/script.py.mako b/apps/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/apps/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/apps/backend/alembic/versions/.gitkeep b/apps/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/__init__.py b/apps/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/api/__init__.py b/apps/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py new file mode 100644 index 0000000..a25b1e1 --- /dev/null +++ b/apps/backend/app/api/deps.py @@ -0,0 +1,10 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import Settings, get_settings +from app.db.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +SettingsDep = Annotated[Settings, Depends(get_settings)] diff --git a/apps/backend/app/api/v1/__init__.py b/apps/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/api/v1/endpoints/__init__.py b/apps/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..b3c8523 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.api.deps import SessionDep, SettingsDep +from app.core.logging import get_logger +from app.schemas.health import LivenessStatus, ReadinessStatus + +logger = get_logger(__name__) +router = APIRouter(tags=["health"]) + + +@router.get("/live", summary="Sonde de vivacite") +async def liveness(settings: SettingsDep) -> LivenessStatus: + return LivenessStatus( + status="ok", + service=settings.name, + version=settings.version, + environment=settings.env, + ) + + +@router.get("/ready", summary="Sonde de disponibilite") +async def readiness(session: SessionDep) -> ReadinessStatus: + try: + await session.execute(text("SELECT 1")) + except SQLAlchemyError, OSError: + logger.exception("Base de donnees injoignable") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Base de donnees injoignable", + ) from None + return ReadinessStatus(status="ready", database="reachable") diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py new file mode 100644 index 0000000..8571d8f --- /dev/null +++ b/apps/backend/app/api/v1/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import health + +api_router = APIRouter() +api_router.include_router(health.router, prefix="/health") diff --git a/apps/backend/app/core/__init__.py b/apps/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py new file mode 100644 index 0000000..c3dbbe2 --- /dev/null +++ b/apps/backend/app/core/config.py @@ -0,0 +1,41 @@ +from functools import lru_cache +from typing import Literal + +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +Environment = Literal["local", "dev", "staging", "prod"] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="APP_", + env_file_encoding="utf-8", + extra="ignore", + ) + + name: str = "EnerVision API" + version: str = "0.1.0" + env: Environment = "local" + debug: bool = False + log_level: str = "INFO" + api_prefix: str = "/api/v1" + secret_key: SecretStr + cors_origins: str = "" + database_url: str = Field(validation_alias="DATABASE_URL") + database_pool_size: int = 5 + database_max_overflow: int = 10 + + @property + def allowed_origins(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + @property + def is_production(self) -> bool: + return self.env == "prod" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/apps/backend/app/core/logging.py b/apps/backend/app/core/logging.py new file mode 100644 index 0000000..c0cc9a6 --- /dev/null +++ b/apps/backend/app/core/logging.py @@ -0,0 +1,48 @@ +import logging +from logging.config import dictConfig + +from app.core.config import Settings + + +def configure_logging(settings: Settings) -> None: + formatter = "json" if settings.is_production else "console" + dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "format": "%(asctime)s %(levelname)-8s %(name)s %(message)s", + }, + "json": { + "()": "pythonjsonlogger.json.JsonFormatter", + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, + "handlers": { + "default": { + "class": "logging.StreamHandler", + "formatter": formatter, + "stream": "ext://sys.stdout", + }, + }, + "root": {"handlers": ["default"], "level": settings.log_level}, + "loggers": { + "uvicorn": { + "handlers": ["default"], + "level": settings.log_level, + "propagate": False, + }, + "uvicorn.access": { + "handlers": ["default"], + "level": settings.log_level, + "propagate": False, + }, + "sqlalchemy.engine": {"level": "WARNING"}, + }, + } + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/apps/backend/app/db/__init__.py b/apps/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/db/base.py b/apps/backend/app/db/base.py new file mode 100644 index 0000000..a1a552c --- /dev/null +++ b/apps/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base declarative commune a tous les modeles.""" diff --git a/apps/backend/app/db/session.py b/apps/backend/app/db/session.py new file mode 100644 index 0000000..998d8c9 --- /dev/null +++ b/apps/backend/app/db/session.py @@ -0,0 +1,33 @@ +from collections.abc import AsyncIterator +from functools import lru_cache + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.core.config import get_settings + + +@lru_cache +def get_engine() -> AsyncEngine: + settings = get_settings() + return create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, + pool_size=settings.database_pool_size, + max_overflow=settings.database_max_overflow, + ) + + +@lru_cache +def get_session_factory() -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(get_engine(), class_=AsyncSession, expire_on_commit=False) + + +async def get_session() -> AsyncIterator[AsyncSession]: + async with get_session_factory()() as session: + yield session diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py new file mode 100644 index 0000000..fa717f5 --- /dev/null +++ b/apps/backend/app/main.py @@ -0,0 +1,54 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from prometheus_fastapi_instrumentator import Instrumentator + +from app.api.v1.router import api_router +from app.core.config import Settings, get_settings +from app.core.logging import configure_logging, get_logger +from app.db.session import get_engine + +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + logger.info( + "Demarrage de %s %s en environnement %s", settings.name, settings.version, settings.env + ) + yield + await get_engine().dispose() + + +def create_app(settings: Settings | None = None) -> FastAPI: + resolved = settings or get_settings() + configure_logging(resolved) + + application = FastAPI( + title=resolved.name, + version=resolved.version, + debug=resolved.debug, + lifespan=lifespan, + docs_url=None if resolved.is_production else "/docs", + redoc_url=None if resolved.is_production else "/redoc", + openapi_url=None if resolved.is_production else "/openapi.json", + ) + + if resolved.allowed_origins: + application.add_middleware( + CORSMiddleware, + allow_origins=resolved.allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + Instrumentator().instrument(application).expose( + application, endpoint="/metrics", include_in_schema=False + ) + application.include_router(api_router, prefix=resolved.api_prefix) + + return application diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py new file mode 100644 index 0000000..6d71227 --- /dev/null +++ b/apps/backend/app/models/__init__.py @@ -0,0 +1,2 @@ +# Piege : tout modele absent de ce module reste invisible de `alembic revision +# --autogenerate`, qui genererait alors un drop de sa table. diff --git a/apps/backend/app/repositories/__init__.py b/apps/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/schemas/__init__.py b/apps/backend/app/schemas/__init__.py new file mode 100644 index 0000000..34017d6 --- /dev/null +++ b/apps/backend/app/schemas/__init__.py @@ -0,0 +1,3 @@ +from app.schemas.health import LivenessStatus, ReadinessStatus + +__all__ = ["LivenessStatus", "ReadinessStatus"] diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py new file mode 100644 index 0000000..d1eb845 --- /dev/null +++ b/apps/backend/app/schemas/health.py @@ -0,0 +1,15 @@ +from typing import Literal + +from pydantic import BaseModel + + +class LivenessStatus(BaseModel): + status: Literal["ok"] + service: str + version: str + environment: str + + +class ReadinessStatus(BaseModel): + status: Literal["ready"] + database: Literal["reachable"] diff --git a/apps/backend/app/services/__init__.py b/apps/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml new file mode 100644 index 0000000..87c916a --- /dev/null +++ b/apps/backend/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "enervision-backend" +version = "0.1.0" +description = "API EnerVision : exposition des series temporelles energetiques" +requires-python = ">=3.14,<3.15" +dependencies = [ + "fastapi>=0.141.1", + "uvicorn[standard]>=0.53.0", + "pydantic>=2.13.5", + "pydantic-settings>=2.15.0", + "sqlalchemy[asyncio]>=2.0.52", + "asyncpg>=0.31.0", + "alembic>=1.20.0", + "prometheus-fastapi-instrumentator>=8.1.0", + "python-json-logger>=4.2.0", +] + +[dependency-groups] +dev = [ + "ruff>=0.16.7", + "mypy>=2.3.1", + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", + "pytest-cov>=7.1.0", + "httpx>=0.28.1", +] + +[build-system] +requires = ["hatchling>=1.32.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 +target-version = "py314" +src = ["app", "tests"] +extend-exclude = ["alembic/versions"] + +[tool.ruff.lint] +select = [ + "E", "W", + "F", + "I", + "N", + "UP", + "B", + "C4", + "SIM", + "TID", + "RUF", + "ASYNC", + "S", + "PT", +] +ignore = ["B008"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.14" +strict = true +warn_unreachable = true +plugins = ["pydantic.mypy"] +exclude = ["^alembic/"] + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "-q --strict-markers --cov=app --cov-report=term-missing" + +[tool.coverage.run] +source = ["app"] +omit = ["app/main.py", "alembic/*"] diff --git a/apps/backend/tests/__init__.py b/apps/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/api/__init__.py b/apps/backend/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py new file mode 100644 index 0000000..6a2f938 --- /dev/null +++ b/apps/backend/tests/api/test_health.py @@ -0,0 +1,51 @@ +from collections.abc import AsyncIterator + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient +from sqlalchemy.exc import OperationalError + +from app.db.session import get_session + + +async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: + response = await client.get("/api/v1/health/live") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "service": "EnerVision API", + "version": "0.1.0", + "environment": "local", + } + + +@pytest.mark.parametrize( + "failure", + [ + OperationalError("SELECT 1", {}, Exception("connexion refusee")), + ConnectionRefusedError(111, "Connection refused"), + ], + ids=["erreur_sqlalchemy", "erreur_reseau_asyncpg"], +) +async def test_readiness_returns_503_when_database_is_unreachable( + app: FastAPI, client: AsyncClient, failure: Exception +) -> None: + class UnreachableSession: + async def execute(self, *_: object, **__: object) -> None: + raise failure + + async def override() -> AsyncIterator[UnreachableSession]: + yield UnreachableSession() + + app.dependency_overrides[get_session] = override + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 503 + assert response.json()["detail"] == "Base de donnees injoignable" + + +@pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) +async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None: + assert (await client.get(path)).status_code == 200 diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py new file mode 100644 index 0000000..d0d5873 --- /dev/null +++ b/apps/backend/tests/conftest.py @@ -0,0 +1,32 @@ +import os +from collections.abc import AsyncIterator, Iterator + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.core.config import get_settings +from app.main import create_app + + +@pytest.fixture(autouse=True, scope="session") +def environment() -> Iterator[None]: + os.environ.setdefault("APP_SECRET_KEY", "secret-de-test") + os.environ.setdefault( + "DATABASE_URL", "postgresql+asyncpg://enervision:enervision@localhost:5432/enervision_test" + ) + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture +def app() -> FastAPI: + return create_app() + + +@pytest.fixture +async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as async_client: + yield async_client diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock new file mode 100644 index 0000000..f799110 --- /dev/null +++ b/apps/backend/uv.lock @@ -0,0 +1,915 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +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 = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[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 = "enervision-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "prometheus-fastapi-instrumentator" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-json-logger" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.20.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "fastapi", specifier = ">=0.141.1" }, + { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, + { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "python-json-logger", specifier = ">=4.2.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.52" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mypy", specifier = ">=2.3.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.16.7" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +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/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/f4/cdcebf7094b03b99fba71ac8f56bd6f227973642662f49d272332d8419b3/prometheus_fastapi_instrumentator-8.1.0.tar.gz", hash = "sha256:b77f3043665e8d28e2bbd21017506195a43d9adf1d402d01bf95b494b7e560e1", size = 20492, upload-time = "2026-07-26T11:12:44.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/91a2246e6cf01b7ccb14479803c8a50f9c258ae5c6a0f16ba3294820632b/prometheus_fastapi_instrumentator-8.1.0-py3-none-any.whl", hash = "sha256:b9f40b2cff3f7891ca0610b3ae4fc6ec723fd326b04bb659819aaeb821a0fc7d", size = 19649, upload-time = "2026-07-26T11:12:45.168Z" }, +] + +[[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/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +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 = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +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-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[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-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +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-json-logger" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/25/5473e46b179f8e8b4ad3aeeb36773d1701b7770eaf5e5bc2025c7303b598/python_json_logger-4.2.0.tar.gz", hash = "sha256:e371ebe22ec01e289850102091a2b1f6fc9e655c7f1f5f29073936756c290afa", size = 18211, upload-time = "2026-08-15T11:36:38.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/55/6467fde553886cb293e41538f3a8b4e4fd4688c6df242cf982162d8367fb/python_json_logger-4.2.0-py3-none-any.whl", hash = "sha256:158a52126fcd6869e09574d2b66272666f3dc8f468c62637ef9a1fa883719cb9", size = 14988, upload-time = "2026-08-15T11:36:36.821Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +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" } +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" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +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/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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 = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +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 = "uvicorn" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +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/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/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, +] + +[[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/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { 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" }, +] From 91f4f007d389c4e1a1367073a63ce12ebaaa146e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 14:13:45 +0200 Subject: [PATCH 003/205] fix(backend): corrige la syntaxe du bloc except de la sonde de disponibilite `except SQLAlchemyError, OSError:` est de la syntaxe Python 2. Le module health.py ne s'importait pas, ce qui cassait make dev, make test, make typecheck et alembic. --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index b3c8523..711bf89 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -24,7 +24,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: await session.execute(text("SELECT 1")) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de donnees injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From 351e928309bc9d69a580a2b98bf9d1d3bb375458 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 14:19:25 +0200 Subject: [PATCH 004/205] feat(db): bootstrap PostgreSQL et extension TimescaleDB Service `db` du docker-compose racine sur timescale/timescaledb-ha:pg17, volume nomme et cibles Makefile db-up / db-down / db-reset / db-logs / db-psql. - db/init/100-extensions.sql declare l'extension attendue, db/init/110 cree la base enervision_test utilisee par la suite de tests du backend. - Numerotation a partir de 100 : l'image depose ses propres scripts 000, 001 et 010, et un prefixe a deux chiffres se trie avant 010 en locale C. - Volume monte sur /home/postgres/pgdata/data, PGDATA de cette image. Monte au chemin habituel de l'image postgres, il ne retiendrait rien sans erreur. - Port publie 5433 par defaut, 5432 etant souvent deja pris sur un poste. --- .env.example | 15 ++++++++++++ Makefile | 26 +++++++++++++++++++-- db/README.md | 26 ++++++++++++++++++++- db/init/.gitkeep | 0 db/init/100-extensions.sql | 4 ++++ db/init/110-test-database.sql | 8 +++++++ docker-compose.yml | 43 +++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 .env.example delete mode 100644 db/init/.gitkeep create mode 100644 db/init/100-extensions.sql create mode 100644 db/init/110-test-database.sql create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4d97678 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Variables lues par docker-compose.yml a la racine. +# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. + +POSTGRES_USER=enervision +POSTGRES_PASSWORD=change_me +POSTGRES_DB=enervision +# 5432 est souvent deja pris par une autre base du poste. +POSTGRES_PORT=5433 + +APP_ENV=local +APP_DEBUG=true +APP_LOG_LEVEL=INFO +APP_SECRET_KEY=change_me +APP_CORS_ORIGINS=http://localhost:4200 +BACKEND_PORT=8000 diff --git a/Makefile b/Makefile index 71dea97..aa29df8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ BACKEND := apps/backend .DEFAULT_GOAL := help -.PHONY: help install dev lint format typecheck test check docker-build +.PHONY: help install dev lint format typecheck test test-integration check docker-build \ + db-up db-down db-reset db-logs db-psql migrate help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -21,10 +22,31 @@ format: ## Formate et corrige le backend typecheck: ## Verifie le typage du backend cd $(BACKEND) && uv run mypy app -test: ## Execute les tests backend +test: ## Execute les tests backend ne demandant pas de base cd $(BACKEND) && uv run pytest +test-integration: ## Execute les tests exigeant une base joignable + cd $(BACKEND) && uv run pytest -m integration + check: lint typecheck test ## Chaine de verification complete docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) + +db-up: ## Demarre la base PostgreSQL TimescaleDB + docker compose up -d db + +db-down: ## Arrete la base en conservant ses donnees + docker compose stop db + +db-reset: ## Detruit la base et rejoue db/init + docker compose down -v && docker compose up -d db + +db-logs: ## Suit les journaux de la base + docker compose logs -f db + +db-psql: ## Ouvre une session psql sur la base applicative + docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision} + +migrate: ## Applique les migrations Alembic + cd $(BACKEND) && uv run alembic upgrade head diff --git a/db/README.md b/db/README.md index e80cb3c..c21a87d 100644 --- a/db/README.md +++ b/db/README.md @@ -1,6 +1,7 @@ # Base de donnees -PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie. +PostgreSQL 17 avec l'extension TimescaleDB, servie en local par le service `db` du +`docker-compose.yml` racine (image `timescale/timescaledb-ha:pg17`). - `init` : scripts de bootstrap joues au premier demarrage du conteneur. - `migrations` : migrations SQL versionnees. @@ -8,3 +9,26 @@ PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie. Les migrations du schema applicatif expose par l'API vivent dans `apps/backend/alembic`, pas ici. + +## `init` ne rejoue jamais + +Le dossier est monte sur `/docker-entrypoint-initdb.d`, dont PostgreSQL ne joue le +contenu qu'a la toute premiere initialisation, quand `PGDATA` est vide. Modifier ou +ajouter un script ensuite reste sans effet sur une base existante : + +```bash +docker compose down -v && docker compose up -d db +``` + +L'image joue d'abord ses propres scripts (`000_`, `001_`, `010_`), dont un +`CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit CASCADE` qui installe `timescaledb` +au passage dans `postgres`, `template1` et la base applicative. Nos fichiers sont +numerotes a partir de `100` pour passer apres, quelle que soit la locale de tri. + +| Script | Role | +|---|---| +| `100-extensions.sql` | Declare explicitement les extensions attendues. | +| `110-test-database.sql` | Cree `enervision_test`, attendue par la suite de tests du backend. | + +Comme un bootstrap peut toujours avoir ete saute, c'est `/api/v1/health/ready` qui fait +foi : la sonde refuse de repondre 200 si l'extension n'est pas chargee. diff --git a/db/init/.gitkeep b/db/init/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/db/init/100-extensions.sql b/db/init/100-extensions.sql new file mode 100644 index 0000000..2326d5d --- /dev/null +++ b/db/init/100-extensions.sql @@ -0,0 +1,4 @@ +-- Piege : ce script ne rejoue qu'a la premiere initialisation, quand PGDATA est vide. +-- Le modifier ensuite reste sans effet tant que le volume n'est pas detruit. + +CREATE EXTENSION IF NOT EXISTS timescaledb; diff --git a/db/init/110-test-database.sql b/db/init/110-test-database.sql new file mode 100644 index 0000000..0f47b63 --- /dev/null +++ b/db/init/110-test-database.sql @@ -0,0 +1,8 @@ +-- Contrainte : le nom de cette base est code en dur dans apps/backend/tests/conftest.py. +-- Elle sert la suite de tests de la stack locale, pas un deploiement. + +CREATE DATABASE enervision_test; + +\connect enervision_test + +CREATE EXTENSION IF NOT EXISTS timescaledb; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e6854d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +# Piege : PGDATA de l'image timescaledb-ha vaut /home/postgres/pgdata/data, pas le chemin +# habituel de l'image postgres. Monte ailleurs, le volume ne retient rien, sans erreur. + +name: enervision + +services: + db: + image: timescale/timescaledb-ha:pg17 + environment: + POSTGRES_USER: ${POSTGRES_USER:?} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + POSTGRES_DB: ${POSTGRES_DB:?} + ports: + - "${POSTGRES_PORT:-5433}:5432" + volumes: + - pgdata:/home/postgres/pgdata/data + - ./db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s + restart: unless-stopped + + backend: + build: ./apps/backend + depends_on: + db: + condition: service_healthy + environment: + APP_ENV: ${APP_ENV:-local} + APP_DEBUG: ${APP_DEBUG:-false} + APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO} + APP_SECRET_KEY: ${APP_SECRET_KEY:?} + APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + ports: + - "${BACKEND_PORT:-8000}:8000" + restart: unless-stopped + +volumes: + pgdata: From 20e7374d90b9c6fdded2e4cf3c62a3061d5c0f78 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 14:19:25 +0200 Subject: [PATCH 005/205] feat(backend): verifie l extension TimescaleDB sur la sonde de disponibilite /api/v1/health/ready interrogeait la base par un SELECT 1, qui ne distingue pas un PostgreSQL nu d'un PostgreSQL avec TimescaleDB. La sonde lit desormais pg_extension et repond 503 si l'extension manque, cas qui survient quand db/init n'a pas ete joue. - Premiere revision Alembic : aucune table, une garde qui refuse de s'appliquer sans l'extension. - Tests du chemin nominal et de l'extension absente, plus un test marque `integration` contre la vraie base. pytest ecarte ce marqueur par defaut pour que make check reste jouable sans Docker. - conftest recycle l'engine entre les tests : get_engine est lru_cache alors que pytest-asyncio ouvre une boucle par test, et les connexions asyncpg sont liees a leur boucle. --- apps/backend/.env.example | 2 +- apps/backend/README.md | 14 ++++- ...4f094_socle_garde_extension_timescaledb.py | 37 ++++++++++++++ apps/backend/app/api/v1/endpoints/health.py | 16 ++++-- apps/backend/app/schemas/health.py | 1 + apps/backend/pyproject.toml | 3 +- apps/backend/tests/api/test_health.py | 51 ++++++++++++++++++- apps/backend/tests/conftest.py | 14 ++++- 8 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 258db03..cd96463 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -3,4 +3,4 @@ APP_DEBUG=true APP_LOG_LEVEL=INFO APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 -DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5432/enervision +DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision diff --git a/apps/backend/README.md b/apps/backend/README.md index bd1c6fa..d70b3ca 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -22,6 +22,9 @@ uv sync --all-groups `APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse de demarrer sans elles. +`DATABASE_URL` pointe sur `localhost:5433`, le port publie par le service `db` du +`docker-compose.yml` racine. Demarrer la base depuis la racine avec `make db-up`. + ## Commandes Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`, @@ -35,8 +38,13 @@ uv run ruff check . # lint uv run ruff format . # format uv run mypy app # typage strict uv run pytest # tests + couverture +uv run pytest -m integration # tests exigeant une base joignable ``` +`pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste +jouable sans Docker. Ces tests visent la base `enervision_test`, creee par +`db/init/110-test-database.sql` au premier demarrage du conteneur. + L'application est exposee par une factory (`create_app`) et non par un objet module : aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations independants de l'environnement. @@ -73,7 +81,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | Route | Role | |------------------------|-------------------------------------------------| | `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | -| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base | +| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB | | `/metrics` | Metriques au format Prometheus | | `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | @@ -86,6 +94,10 @@ uv run alembic upgrade head L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`. +La premiere revision ne cree aucune table : elle refuse de s'appliquer si l'extension +TimescaleDB manque, ce qui arrive quand `db/init` n'a pas ete joue. Le DDL propre a +TimescaleDB qui ne depend pas du schema applicatif vit dans `db/`, pas ici. + ## Image Docker Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un diff --git a/apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py b/apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py new file mode 100644 index 0000000..de14bd3 --- /dev/null +++ b/apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py @@ -0,0 +1,37 @@ +"""socle garde extension timescaledb + +Revision ID: 5353c0e4f094 +Revises: +Create Date: 2026-09-14 14:17:17.556764 + +Premiere revision du schema applicatif. Elle ne cree aucune table : elle etablit +alembic_version et refuse de s'appliquer sur une base ou l'extension TimescaleDB +manque, cas qui se produit quand db/init n'a pas ete joue. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "5353c0e4f094" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +GARDE_EXTENSION = """ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN + RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md'; + END IF; +END +$$; +""" + + +def upgrade() -> None: + op.execute(GARDE_EXTENSION) + + +def downgrade() -> None: + pass diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 711bf89..be3abf8 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -9,6 +9,8 @@ from app.schemas.health import LivenessStatus, ReadinessStatus logger = get_logger(__name__) router = APIRouter(tags=["health"]) +TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'") + @router.get("/live", summary="Sonde de vivacite") async def liveness(settings: SettingsDep) -> LivenessStatus: @@ -23,11 +25,19 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: @router.get("/ready", summary="Sonde de disponibilite") async def readiness(session: SessionDep) -> ReadinessStatus: try: - await session.execute(text("SELECT 1")) - except (SQLAlchemyError, OSError): + version: str | None = await session.scalar(TIMESCALEDB_VERSION) + except SQLAlchemyError, OSError: logger.exception("Base de donnees injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Base de donnees injoignable", ) from None - return ReadinessStatus(status="ready", database="reachable") + + if version is None: + logger.error("Extension TimescaleDB absente de la base") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Extension TimescaleDB absente", + ) + + return ReadinessStatus(status="ready", database="reachable", timescaledb=version) diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py index d1eb845..e4ec86e 100644 --- a/apps/backend/app/schemas/health.py +++ b/apps/backend/app/schemas/health.py @@ -13,3 +13,4 @@ class LivenessStatus(BaseModel): class ReadinessStatus(BaseModel): status: Literal["ready"] database: Literal["reachable"] + timescaledb: str diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 87c916a..fee4044 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -79,7 +79,8 @@ disallow_untyped_defs = false [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" -addopts = "-q --strict-markers --cov=app --cov-report=term-missing" +addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing" +markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"] [tool.coverage.run] source = ["app"] diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index 6a2f938..f0d647f 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -20,6 +20,44 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } +async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None: + class ReadySession: + async def scalar(self, *_: object, **__: object) -> str: + return "2.22.1" + + async def override() -> AsyncIterator[ReadySession]: + yield ReadySession() + + app.dependency_overrides[get_session] = override + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 200 + assert response.json() == { + "status": "ready", + "database": "reachable", + "timescaledb": "2.22.1", + } + + +async def test_readiness_returns_503_when_the_extension_is_missing( + app: FastAPI, client: AsyncClient +) -> None: + class SessionWithoutExtension: + async def scalar(self, *_: object, **__: object) -> None: + return None + + async def override() -> AsyncIterator[SessionWithoutExtension]: + yield SessionWithoutExtension() + + app.dependency_overrides[get_session] = override + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 503 + assert response.json()["detail"] == "Extension TimescaleDB absente" + + @pytest.mark.parametrize( "failure", [ @@ -32,7 +70,7 @@ async def test_readiness_returns_503_when_database_is_unreachable( app: FastAPI, client: AsyncClient, failure: Exception ) -> None: class UnreachableSession: - async def execute(self, *_: object, **__: object) -> None: + async def scalar(self, *_: object, **__: object) -> None: raise failure async def override() -> AsyncIterator[UnreachableSession]: @@ -49,3 +87,14 @@ async def test_readiness_returns_503_when_database_is_unreachable( @pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None: assert (await client.get(path)).status_code == 200 + + +@pytest.mark.integration +async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None: + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 200, response.text + body = response.json() + assert body["status"] == "ready" + assert body["database"] == "reachable" + assert body["timescaledb"] diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index d0d5873..ac0e89b 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app.core.config import get_settings +from app.db.session import get_engine, get_session_factory from app.main import create_app @@ -13,13 +14,24 @@ from app.main import create_app def environment() -> Iterator[None]: os.environ.setdefault("APP_SECRET_KEY", "secret-de-test") os.environ.setdefault( - "DATABASE_URL", "postgresql+asyncpg://enervision:enervision@localhost:5432/enervision_test" + "DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test" ) get_settings.cache_clear() yield get_settings.cache_clear() +# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce +# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte. +@pytest.fixture(autouse=True) +async def engine_per_test() -> AsyncIterator[None]: + yield + if get_engine.cache_info().currsize: + await get_engine().dispose() + get_engine.cache_clear() + get_session_factory.cache_clear() + + @pytest.fixture def app() -> FastAPI: return create_app() From f4d05a8ca9c473923ecdcf3e8a6b8d471d8093a8 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 14:19:25 +0200 Subject: [PATCH 006/205] docs: ADR du choix PostgreSQL TimescaleDB Acte le choix de l'extension plutot qu'un second SGBD, celui de l'image -ha et celui de PG17. Fixe surtout la frontiere db/init contre db/migrations contre apps/backend/alembic, qui n'est deductible d'aucun fichier. --- README.md | 26 ++++++++-- docs/adr/.gitkeep | 0 docs/adr/0001-postgresql-timescaledb.md | 66 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) delete mode 100644 docs/adr/.gitkeep create mode 100644 docs/adr/0001-postgresql-timescaledb.md diff --git a/README.md b/README.md index b6ab9c8..12342ac 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ series temporelles energetiques, deployee sur une machine on-premise. |------------|-------------------------------------|---------------------|---------------| | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser | -| Base | PostgreSQL + TimescaleDB | `db` | A initialiser | +| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform | `infra/terraform` | A initialiser | | CI/CD | GitHub Actions | `.github/workflows` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | -Seul le backend est initialise a ce stade. Les autres dossiers portent l'arborescence et -un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +Le backend et la base sont initialises a ce stade. Les autres dossiers portent +l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. ## Arborescence @@ -50,13 +50,33 @@ un README de cadrage, leur contenu fait l'objet d'un ticket dedie. Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul. ```bash +cp .env.example .env # variables de docker-compose +cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur + +make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433 make install # dependances du backend +make migrate # applique les migrations Alembic make dev # API sur http://localhost:8000, docs sur /docs make check # lint + typage + tests ``` `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 service `backend` du `docker-compose.yml` sert la stack complete et la recette, +et n'embarque pas le source, donc toute modification y demande un +`docker compose up -d --build backend`. + +Verifier que la base repond et que l'extension est chargee : + +```bash +curl -s localhost:8000/api/v1/health/ready +``` + ## Conventions - Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court. diff --git a/docs/adr/.gitkeep b/docs/adr/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/adr/0001-postgresql-timescaledb.md b/docs/adr/0001-postgresql-timescaledb.md new file mode 100644 index 0000000..4ed562b --- /dev/null +++ b/docs/adr/0001-postgresql-timescaledb.md @@ -0,0 +1,66 @@ +# 0001 - PostgreSQL avec l'extension TimescaleDB + +- Statut : accepte +- Date : 2026-09-14 + +## Contexte + +EnerVision collecte, stocke et restitue des series temporelles energetiques sur une machine +on-premise. La charge est dominee par des insertions horodatees en flux et par des lectures +agregees sur des fenetres de temps. Airflow produira des agregations continues, Grafana lira +les memes donnees, et l'API FastAPI les exposera. + +Un SGBD relationnel generaliste sait faire, mais degrade a mesure que la table de mesures +grossit : les index se fragmentent, les balayages de fenetre deviennent couteux, et il faut +ecrire a la main le partitionnement, la retention et les agregats pre-calcules. + +## Decision + +PostgreSQL 17 avec l'extension TimescaleDB, servie en local par l'image +`timescale/timescaledb-ha:pg17`. + +PostgreSQL reste une base relationnelle standard : un seul SGBD pour les donnees metier et +les mesures, un seul dialecte SQL, un seul pilote (`asyncpg`), et l'outillage habituel. +TimescaleDB ajoute le partitionnement automatique, les agregations continues et les +politiques de retention sans changer de moteur. + +L'image `-ha` plutot que l'image alpine : elle embarque `timescaledb_toolkit`, `postgis` et +`pgvector`. Le toolkit porte les fonctions de comblement de trous et d'analyse de series dont +l'ETL aura besoin, et changer d'image plus tard imposerait une reinitialisation du volume. + +PG17 plutot que PG18 : c'est la version la mieux couverte par Airflow et Grafana a ce jour. + +## Frontiere entre `db/` et `apps/backend/alembic/` + +C'est la regle que ce document existe surtout pour fixer. + +- `db/init/` : bootstrap joue **une seule fois**, a la premiere initialisation du conteneur. + Extensions, bases annexes. Ne rejoue jamais sur un volume existant. +- `db/migrations/` : SQL versionne qui ne decoule pas du schema applicatif, typiquement les + politiques de retention et de compression TimescaleDB. +- `apps/backend/alembic/` : le schema expose par l'API, et lui seul. C'est `Base.metadata` + qui fait foi. + +Une hypertable relevera des deux : Alembic cree la table, et le `create_hypertable()` vit +dans la meme revision Alembic, parce que separer les deux rendrait le schema irreproductible +depuis un seul `alembic upgrade head`. + +## Consequences + +- Le projet se lie a une extension, donc a un hebergement qui l'autorise. C'est acquis + puisque le deploiement est on-premise. +- `CREATE EXTENSION` demande le superutilisateur : cela reste un acte de bootstrap, pas une + migration applicative. +- Un bootstrap saute ne se voit pas au demarrage de l'API. Deux gardes couvrent ce cas : + `/api/v1/health/ready` repond 503 si l'extension est absente, et la premiere revision + Alembic refuse de s'appliquer. +- L'image `-ha` pese environ 1 Go, a telecharger une fois par poste. + +## Alternatives ecartees + +- **PostgreSQL nu, partitionnement manuel** : faisable, mais il faudrait reecrire ce que + TimescaleDB fournit, et le maintenir. +- **InfluxDB** : tres bon sur la serie temporelle, mais imposerait un second SGBD pour le + relationnel, donc deux dialectes, deux sauvegardes et des jointures applicatives. +- **ClickHouse** : taille pour un volume analytique que le projet n'atteindra pas, et moins + a l'aise sur les ecritures unitaires frequentes du flux d'ingestion. From 49f46978b0348c856541a58c5ed791d481bc695b Mon Sep 17 00:00:00 2001 From: valentin Date: Mon, 14 Sep 2026 14:28:44 +0200 Subject: [PATCH 007/205] Mise en place du frontend Angular (init, environments, proxy vers le backend) --- apps/frontend/.editorconfig | 17 + apps/frontend/.gitignore | 44 + apps/frontend/.prettierrc | 12 + apps/frontend/README.md | 70 +- apps/frontend/angular.json | 85 + apps/frontend/package-lock.json | 8069 +++++++++++++++++ apps/frontend/package.json | 32 + apps/frontend/proxy.conf.json | 8 + apps/frontend/public/favicon.ico | Bin 0 -> 15086 bytes apps/frontend/src/app/app.config.ts | 7 + apps/frontend/src/app/app.html | 353 + apps/frontend/src/app/app.routes.ts | 3 + apps/frontend/src/app/app.scss | 0 apps/frontend/src/app/app.spec.ts | 23 + apps/frontend/src/app/app.ts | 12 + .../environments/environment.development.ts | 4 + apps/frontend/src/environments/environment.ts | 4 + apps/frontend/src/index.html | 13 + apps/frontend/src/main.ts | 5 + apps/frontend/src/styles.scss | 1 + apps/frontend/tsconfig.app.json | 10 + apps/frontend/tsconfig.json | 31 + apps/frontend/tsconfig.spec.json | 9 + 23 files changed, 8804 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/.editorconfig create mode 100644 apps/frontend/.gitignore create mode 100644 apps/frontend/.prettierrc create mode 100644 apps/frontend/angular.json create mode 100644 apps/frontend/package-lock.json create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/proxy.conf.json create mode 100644 apps/frontend/public/favicon.ico create mode 100644 apps/frontend/src/app/app.config.ts create mode 100644 apps/frontend/src/app/app.html create mode 100644 apps/frontend/src/app/app.routes.ts create mode 100644 apps/frontend/src/app/app.scss create mode 100644 apps/frontend/src/app/app.spec.ts create mode 100644 apps/frontend/src/app/app.ts create mode 100644 apps/frontend/src/environments/environment.development.ts create mode 100644 apps/frontend/src/environments/environment.ts create mode 100644 apps/frontend/src/index.html create mode 100644 apps/frontend/src/main.ts create mode 100644 apps/frontend/src/styles.scss create mode 100644 apps/frontend/tsconfig.app.json create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/frontend/tsconfig.spec.json diff --git a/apps/frontend/.editorconfig b/apps/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/apps/frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 0000000..854acd5 --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/apps/frontend/.prettierrc b/apps/frontend/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/apps/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 36b31b4..ce9c73c 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,10 +1,62 @@ # Frontend EnerVision -Le squelette applicatif n'est pas versionne a la main : il est genere par Angular CLI. +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.8. -## Initialisation +## Development server -Depuis `apps/` : +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Configuration spécifique au projet EnerVision + +Le squelette applicatif n'est pas versionné à la main : il a été généré par Angular CLI avec la commande suivante, depuis `apps/` : ```bash npx --yes @angular/cli@latest new frontend \ @@ -16,11 +68,13 @@ npx --yes @angular/cli@latest new frontend \ --skip-git ``` -Le dossier `apps/frontend` doit etre vide (hors ce README) avant de lancer la commande. - -## Apres generation +Points à vérifier après toute regénération : 1. Pointer l'API dans `src/environments/` sur `http://localhost:8000/api/v1`. -2. Ajouter le proxy de developpement (`proxy.conf.json`) vers le backend. -3. Verifier que `npm start` sert bien sur le port 4200 attendu par `docker-compose.yml`. +2. Ajouter le proxy de développement (`proxy.conf.json`) vers le backend. +3. Vérifier que `npm start` sert bien sur le port 4200 attendu par `docker-compose.yml`. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json new file mode 100644 index 0000000..3140772 --- /dev/null +++ b/apps/frontend/angular.json @@ -0,0 +1,85 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.scss"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, + "configurations": { + "production": { + "buildTarget": "frontend:build:production" + }, + "development": { + "buildTarget": "frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json new file mode 100644 index 0000000..8f4408b --- /dev/null +++ b/apps/frontend/package-lock.json @@ -0,0 +1,8069 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "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", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.1.8", + "@angular/cli": "^22.1.8", + "@angular/compiler-cli": "^22.1.0", + "jsdom": "^28.0.0", + "prettier": "^3.8.1", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } + }, + "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", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2201.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2201.8.tgz", + "integrity": "sha512-EUQo8RDS1my2Bo5FRS+gBYgz1/klfIp9XESfMpTBO04nBkjF6DkPCeOxeAf1CYjWy9nCXcWn968eSdEhV5jXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.8.tgz", + "integrity": "sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.5", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.8.tgz", + "integrity": "sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "jsonc-parser": "3.3.1", + "magic-string": "1.0.0", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/build": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.1.8.tgz", + "integrity": "sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2201.8", + "@babel/core": "8.0.1", + "@babel/helper-annotate-as-pure": "8.0.0", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "6.1.1", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.4.3", + "browserslist": "^4.26.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.0.0", + "magic-string": "1.0.0", + "mrmime": "2.0.1", + "oxc-parser": "0.142.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.5", + "piscina": "5.2.0", + "rolldown": "1.2.0", + "sass": "1.101.0", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.1.5", + "watchpack": "2.5.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.6" + }, + "peerDependencies": { + "@angular/compiler": "^22.0.0", + "@angular/compiler-cli": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/localize": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/service-worker": "^22.0.0", + "@angular/ssr": "^22.1.8", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^22.0.0", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/cli": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.1.8.tgz", + "integrity": "sha512-MFN/FydqDndai8vF12hDxG5RlBt1HUUheeJgJNmuHYIne4bOAfOT+xuMpIK8+gLSRwCemdqR3OMM38pnsFJ/nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2201.8", + "@angular-devkit/core": "22.1.8", + "@angular-devkit/schematics": "22.1.8", + "@inquirer/prompts": "8.5.2", + "@listr2/prompt-adapter-inquirer": "4.2.5", + "@modelcontextprotocol/sdk": "1.30.0", + "@schematics/angular": "22.1.8", + "jsonc-parser": "3.3.1", + "listr2": "11.0.0", + "npm-package-arg": "14.0.0", + "parse5-html-rewriting-stream": "8.0.1", + "semver": "7.8.5", + "yargs": "18.1.0", + "zod": "4.4.3" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.1.6", + "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==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "8.0.1", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.1.6", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/core": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", + "integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.10" + }, + "engines": { + "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", + "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==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "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" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "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", + "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==", + "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-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "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", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.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", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.5.tgz", + "integrity": "sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.5", + "@babel/types": "^8.0.5", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.5.tgz", + "integrity": "sha512-Qk8ahMGooH5mz6uuhoDvfZGkUf/Mf3RTBucVVl4MKx4LKMTv872TeW8O92h15iVtlN8wAROBIpI1aV6x1z0LCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.5.tgz", + "integrity": "sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz", + "integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.5.tgz", + "integrity": "sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.5", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", + "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", + "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", + "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", + "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", + "integrity": "sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "11.0.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", + "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.6.tgz", + "integrity": "sha512-foa+pwitysO8k+xhs7psBFfTKnVgR69NlZRRTHaFVDqphh7AdGpLeyRzKw/ofatr/sN6TiHRRW6mmop0ZrrppQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.6.tgz", + "integrity": "sha512-QR4YRyR5h5Z8eGXrNQjiyo2NNDfqi3tCc9dQG5Is1blCt+qWw1ZoBWhlWAr5d+jshkifMIJjVHzHGKbkKzF8Tw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.6.tgz", + "integrity": "sha512-HmiyFFdJa38s1heCMSooSPaBSFTHJ3C+ERPp28xAPlDX1YiALJVOgbry065nXd8Y7KISWjnw05zpG1RX8IfftA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.6.tgz", + "integrity": "sha512-ADzCuCF2cTNiX9kDScqcz1fjnAkxPpQNneV3KFTdV3wWtVlI2sTGzySoMTgDpinkMMFj1NTJlxA6XR8fwc4hlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.6.tgz", + "integrity": "sha512-J7A9aEQsQiv0TYtBGL7NDIPp2lOS8nnl+zm4sWZm1xlsTTaQ4PgD096Adzdrk27rw3UxCkDXdCUa4ax41oztBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.6.tgz", + "integrity": "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz", + "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz", + "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz", + "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz", + "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz", + "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz", + "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz", + "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz", + "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz", + "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz", + "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz", + "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz", + "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz", + "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz", + "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz", + "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz", + "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz", + "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz", + "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz", + "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz", + "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", + "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz", + "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz", + "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz", + "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz", + "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz", + "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@schematics/angular": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.8.tgz", + "integrity": "sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "@angular-devkit/schematics": "22.1.8", + "jsonc-parser": "3.3.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.3.tgz", + "integrity": "sha512-fIIeLOcbAB/K1kb1HBVJoiq1alHL4RCYBSo5e7HzrNkkgMggXR1Vqt/Z9JWnkfe/qdCo66Ux3QRwZioAIBdWRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "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", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz", + "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "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", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.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", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/listr2": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-11.0.0.tgz", + "integrity": "sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^6.1.1", + "log-update": "^8.0.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/lmdb": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.6.tgz", + "integrity": "sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.6", + "@lmdb/lmdb-darwin-x64": "3.5.6", + "@lmdb/lmdb-linux-arm": "3.5.6", + "@lmdb/lmdb-linux-arm64": "3.5.6", + "@lmdb/lmdb-linux-x64": "3.5.6", + "@lmdb/lmdb-win32-arm64": "3.5.6", + "@lmdb/lmdb-win32-x64": "3.5.6" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-8.0.0.tgz", + "integrity": "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.3.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0", + "strip-ansi": "^7.2.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", + "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-package-arg": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-14.0.0.tgz", + "integrity": "sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^10.1.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/oxc-parser": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz", + "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.142.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.142.0", + "@oxc-parser/binding-android-arm64": "0.142.0", + "@oxc-parser/binding-darwin-arm64": "0.142.0", + "@oxc-parser/binding-darwin-x64": "0.142.0", + "@oxc-parser/binding-freebsd-x64": "0.142.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", + "@oxc-parser/binding-linux-arm64-musl": "0.142.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", + "@oxc-parser/binding-linux-x64-gnu": "0.142.0", + "@oxc-parser/binding-linux-x64-musl": "0.142.0", + "@oxc-parser/binding-openharmony-arm64": "0.142.0", + "@oxc-parser/binding-wasm32-wasi": "0.142.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", + "@oxc-parser/binding-win32-x64-msvc": "0.142.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz", + "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.140.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.0", + "@rolldown/binding-darwin-arm64": "1.2.0", + "@rolldown/binding-darwin-x64": "1.2.0", + "@rolldown/binding-freebsd-x64": "1.2.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", + "@rolldown/binding-linux-arm64-gnu": "1.2.0", + "@rolldown/binding-linux-arm64-musl": "1.2.0", + "@rolldown/binding-linux-ppc64-gnu": "1.2.0", + "@rolldown/binding-linux-s390x-gnu": "1.2.0", + "@rolldown/binding-linux-x64-gnu": "1.2.0", + "@rolldown/binding-linux-x64-musl": "1.2.0", + "@rolldown/binding-openharmony-arm64": "1.2.0", + "@rolldown/binding-wasm32-wasi": "1.2.0", + "@rolldown/binding-win32-arm64-msvc": "1.2.0", + "@rolldown/binding-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.140.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz", + "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-8.0.0.tgz", + "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verkit": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..7369db4 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "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", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.1.8", + "@angular/cli": "^22.1.8", + "@angular/compiler-cli": "^22.1.0", + "jsdom": "^28.0.0", + "prettier": "^3.8.1", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/apps/frontend/proxy.conf.json b/apps/frontend/proxy.conf.json new file mode 100644 index 0000000..3354dec --- /dev/null +++ b/apps/frontend/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:8000", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + } +} diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 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 Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts new file mode 100644 index 0000000..2261369 --- /dev/null +++ b/apps/frontend/src/app/app.config.ts @@ -0,0 +1,7 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], +}; diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html new file mode 100644 index 0000000..4f4ddf5 --- /dev/null +++ b/apps/frontend/src/app/app.html @@ -0,0 +1,353 @@ + + + + + + + + + + + +
+
+
+ +

Hello, {{ title() }}

+

Congratulations! Your app is running. 🎉

+
+ +
+
+ @for ( + item of [ + { title: 'Explore the Docs', link: 'https://angular.dev' }, + { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, + { + title: 'Prompt and best practices for AI', + link: 'https://angular.dev/ai/develop-with-ai', + }, + { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, + { + title: 'Angular Language Service', + link: 'https://angular.dev/tools/language-service', + }, + { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, + ]; + track item.title + ) { + + {{ item.title }} + + + + + } +
+ +
+
+
+ + + + + + + + + + diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..dc39edb --- /dev/null +++ b/apps/frontend/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/apps/frontend/src/app/app.scss b/apps/frontend/src/app/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/app/app.spec.ts b/apps/frontend/src/app/app.spec.ts new file mode 100644 index 0000000..f13c264 --- /dev/null +++ b/apps/frontend/src/app/app.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', async () => { + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); + }); +}); diff --git a/apps/frontend/src/app/app.ts b/apps/frontend/src/app/app.ts new file mode 100644 index 0000000..5bc92fa --- /dev/null +++ b/apps/frontend/src/app/app.ts @@ -0,0 +1,12 @@ +import { Component, signal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + imports: [RouterOutlet], + selector: 'app-root', + styleUrl: './app.scss', + templateUrl: './app.html', +}) +export class App { + protected readonly title = signal('frontend'); +} diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts new file mode 100644 index 0000000..5ebc640 --- /dev/null +++ b/apps/frontend/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: '/api/v1' +}; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts new file mode 100644 index 0000000..5c2010d --- /dev/null +++ b/apps/frontend/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiUrl: 'http://localhost:8000/api/v1' +}; diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html new file mode 100644 index 0000000..71cdfcc --- /dev/null +++ b/apps/frontend/src/index.html @@ -0,0 +1,13 @@ + + + + + Frontend + + + + + + + + diff --git a/apps/frontend/src/main.ts b/apps/frontend/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/apps/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss new file mode 100644 index 0000000..90d4ee0 --- /dev/null +++ b/apps/frontend/src/styles.scss @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/apps/frontend/tsconfig.app.json b/apps/frontend/tsconfig.app.json new file mode 100644 index 0000000..1eb42f4 --- /dev/null +++ b/apps/frontend/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/frontend/tsconfig.spec.json b/apps/frontend/tsconfig.spec.json new file mode 100644 index 0000000..aecce35 --- /dev/null +++ b/apps/frontend/tsconfig.spec.json @@ -0,0 +1,9 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} From 6bc2c3793f47a6c7f45d576432ccc1981e54779f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 14:28:49 +0200 Subject: [PATCH 008/205] fix(db): monte db/init fichier par fichier et coupe la telemetrie Monter le dossier ./db/init sur /docker-entrypoint-initdb.d remplacait le dossier de l'image au lieu de s'y ajouter. Les trois scripts d'init livres par timescaledb-ha disparaissaient sans aucun message : creation de l'extension dans template1, reglage par timescaledb-tune, et installation de timescaledb_toolkit. Verifie au demarrage : le dossier ne contenait que nos deux fichiers, et timescaledb_toolkit etait absent des bases. Monter chaque fichier separement retablit l'ordre attendu, verifie dans les journaux : 000, 001, 010, puis 100 et 110. TIMESCALEDB_TELEMETRY passe a off par defaut : l'image envoie sinon des statistiques d'usage a Timescale, ce qui ne va pas pour un deploiement on-premise. --- .env.example | 2 ++ db/README.md | 35 ++++++++++++++++++++--------------- docker-compose.yml | 6 +++++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 4d97678..a5fba5a 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ POSTGRES_PASSWORD=change_me POSTGRES_DB=enervision # 5432 est souvent deja pris par une autre base du poste. POSTGRES_PORT=5433 +# `basic` renvoie des statistiques d'usage a Timescale. +TIMESCALEDB_TELEMETRY=off APP_ENV=local APP_DEBUG=true diff --git a/db/README.md b/db/README.md index c21a87d..fd62d6f 100644 --- a/db/README.md +++ b/db/README.md @@ -12,23 +12,28 @@ Les migrations du schema applicatif expose par l'API vivent dans ## `init` ne rejoue jamais -Le dossier est monte sur `/docker-entrypoint-initdb.d`, dont PostgreSQL ne joue le +Ces scripts sont montes sur `/docker-entrypoint-initdb.d`, dont PostgreSQL ne joue le contenu qu'a la toute premiere initialisation, quand `PGDATA` est vide. Modifier ou -ajouter un script ensuite reste sans effet sur une base existante : +ajouter un script ensuite reste sans effet sur une base existante : il faut detruire +le volume, ce que fait `make db-reset`. -```bash -docker compose down -v && docker compose up -d db -``` +L'image apporte ses propres scripts dans ce dossier, et ils comptent : -L'image joue d'abord ses propres scripts (`000_`, `001_`, `010_`), dont un -`CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit CASCADE` qui installe `timescaledb` -au passage dans `postgres`, `template1` et la base applicative. Nos fichiers sont -numerotes a partir de `100` pour passer apres, quelle que soit la locale de tri. +| Script | Origine | Role | +|---|---|---| +| `000_install_timescaledb.sh` | image | Cree l'extension dans `postgres`, `template1` et la base applicative, et fixe `timescaledb.telemetry_level`. | +| `001_timescaledb_tune.sh` | image | Lance `timescaledb-tune` sur la memoire et les CPU vus par le conteneur. | +| `010_install_timescaledb_toolkit.sh` | image | Ajoute `timescaledb_toolkit`. | +| `100-extensions.sql` | ce depot | Declare explicitement les extensions attendues. | +| `110-test-database.sql` | ce depot | Cree `enervision_test`, attendue par la suite de tests du backend. | -| Script | Role | -|---|---| -| `100-extensions.sql` | Declare explicitement les extensions attendues. | -| `110-test-database.sql` | Cree `enervision_test`, attendue par la suite de tests du backend. | +D'ou deux contraintes dans `docker-compose.yml`. Nos fichiers sont **montes un par un**, +et non par leur dossier : un montage de `./db/init` sur `/docker-entrypoint-initdb.d` +remplacerait le dossier de l'image au lieu de s'y ajouter, et ferait disparaitre les trois +scripts ci-dessus sans le moindre message. Ajouter un fichier ici impose donc d'ajouter +une ligne la-bas. Et leur numerotation commence a `100` pour passer apres `010`, y compris +en locale C ou un prefixe a deux chiffres se trierait avant. -Comme un bootstrap peut toujours avoir ete saute, c'est `/api/v1/health/ready` qui fait -foi : la sonde refuse de repondre 200 si l'extension n'est pas chargee. +Comme un bootstrap peut toujours avoir ete saute, deux gardes le rattrapent : +`/api/v1/health/ready` repond 503 si l'extension n'est pas chargee, et la premiere +revision Alembic refuse de s'appliquer. diff --git a/docker-compose.yml b/docker-compose.yml index e6854d8..d8569c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,7 @@ # Piege : PGDATA de l'image timescaledb-ha vaut /home/postgres/pgdata/data, pas le chemin # habituel de l'image postgres. Monte ailleurs, le volume ne retient rien, sans erreur. +# Piege : db/init est monte fichier par fichier. Monter le dossier masquerait les scripts +# d'init de l'image, dont timescaledb-tune. Ajouter un fichier impose une ligne ici. name: enervision @@ -10,11 +12,13 @@ services: POSTGRES_USER: ${POSTGRES_USER:?} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} POSTGRES_DB: ${POSTGRES_DB:?} + TIMESCALEDB_TELEMETRY: ${TIMESCALEDB_TELEMETRY:-off} ports: - "${POSTGRES_PORT:-5433}:5432" volumes: - pgdata:/home/postgres/pgdata/data - - ./db/init:/docker-entrypoint-initdb.d:ro + - ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro + - ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s From 0be2418e028082c20b3c4812f43c15adf3a4c211 Mon Sep 17 00:00:00 2001 From: ineszang Date: Mon, 14 Sep 2026 14:46:08 +0200 Subject: [PATCH 009/205] chore: Initialisation de Terraform --- .terraform.lock.hcl | 23 + .../docker/3.9.0/windows_386/CHANGELOG.md | 870 ++++++++++++++++++ .../docker/3.9.0/windows_386/LICENSE | 373 ++++++++ .../docker/3.9.0/windows_386/README.md | 117 +++ terraform.tf | 19 + 5 files changed, 1402 insertions(+) create mode 100644 .terraform.lock.hcl create mode 100644 .terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md create mode 100644 .terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE create mode 100644 .terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md create mode 100644 terraform.tf diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..da14477 --- /dev/null +++ b/.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/kreuzwerker/docker" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:MmhVJBgNpE2Fbksv/XObZJncwm4th4mFE7Ai+6LiIy4=", + "zh:0ead8281830e9b9496651282235d9a139ba1b1b6ff79e395eb8c78658dc446b9", + "zh:0f17d37d8d3872df3fb75c68b5272e0c981343f53b506a9675b4405191edd3ef", + "zh:11d50b37323874427c6d2a08b737d3c7707c8301fdd236c94485cf2828d0b14b", + "zh:32f6f9b847446054e2db3d72886ef2f1d1aa51a6d0dac42340b07dad18e3f28f", + "zh:5ea5c67668b5dcbda560dc6104b788a9bfc974d52f02f7886889b77cc0e5d248", + "zh:5fb19a0b07edc344cd3ddeeb9cfb3d183089deb7a6a94a7b22a583aa1712596b", + "zh:602a7ece444e2a142ec5245abb98e7a1a990a68afae2df63b6c85ec084f0c5d7", + "zh:693dce278524ad8a6d6c9dd7a01bcd63bb85189639198f8d0b044ab0e5099401", + "zh:72e9911568103576c6a78fa38841cfd45eeb88ad22a2c649eb140a377a5b3c26", + "zh:956b62b6857cbb467b50158601f01b1203daa34cbd447dcc7f044c327e878b68", + "zh:9d372bac0d4479868b34485fb4966ba7bb525938f818b6a625f4977004ea83f9", + "zh:e06658a51427f9f53dbdb06263406fc1bc56d1a4fb5e7eb660d7cdfc22f596bd", + "zh:eee38dadf672b946419af25160eae7c03fc2afbb14f39f2f1d2a7404d647e2f7", + ] +} diff --git a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md new file mode 100644 index 0000000..35b0c05 --- /dev/null +++ b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md @@ -0,0 +1,870 @@ + + +## [v3.9.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.8.0...v3.9.0) (2025-11-09) + +### Chore + +* Add file requested by hashicorp ([#813](https://github.com/kreuzwerker/terraform-provider-docker/issues/813)) +* Prepare release v3.8.0 ([#806](https://github.com/kreuzwerker/terraform-provider-docker/issues/806)) + +### Feat + +* Implement caching of docker provider ([#808](https://github.com/kreuzwerker/terraform-provider-docker/issues/808)) + +### Fix + +* test attribute of docker_service healthcheck is not required ([#815](https://github.com/kreuzwerker/terraform-provider-docker/issues/815)) +* docker_service label can be updated without recreate ([#814](https://github.com/kreuzwerker/terraform-provider-docker/issues/814)) + + + +## [v3.8.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.7.0...v3.8.0) (2025-10-08) + +### Feat + +* Add build attribute for docker_registry_image ([#805](https://github.com/kreuzwerker/terraform-provider-docker/issues/805)) +* Add build option for additional contexts ([#798](https://github.com/kreuzwerker/terraform-provider-docker/issues/798)) +* implement mac_address for networks_advanced ([#794](https://github.com/kreuzwerker/terraform-provider-docker/issues/794)) +* Implement docker cluster volume ([#793](https://github.com/kreuzwerker/terraform-provider-docker/issues/793)) + + + +## [v3.7.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.2...v3.7.0) (2025-08-19) + +### Chore + +* Prepare release v3.7.0 ([#774](https://github.com/kreuzwerker/terraform-provider-docker/issues/774)) + +### Feat + +* Implement memory_reservation and network_mode enhancements ([#773](https://github.com/kreuzwerker/terraform-provider-docker/issues/773)) +* Implement cache_from and cache_to for docker_image ([#772](https://github.com/kreuzwerker/terraform-provider-docker/issues/772)) + +### Fix + +* Correctly get and set nanoCPUs for docker_container ([#771](https://github.com/kreuzwerker/terraform-provider-docker/issues/771)) + + + +## [v3.6.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.1...v3.6.2) (2025-06-13) + +### Chore + +* Prepare release v3.6.2 ([#750](https://github.com/kreuzwerker/terraform-provider-docker/issues/750)) + +### Feat + +* Allow digest in image name ([#744](https://github.com/kreuzwerker/terraform-provider-docker/issues/744)) + +### Fix + +* Remove wrong buildkit version assignment ([#747](https://github.com/kreuzwerker/terraform-provider-docker/issues/747)) +* Reading non existant volume should recreate ([#749](https://github.com/kreuzwerker/terraform-provider-docker/issues/749)) +* Typo in cgroup_parent handling ([#746](https://github.com/kreuzwerker/terraform-provider-docker/issues/746)) + + + +## [v3.6.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.0...v3.6.1) (2025-06-05) + +### Chore + +* Prepare release v3.6.1 ([#743](https://github.com/kreuzwerker/terraform-provider-docker/issues/743)) + +### Feat + +* allow to set the cgroup parent for container ([#609](https://github.com/kreuzwerker/terraform-provider-docker/issues/609)) + + + +## [v3.6.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.5.0...v3.6.0) (2025-05-25) + +### Chore + +* Prepare release v3.6.0 ([#735](https://github.com/kreuzwerker/terraform-provider-docker/issues/735)) + +### Feat + +* Implement correct cpu scheduler settings ([#732](https://github.com/kreuzwerker/terraform-provider-docker/issues/732)) +* Add implementaion of capabilities in docker servic ([#727](https://github.com/kreuzwerker/terraform-provider-docker/issues/727)) +* implement Buildx builder resource ([#724](https://github.com/kreuzwerker/terraform-provider-docker/issues/724)) + +### Fix + +* Implement buildx fixes for general buildkit support and platform handling ([#734](https://github.com/kreuzwerker/terraform-provider-docker/issues/734)) +* Make endpoint validation less strict ([#733](https://github.com/kreuzwerker/terraform-provider-docker/issues/733)) + + + +## [v3.5.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.4.0...v3.5.0) (2025-05-06) + +### Chore + +* Prepare release v3.5.0 ([#721](https://github.com/kreuzwerker/terraform-provider-docker/issues/721)) + +### Feat + +* Implement using of buildx for docker_image ([#717](https://github.com/kreuzwerker/terraform-provider-docker/issues/717)) +* Support registries that return empty auth scope [#646](https://github.com/kreuzwerker/terraform-provider-docker/issues/646) +* Implement registry_image_manifests data source ([#714](https://github.com/kreuzwerker/terraform-provider-docker/issues/714)) +* Implement healthcheck start interval ([#713](https://github.com/kreuzwerker/terraform-provider-docker/issues/713)) + + + +## [v3.4.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.3.0...v3.4.0) (2025-04-25) + +### Chore + +* Prepare release v3.4.0 ([#712](https://github.com/kreuzwerker/terraform-provider-docker/issues/712)) + +### Feat + +* Implement volume_options subpath ([#710](https://github.com/kreuzwerker/terraform-provider-docker/issues/710)) + +### Fix + +* Prevent recreation of image name is intentionally set to a fixed value ([#711](https://github.com/kreuzwerker/terraform-provider-docker/issues/711)) +* Improve container wait handling ([#709](https://github.com/kreuzwerker/terraform-provider-docker/issues/709)) +* Use auth_config block also for registry_image delete functionality ([#708](https://github.com/kreuzwerker/terraform-provider-docker/issues/708)) + + + +## [v3.3.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.2.0...v3.3.0) (2025-04-19) + +### Chore + +* Prepare release v3.3.0 ([#705](https://github.com/kreuzwerker/terraform-provider-docker/issues/705)) +* Update terraform-plugin-sdk/v2 dependency ([#699](https://github.com/kreuzwerker/terraform-provider-docker/issues/699)) +* Update docker/docker and docker/cli to newest stable ([#695](https://github.com/kreuzwerker/terraform-provider-docker/issues/695)) + +### Feat + +* Implement support for docker context ([#704](https://github.com/kreuzwerker/terraform-provider-docker/issues/704)) +* disable_docker_daemon_check for provider ([#703](https://github.com/kreuzwerker/terraform-provider-docker/issues/703)) +* Implement tag triggers for docker_tag resource ([#702](https://github.com/kreuzwerker/terraform-provider-docker/issues/702)) +* Implement auth_config for docker_registry_image ([#701](https://github.com/kreuzwerker/terraform-provider-docker/issues/701)) + +### Fix + +* Store correctly ports from server ([#698](https://github.com/kreuzwerker/terraform-provider-docker/issues/698)) + + + +## [v3.2.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.2...v3.2.0) (2025-04-16) + +### Chore + +* Prepare release v3.2.0 ([#694](https://github.com/kreuzwerker/terraform-provider-docker/issues/694)) +* Upgrade golangci-lint to next major version ([#686](https://github.com/kreuzwerker/terraform-provider-docker/issues/686)) + +### Docs + +* Consolidated update of docs from several PRs ([#691](https://github.com/kreuzwerker/terraform-provider-docker/issues/691)) + +### Feat + +* Implement upload permissions in docker_container resource ([#693](https://github.com/kreuzwerker/terraform-provider-docker/issues/693)) +* Implement docker_image timeouts ([#692](https://github.com/kreuzwerker/terraform-provider-docker/issues/692)) +* Add support for build-secrets ([#604](https://github.com/kreuzwerker/terraform-provider-docker/issues/604)) + +### Fix + +* Authentication to ECR public ([#690](https://github.com/kreuzwerker/terraform-provider-docker/issues/690)) + + + +## [v3.1.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.1...v3.1.2) (2025-04-15) + +### Chore + +* prepare release 3.1.2 ([#688](https://github.com/kreuzwerker/terraform-provider-docker/issues/688)) + + + +## [v3.1.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.0...v3.1.1) (2025-04-14) + +### Chore + +* Prepare release 3.1.1 ([#687](https://github.com/kreuzwerker/terraform-provider-docker/issues/687)) + + + +## [v3.1.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.2...v3.1.0) (2025-04-14) + +### Chore + +* Prepare release 3.1.0 ([#685](https://github.com/kreuzwerker/terraform-provider-docker/issues/685)) +* update Go version to 1.22 for consistency across workflows, jo… ([#613](https://github.com/kreuzwerker/terraform-provider-docker/issues/613)) + +### Feat + +* support setting cpu shares ([#575](https://github.com/kreuzwerker/terraform-provider-docker/issues/575)) + +### Fix + +* Use build_args everywhere and update documentation ([#681](https://github.com/kreuzwerker/terraform-provider-docker/issues/681)) +* Compress build context before sending it to Docker ([#461](https://github.com/kreuzwerker/terraform-provider-docker/issues/461)) +* Set correct default network driver and fix a test ([#677](https://github.com/kreuzwerker/terraform-provider-docker/issues/677)) + +### Typo + +* s/presend/present/ ([#606](https://github.com/kreuzwerker/terraform-provider-docker/issues/606)) + + + +## [v3.0.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.1...v3.0.2) (2023-03-17) + +### Chore + +* Prepare release v3.0.2 + +### Docs + +* correct spelling of "networks_advanced" ([#517](https://github.com/kreuzwerker/terraform-provider-docker/issues/517)) + +### Fix + +* Implement proxy support. ([#529](https://github.com/kreuzwerker/terraform-provider-docker/issues/529)) + + + +## [v3.0.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.0...v3.0.1) (2023-01-13) + +### Chore + +* Prepare release v3.0.1 + +### Fix + +* Access health of container correctly. ([#506](https://github.com/kreuzwerker/terraform-provider-docker/issues/506)) + + + +## [v3.0.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.25.0...v3.0.0) (2023-01-13) + +### Chore + +* Prepare release v3.0.0 + +### Docs + +* Update documentation. +* Add migration guide and update README ([#502](https://github.com/kreuzwerker/terraform-provider-docker/issues/502)) + +### Feat + +* Prepare v3 release ([#503](https://github.com/kreuzwerker/terraform-provider-docker/issues/503)) + + + +## [v2.25.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.24.0...v2.25.0) (2023-01-05) + +### Chore + +* Prepare release v2.25.0 + +### Docs + +* Add documentation of remote hosts. ([#498](https://github.com/kreuzwerker/terraform-provider-docker/issues/498)) + +### Feat + +* Migrate build block to `docker_image` ([#501](https://github.com/kreuzwerker/terraform-provider-docker/issues/501)) +* Add platform attribute to docker_image resource ([#500](https://github.com/kreuzwerker/terraform-provider-docker/issues/500)) +* Add sysctl implementation to container of docker_service. ([#499](https://github.com/kreuzwerker/terraform-provider-docker/issues/499)) + + + +## [v2.24.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.23.1...v2.24.0) (2022-12-23) + +### Chore + +* Prepare release v2.24.0 + +### Docs + +* Fix generated website. +* Update command typo ([#487](https://github.com/kreuzwerker/terraform-provider-docker/issues/487)) + +### Feat + +* cgroupns support ([#497](https://github.com/kreuzwerker/terraform-provider-docker/issues/497)) +* Add triggers attribute to docker_registry_image ([#496](https://github.com/kreuzwerker/terraform-provider-docker/issues/496)) +* Support registries with disabled auth ([#494](https://github.com/kreuzwerker/terraform-provider-docker/issues/494)) +* add IPAM options block for docker networks ([#491](https://github.com/kreuzwerker/terraform-provider-docker/issues/491)) + +### Fix + +* Pin data source specific tag test to older tag. + +### Tests + +* Add test for parsing auth headers. + + + +## [v2.23.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.23.0...v2.23.1) (2022-11-23) + +### Chore + +* Prepare release v2.23.1 + +### Fix + +* Update shasum of busybox:1.35.0 tag in test. +* Handle Auth Header Scopes ([#482](https://github.com/kreuzwerker/terraform-provider-docker/issues/482)) +* Set OS_ARCH from GOHOSTOS and GOHOSTARCH ([#477](https://github.com/kreuzwerker/terraform-provider-docker/issues/477)) + + + +## [v2.23.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.22.0...v2.23.0) (2022-11-02) + +### Chore + +* Prepare release v2.23.0 + +### Feat + +* wait container healthy state ([#467](https://github.com/kreuzwerker/terraform-provider-docker/issues/467)) +* add docker logs data source ([#471](https://github.com/kreuzwerker/terraform-provider-docker/issues/471)) + +### Fix + +* Update shasum of busybox:1.35.0 tag in test. +* Update shasum of busybox:1.35.0 tag +* Correct provider name to match the public registry ([#462](https://github.com/kreuzwerker/terraform-provider-docker/issues/462)) + + + +## [v2.22.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.21.0...v2.22.0) (2022-09-20) + +### Chore + +* Prepare release v2.22.0 + +### Feat + +* Configurable timeout for docker_container resource stateChangeConf ([#454](https://github.com/kreuzwerker/terraform-provider-docker/issues/454)) + +### Fix + +* oauth authorization support for azurecr ([#451](https://github.com/kreuzwerker/terraform-provider-docker/issues/451)) + + + +## [v2.21.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.3...v2.21.0) (2022-09-05) + +### Chore + +* Prepare release v2.21.0 + +### Docs + +* Fix docker config example. + +### Feat + +* Add image_id attribute to docker_image resource. ([#450](https://github.com/kreuzwerker/terraform-provider-docker/issues/450)) +* Update used goversion to 1.18. ([#449](https://github.com/kreuzwerker/terraform-provider-docker/issues/449)) + +### Fix + +* Replace deprecated .latest attribute with new image_id. ([#453](https://github.com/kreuzwerker/terraform-provider-docker/issues/453)) +* Remove reading part of docker_tag resource. ([#448](https://github.com/kreuzwerker/terraform-provider-docker/issues/448)) +* Fix repo_digest value for DockerImageDatasource test. + + + +## [v2.20.3](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.2...v2.20.3) (2022-08-31) + +### Chore + +* Prepare release v2.20.3 + +### Fix + +* Docker Registry Image data source use HEAD request to query image digest ([#433](https://github.com/kreuzwerker/terraform-provider-docker/issues/433)) +* Adding Support for Windows Paths in Bash ([#438](https://github.com/kreuzwerker/terraform-provider-docker/issues/438)) + + + +## [v2.20.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.1...v2.20.2) (2022-08-10) + +### Chore + +* Prepare release v2.20.2 + +### Fix + +* Check the operating system for determining the default Docker socket ([#427](https://github.com/kreuzwerker/terraform-provider-docker/issues/427)) + +### Reverts + +* fix(deps): update module github.com/golangci/golangci-lint to v1.48.0 ([#423](https://github.com/kreuzwerker/terraform-provider-docker/issues/423)) + + + +## [v2.20.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.0...v2.20.1) (2022-08-10) + +### Chore + +* Prepare release v2.20.1 +* Reduce time to setup AccTests ([#430](https://github.com/kreuzwerker/terraform-provider-docker/issues/430)) + +### Docs + +* Improve docker network usage documentation [skip-ci] + +### Feat + +* Implement triggers attribute for docker_image. ([#425](https://github.com/kreuzwerker/terraform-provider-docker/issues/425)) + +### Fix + +* Add ForceTrue to docker_image name attribute. ([#421](https://github.com/kreuzwerker/terraform-provider-docker/issues/421)) + + + +## [v2.20.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.19.0...v2.20.0) (2022-07-28) + +### Chore + +* Prepare release v2.20.0 +* Fix release targets in Makefile. + +### Feat + +* Implementation of `docker_tag` resource. ([#418](https://github.com/kreuzwerker/terraform-provider-docker/issues/418)) +* Implement support for insecure registries ([#414](https://github.com/kreuzwerker/terraform-provider-docker/issues/414)) + + + +## [v2.19.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.18.1...v2.19.0) (2022-07-15) + +### Chore + +* Prepare release v2.19.0 + +### Feat + +* Add gpu flag to docker_container resource ([#405](https://github.com/kreuzwerker/terraform-provider-docker/issues/405)) + +### Fix + +* Enable authentication to multiple registries again. ([#400](https://github.com/kreuzwerker/terraform-provider-docker/issues/400)) +* ECR authentication ([#409](https://github.com/kreuzwerker/terraform-provider-docker/issues/409)) + + + +## [v2.18.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.18.0...v2.18.1) (2022-07-14) + +### Chore + +* Prepare release v2.18.1 +* Automate changelog generation [skip ci] + +### Fix + +* Improve searchLocalImages error handling. ([#407](https://github.com/kreuzwerker/terraform-provider-docker/issues/407)) +* Throw errors when any part of docker config file handling goes wrong. ([#406](https://github.com/kreuzwerker/terraform-provider-docker/issues/406)) +* Enables having a Dockerfile outside the context ([#402](https://github.com/kreuzwerker/terraform-provider-docker/issues/402)) + + + +## [v2.18.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.17.0...v2.18.0) (2022-07-11) + +### Chore + +* prepare release v2.18.0 + +### Feat + +* add runtime, stop_signal and stop_timeout properties to the docker_container resource ([#364](https://github.com/kreuzwerker/terraform-provider-docker/issues/364)) + +### Fix + +* Correctly handle build files and context for docker_registry_image ([#398](https://github.com/kreuzwerker/terraform-provider-docker/issues/398)) +* Switch to proper go tools mechanism to fix website-* workflows. ([#399](https://github.com/kreuzwerker/terraform-provider-docker/issues/399)) +* compare relative paths when excluding, fixes kreuzwerker[#280](https://github.com/kreuzwerker/terraform-provider-docker/issues/280) ([#397](https://github.com/kreuzwerker/terraform-provider-docker/issues/397)) + + + +## [v2.17.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.16.0...v2.17.0) (2022-06-23) + +### Chore + +* prepare release v2.17.0 +* Exclude examples directory from renovate. +* remove the workflow to close stale issues and pull requests ([#371](https://github.com/kreuzwerker/terraform-provider-docker/issues/371)) + +### Fix + +* update go package files directly on master to fix build. +* correct authentication for ghcr.io registry([#349](https://github.com/kreuzwerker/terraform-provider-docker/issues/349)) + + + +## [v2.16.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.15.0...v2.16.0) (2022-01-24) + +### Chore + +* prepare release v2.16.0 + +### Docs + +* fix service options ([#337](https://github.com/kreuzwerker/terraform-provider-docker/issues/337)) +* update registry_image.md ([#321](https://github.com/kreuzwerker/terraform-provider-docker/issues/321)) +* fix r/registry_image truncated docs ([#304](https://github.com/kreuzwerker/terraform-provider-docker/issues/304)) + +### Feat + +* add parameter for SSH options ([#335](https://github.com/kreuzwerker/terraform-provider-docker/issues/335)) + +### Fix + +* pass container rm flag ([#322](https://github.com/kreuzwerker/terraform-provider-docker/issues/322)) +* add nil check of DriverConfig ([#315](https://github.com/kreuzwerker/terraform-provider-docker/issues/315)) +* fmt of go files for go 1.17 + + + +## [v2.15.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.14.0...v2.15.0) (2021-08-11) + +### Chore + +* prepare release v2.15.0 +* re go gets terraform-plugin-docs + +### Docs + +* corrects authentication misspell. Closes [#264](https://github.com/kreuzwerker/terraform-provider-docker/issues/264) + +### Feat + +* add container storage opts ([#258](https://github.com/kreuzwerker/terraform-provider-docker/issues/258)) + +### Fix + +* add current timestamp for file upload to container ([#259](https://github.com/kreuzwerker/terraform-provider-docker/issues/259)) + + + +## [v2.14.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.13.0...v2.14.0) (2021-07-09) + +### Chore + +* prepare release v2.14.0 + +### Docs + +* update to absolute path for registry image context ([#246](https://github.com/kreuzwerker/terraform-provider-docker/issues/246)) +* update readme with logos and subsections ([#235](https://github.com/kreuzwerker/terraform-provider-docker/issues/235)) + +### Feat + +* support terraform v1 ([#242](https://github.com/kreuzwerker/terraform-provider-docker/issues/242)) + +### Fix + +* Update the URL of the docker hub registry ([#230](https://github.com/kreuzwerker/terraform-provider-docker/issues/230)) + + + +## [v2.13.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.2...v2.13.0) (2021-06-22) + +### Chore + +* prepare release v2.13.0 + +### Docs + +* fix a few typos ([#216](https://github.com/kreuzwerker/terraform-provider-docker/issues/216)) +* fix typos in docker_image example usage ([#213](https://github.com/kreuzwerker/terraform-provider-docker/issues/213)) + + + +## [v2.12.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.1...v2.12.2) (2021-05-26) + +### Chore + +* prepare release v2.12.2 + + + +## [v2.12.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.0...v2.12.1) (2021-05-26) + +### Chore + +* update changelog for v2.12.1 + +### Fix + +* add service host flattener with space split ([#205](https://github.com/kreuzwerker/terraform-provider-docker/issues/205)) +* service state upgradeV2 for empty auth + + + +## [v2.12.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.11.0...v2.12.0) (2021-05-23) + +### Chore + +* update changelog for v2.12.0 +* ignore dist folder +* configure actions/stale ([#157](https://github.com/kreuzwerker/terraform-provider-docker/issues/157)) +* add the guide about Terraform Configuration in Bug Report ([#139](https://github.com/kreuzwerker/terraform-provider-docker/issues/139)) +* bump docker dependency to v20.10.5 ([#119](https://github.com/kreuzwerker/terraform-provider-docker/issues/119)) + +### Ci + +* run acceptance tests with multiple Terraform versions ([#129](https://github.com/kreuzwerker/terraform-provider-docker/issues/129)) + +### Docs + +* update for v2.12.0 +* add releasing steps +* format `Guide of Bug report` ([#159](https://github.com/kreuzwerker/terraform-provider-docker/issues/159)) +* add an example to build an image with docker_image ([#158](https://github.com/kreuzwerker/terraform-provider-docker/issues/158)) +* add a guide about writing issues to CONTRIBUTING.md ([#149](https://github.com/kreuzwerker/terraform-provider-docker/issues/149)) +* fix Github repository URL in README ([#136](https://github.com/kreuzwerker/terraform-provider-docker/issues/136)) + +### Feat + +* support darwin arm builds and golang 1.16 ([#140](https://github.com/kreuzwerker/terraform-provider-docker/issues/140)) +* migrate to terraform-sdk v2 ([#102](https://github.com/kreuzwerker/terraform-provider-docker/issues/102)) + +### Fix + +* rewriting tar header fields ([#198](https://github.com/kreuzwerker/terraform-provider-docker/issues/198)) +* test spaces for windows ([#190](https://github.com/kreuzwerker/terraform-provider-docker/issues/190)) +* replace for loops with StateChangeConf ([#182](https://github.com/kreuzwerker/terraform-provider-docker/issues/182)) +* skip sign on compile action +* assign map to rawState when it is nil to prevent panic ([#180](https://github.com/kreuzwerker/terraform-provider-docker/issues/180)) +* search local images with Docker image ID ([#151](https://github.com/kreuzwerker/terraform-provider-docker/issues/151)) +* set "ForceNew: true" to labelSchema ([#152](https://github.com/kreuzwerker/terraform-provider-docker/issues/152)) + + + +## [v2.11.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.10.0...v2.11.0) (2021-01-22) + +### Chore + +* update changelog for v2.11.0 +* updates changelog for v2.10.0 + +### Docs + +* fix legacy configuration style ([#126](https://github.com/kreuzwerker/terraform-provider-docker/issues/126)) + +### Feat + +* add properties -it (tty and stdin_opn) to docker container + + + +## [v2.10.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.9.0...v2.10.0) (2021-01-08) + +### Chore + +* updates changelog for 2.10.0 +* ignores testing folders +* adds separate bug and ft req templates + +### Ci + +* bumps to docker version 20.10.1 +* pins workflows to ubuntu:20.04 image + +### Docs + +* add labels to arguments of docker_service ([#105](https://github.com/kreuzwerker/terraform-provider-docker/issues/105)) +* cleans readme +* adds coc and contributing + +### Feat + +* supports Docker plugin ([#35](https://github.com/kreuzwerker/terraform-provider-docker/issues/35)) +* support max replicas of Docker Service Task Spec ([#112](https://github.com/kreuzwerker/terraform-provider-docker/issues/112)) +* add force_remove option to r/image ([#104](https://github.com/kreuzwerker/terraform-provider-docker/issues/104)) +* add local semantic commit validation ([#99](https://github.com/kreuzwerker/terraform-provider-docker/issues/99)) +* add ability to lint/check of links in documentation locally ([#98](https://github.com/kreuzwerker/terraform-provider-docker/issues/98)) + +### Fix + +* set "latest" to tag when tag isn't specified ([#117](https://github.com/kreuzwerker/terraform-provider-docker/issues/117)) +* image label for workflows +* remove all azure cps + +### Pull Requests + +* Merge pull request [#38](https://github.com/kreuzwerker/terraform-provider-docker/issues/38) from kreuzwerker/ci-ubuntu2004-workflow +* Merge pull request [#36](https://github.com/kreuzwerker/terraform-provider-docker/issues/36) from kreuzwerker/chore-gh-issue-tpl + + + +## [v2.9.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.8.0...v2.9.0) (2020-12-25) + +### Chore + +* updates changelog for 2.9.0 +* update changelog 2.8.0 release date +* introduces golangci-lint ([#32](https://github.com/kreuzwerker/terraform-provider-docker/issues/32)) +* fix changelog links + +### Ci + +* add gofmt's '-s' option +* remove unneeded make tasks +* fix test of website + +### Doc + +* devices is a block, not a boolean + +### Feat + +* adds support for OCI manifests ([#316](https://github.com/kreuzwerker/terraform-provider-docker/issues/316)) +* adds security_opts to container config. ([#308](https://github.com/kreuzwerker/terraform-provider-docker/issues/308)) +* adds support for init process injection for containers. ([#300](https://github.com/kreuzwerker/terraform-provider-docker/issues/300)) + +### Fix + +* changing mounts requires ForceNew ([#314](https://github.com/kreuzwerker/terraform-provider-docker/issues/314)) +* allow healthcheck to be computed as container can specify ([#312](https://github.com/kreuzwerker/terraform-provider-docker/issues/312)) +* treat null user as a no-op ([#318](https://github.com/kreuzwerker/terraform-provider-docker/issues/318)) +* workdir null behavior ([#320](https://github.com/kreuzwerker/terraform-provider-docker/issues/320)) + +### Style + +* format with gofumpt + +### Pull Requests + +* Merge pull request [#33](https://github.com/kreuzwerker/terraform-provider-docker/issues/33) from brandonros/patch-1 +* Merge pull request [#11](https://github.com/kreuzwerker/terraform-provider-docker/issues/11) from suzuki-shunsuke/format-with-gofumpt +* Merge pull request [#26](https://github.com/kreuzwerker/terraform-provider-docker/issues/26) from kreuzwerker/ci/fix-website-ci +* Merge pull request [#8](https://github.com/kreuzwerker/terraform-provider-docker/issues/8) from dubo-dubon-duponey/patch1 + + + +## v2.8.0 (2020-11-11) + +### Chore + +* updates changelog for 2.8.0 +* removes travis.yml +* deactivates travis +* removes vendor dir ([#298](https://github.com/kreuzwerker/terraform-provider-docker/issues/298)) +* bump go 115 ([#297](https://github.com/kreuzwerker/terraform-provider-docker/issues/297)) +* documentation updates ([#286](https://github.com/kreuzwerker/terraform-provider-docker/issues/286)) +* updates link syntax ([#287](https://github.com/kreuzwerker/terraform-provider-docker/issues/287)) +* fix typo ([#292](https://github.com/kreuzwerker/terraform-provider-docker/issues/292)) + +### Ci + +* reactivats all workflows +* fix website +* only run website workflow +* exports gopath manually +* fix absolute gopath for website +* make website check separate workflow +* fix workflow names +* adds website test to unit test +* adds acc test +* adds compile +* adds go version and goproxy env +* enables unit tests for master branch +* adds unit test workflow +* adds goreleaser and gh action +* bumps docker and ubuntu versions ([#241](https://github.com/kreuzwerker/terraform-provider-docker/issues/241)) +* removes debug option from acc tests +* skips test which is flaky only on travis + +### Deps + +* github.com/hashicorp/terraform[@sdk](https://github.com/sdk)-v0.11-with-go-modules Updated via: go get github.com/hashicorp/terraform[@sdk](https://github.com/sdk)-v0.11-with-go-modules and go mod tidy +* use go modules for dep mgmt run go mod tidy remove govendor from makefile and travis config set appropriate env vars for go modules + +### Docker + +* improve validation of runtime constraints + +### Docs + +* update container.html.markdown ([#278](https://github.com/kreuzwerker/terraform-provider-docker/issues/278)) +* update service.html.markdown ([#281](https://github.com/kreuzwerker/terraform-provider-docker/issues/281)) +* update restart_policy for service. Closes [#228](https://github.com/kreuzwerker/terraform-provider-docker/issues/228) +* adds new label structure. Closes [#214](https://github.com/kreuzwerker/terraform-provider-docker/issues/214) +* update anchors with -1 suffix ([#178](https://github.com/kreuzwerker/terraform-provider-docker/issues/178)) +* Fix misspelled words +* Fix exported attribute name in docker_registry_image +* Fix example for docker_registry_image ([#8308](https://github.com/kreuzwerker/terraform-provider-docker/issues/8308)) +* provider/docker - network settings attrs + +### Feat + +* conditionally adding port binding ([#293](https://github.com/kreuzwerker/terraform-provider-docker/issues/293)). +* adds docker Image build feature ([#283](https://github.com/kreuzwerker/terraform-provider-docker/issues/283)) +* adds complete support for Docker credential helpers ([#253](https://github.com/kreuzwerker/terraform-provider-docker/issues/253)) +* Expose IPv6 properties as attributes +* allow use of source file instead of content / content_base64 ([#240](https://github.com/kreuzwerker/terraform-provider-docker/issues/240)) +* supports to update docker_container ([#236](https://github.com/kreuzwerker/terraform-provider-docker/issues/236)) +* support to import some docker_container's attributes ([#234](https://github.com/kreuzwerker/terraform-provider-docker/issues/234)) +* adds config file content as plain string ([#232](https://github.com/kreuzwerker/terraform-provider-docker/issues/232)) +* make UID, GID, & mode for secrets and configs configurable ([#231](https://github.com/kreuzwerker/terraform-provider-docker/issues/231)) +* adds import for resources ([#196](https://github.com/kreuzwerker/terraform-provider-docker/issues/196)) +* add container ipc mode. ([#182](https://github.com/kreuzwerker/terraform-provider-docker/issues/182)) +* adds container working dir ([#181](https://github.com/kreuzwerker/terraform-provider-docker/issues/181)) + +### Fix + +* ignores 'remove_volumes' on container import +* duplicated buildImage function +* port objects with the same internal port but different protocol trigger recreation of container ([#274](https://github.com/kreuzwerker/terraform-provider-docker/issues/274)) +* panic to migrate schema of docker_container from v1 to v2 ([#271](https://github.com/kreuzwerker/terraform-provider-docker/issues/271)). Closes [#264](https://github.com/kreuzwerker/terraform-provider-docker/issues/264) +* pins docker registry for tests to v2.7.0 +* prevent force recreate of container about some attributes ([#269](https://github.com/kreuzwerker/terraform-provider-docker/issues/269)) +* service endpoint spec flattening +* corrects IPAM config read on the data provider ([#229](https://github.com/kreuzwerker/terraform-provider-docker/issues/229)) +* replica to 0 in current schema. Closes [#221](https://github.com/kreuzwerker/terraform-provider-docker/issues/221) +* label for network and volume after improt +* binary upload as base 64 content ([#194](https://github.com/kreuzwerker/terraform-provider-docker/issues/194)) +* service env truncation for multiple delimiters ([#193](https://github.com/kreuzwerker/terraform-provider-docker/issues/193)) +* destroy_grace_seconds are considered ([#179](https://github.com/kreuzwerker/terraform-provider-docker/issues/179)) + +### Make + +* Add website + website-test targets + +### Provider + +* Ensured Go 1.11 in TravisCI and README provider: Run go fix provider: Run go fmt provider: Encode go version 1.11.5 to .go-version file +* Require Go 1.11 in TravisCI and README provider: Run go fix provider: Run go fmt + +### Tests + +* Skip test if swap limit isn't available ([#136](https://github.com/kreuzwerker/terraform-provider-docker/issues/136)) +* Simplify Dockerfile(s) + +### Vendor + +* github.com/hashicorp/terraform/...[@v0](https://github.com/v0).10.0 +* Ignore github.com/hashicorp/terraform/backend + +### Website + +* Docs sweep for lists & maps +* note on docker +* docker docs + +### Pull Requests + +* Merge pull request [#134](https://github.com/kreuzwerker/terraform-provider-docker/issues/134) from terraform-providers/go-modules-2019-03-01 +* Merge pull request [#135](https://github.com/kreuzwerker/terraform-provider-docker/issues/135) from terraform-providers/t-simplify-dockerfile +* Merge pull request [#47](https://github.com/kreuzwerker/terraform-provider-docker/issues/47) from captn3m0/docker-link-warning +* Merge pull request [#60](https://github.com/kreuzwerker/terraform-provider-docker/issues/60) from terraform-providers/f-make-website +* Merge pull request [#23](https://github.com/kreuzwerker/terraform-provider-docker/issues/23) from JamesLaverack/patch-1 +* Merge pull request [#18](https://github.com/kreuzwerker/terraform-provider-docker/issues/18) from terraform-providers/vendor-tf-0.10 +* Merge pull request [#5046](https://github.com/kreuzwerker/terraform-provider-docker/issues/5046) from tpounds/use-built-in-schema-string-hash +* Merge pull request [#3761](https://github.com/kreuzwerker/terraform-provider-docker/issues/3761) from ryane/f-provider-docker-improvements +* Merge pull request [#3383](https://github.com/kreuzwerker/terraform-provider-docker/issues/3383) from apparentlymart/docker-container-command-docs +* Merge pull request [#1564](https://github.com/kreuzwerker/terraform-provider-docker/issues/1564) from nickryand/docker_links + diff --git a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md new file mode 100644 index 0000000..6be3b5f --- /dev/null +++ b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md @@ -0,0 +1,117 @@ + + Docker logo + + + Terraform logo + + + Kreuzwerker logo + + +# Terraform Provider for Docker + +[![Release](https://img.shields.io/github/v/release/kreuzwerker/terraform-provider-docker)](https://github.com/kreuzwerker/terraform-provider-docker/releases) +[![Installs](https://img.shields.io/badge/dynamic/json?logo=terraform&label=installs&query=$.data.attributes.downloads&url=https%3A%2F%2Fregistry.terraform.io%2Fv2%2Fproviders%2F713)](https://registry.terraform.io/providers/kreuzwerker/docker) +[![Registry](https://img.shields.io/badge/registry-doc%40latest-lightgrey?logo=terraform)](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kreuzwerker/terraform-provider-docker/blob/main/LICENSE) +[![Go Status](https://github.com/kreuzwerker/terraform-provider-docker/workflows/Acc%20Tests/badge.svg)](https://github.com/kreuzwerker/terraform-provider-docker/actions) +[![Lint Status](https://github.com/kreuzwerker/terraform-provider-docker/workflows/golangci-lint/badge.svg)](https://github.com/kreuzwerker/terraform-provider-docker/actions) +[![Go Report Card](https://goreportcard.com/badge/github.com/kreuzwerker/terraform-provider-docker)](https://goreportcard.com/report/github.com/kreuzwerker/terraform-provider-docker) + +## Documentation + +The documentation for the provider is available on the [Terraform Registry](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). + +Do you want to migrate from `v2.x` to `v3.x`? Please read the [migration guide](docs/v2_v3_migration.md) + +## Example usage + +Take a look at the examples in the [documentation](https://registry.terraform.io/providers/kreuzwerker/docker/3.9.0/docs) of the registry +or use the following example: + + +```hcl +# Set the required provider and versions +terraform { + required_providers { + # We recommend pinning to the specific version of the Docker Provider you're using + # since new versions are released frequently + docker = { + source = "kreuzwerker/docker" + version = "3.9.0" + } + } +} + +# Configure the docker provider +provider "docker" { +} + +# Create a docker image resource +# -> docker pull nginx:latest +resource "docker_image" "nginx" { + name = "nginx:latest" + keep_locally = true +} + +# Create a docker container resource +# -> same as 'docker run --name nginx -p8080:80 -d nginx:latest' +resource "docker_container" "nginx" { + name = "nginx" + image = docker_image.nginx.image_id + + ports { + external = 8080 + internal = 80 + } +} + +# Or create a service resource +# -> same as 'docker service create -d -p 8081:80 --name nginx-service --replicas 2 nginx:latest' +resource "docker_service" "nginx_service" { + name = "nginx-service" + task_spec { + container_spec { + image = docker_image.nginx.repo_digest + } + } + + mode { + replicated { + replicas = 2 + } + } + + endpoint_spec { + ports { + published_port = 8081 + target_port = 80 + } + } +} +``` + +## Building The Provider + +[Go](https://golang.org/doc/install) 1.18.x (to build the provider plugin) + + +```sh +$ git clone git@github.com:kreuzwerker/terraform-provider-docker +$ make build +``` + +## Contributing + +The Terraform Docker Provider is the work of many of contributors. We appreciate your help! + +To contribute, please read the contribution guidelines: [Contributing to Terraform - Docker Provider](CONTRIBUTING.md) + +## License + +The Terraform Provider Docker is available to everyone under the terms of the Mozilla Public License Version 2.0. [Take a look the LICENSE file](LICENSE). + + +## Stargazers over time + +[![Stargazers over time](https://starchart.cc/kreuzwerker/terraform-provider-docker.svg)](https://starchart.cc/kreuzwerker/terraform-provider-docker) diff --git a/terraform.tf b/terraform.tf new file mode 100644 index 0000000..0994a4d --- /dev/null +++ b/terraform.tf @@ -0,0 +1,19 @@ +# Provider Docker +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + } +} + +provider "docker" { + host = "unix:///var/run/docker.sock" +} + +# Image Docker +resource "docker_image" "python" { + name = "python:3.14.7" + keep_locally = true +} From 34890b2b04aa77c267ff5d5e54215a239363b2a4 Mon Sep 17 00:00:00 2001 From: valentin Date: Mon, 14 Sep 2026 16:41:45 +0200 Subject: [PATCH 010/205] Outillage tests unitaires frontend : couverture Vitest, scripts npm, conventions TESTING.md. --- apps/frontend/TESTING.md | 81 +++++++++++ apps/frontend/angular.json | 19 ++- apps/frontend/package-lock.json | 201 +++++++++++++++++++++++++++ apps/frontend/package.json | 4 +- apps/frontend/test-results/junit.xml | 9 ++ 5 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/TESTING.md create mode 100644 apps/frontend/test-results/junit.xml diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md new file mode 100644 index 0000000..e23ed7a --- /dev/null +++ b/apps/frontend/TESTING.md @@ -0,0 +1,81 @@ +# Conventions de tests unitaires — Frontend + +## Outil +Vitest (intégré nativement à Angular CLI, pas d'installation à faire). + +## Où écrire les tests +Un fichier `*.spec.ts` à côté de chaque fichier testé (convention Angular CLI +par défaut, respectée automatiquement par `ng generate`). + +## Structure attendue (Arrange / Act / Assert) +```typescript +it('devrait faire X quand Y', () => { + // Arrange : préparer les données et les mocks + const input = { valeur: 42 }; + + // Act : exécuter le code testé + const result = service.doSomething(input); + + // Assert : vérifier le résultat + expect(result).toBe(true); +}); +``` + +## Ce qui doit être testé en priorité +- Services (`core/services/`) : logique métier, gestion des erreurs +- Guards et interceptors (`core/guards/`, `core/interceptors/`) : chaque branche de décision +- Composants avec logique (formulaires, conditions d'affichage) — pas nécessaire pour + un composant 100% template, sans logique + +## Gabarit — tester un service avec appel HTTP +```typescript +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { MonService } from './mon.service'; + +describe('MonService', () => { + let service: MonService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [MonService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(MonService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('devrait récupérer les données', () => { + service.getData().subscribe(); + const req = httpMock.expectOne('/api/v1/...'); + expect(req.request.method).toBe('GET'); + req.flush({ /* réponse simulée */ }); + }); +}); +``` + +## Gabarit — tester un composant standalone +```typescript +import { TestBed } from '@angular/core/testing'; +import { MonComposant } from './mon-composant'; + +describe('MonComposant', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MonComposant], + }).compileComponents(); + }); + + it('devrait se créer', () => { + const fixture = TestBed.createComponent(MonComposant); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); +``` + +## Lancer les tests +- Développement (mode watch) : `npm test` +- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html` diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 3140772..ddf87a3 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -77,7 +77,24 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "coverage": true, + "coverageReporters": [ + "text-summary", + "lcov", + "html" + ], + "reporters": [ + "default", + [ + "junit", + { + "outputFile": "test-results/junit.xml" + } + ] + ] + } } } } diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 8f4408b..5ba6595 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -21,6 +21,7 @@ "@angular/build": "^22.1.8", "@angular/cli": "^22.1.8", "@angular/compiler-cli": "^22.1.0", + "@vitest/coverage-v8": "^4.1.11", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "~6.0.2", @@ -733,6 +734,16 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3692,6 +3703,37 @@ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -3943,6 +3985,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", + "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.23", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", @@ -5077,6 +5131,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5139,6 +5203,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -5382,6 +5453,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", @@ -5886,6 +5996,84 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/magicast/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/magicast/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7088,6 +7276,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7369db4..552e346 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,8 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "test:ci": "ng test --watch=false" }, "private": true, "packageManager": "npm@11.19.0", @@ -24,6 +25,7 @@ "@angular/build": "^22.1.8", "@angular/cli": "^22.1.8", "@angular/compiler-cli": "^22.1.0", + "@vitest/coverage-v8": "^4.1.11", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "~6.0.2", diff --git a/apps/frontend/test-results/junit.xml b/apps/frontend/test-results/junit.xml new file mode 100644 index 0000000..28e5ba4 --- /dev/null +++ b/apps/frontend/test-results/junit.xml @@ -0,0 +1,9 @@ + + + + + + + + + From 98ec01c847393da2049bd0fcaf52ff267c56b46d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:54:15 +0200 Subject: [PATCH 011/205] test(backend): rend la configuration de test independante du poste APP_ENV, APP_DEBUG, APP_LOG_LEVEL et APP_CORS_ORIGINS n'etaient poses nulle part : le .env du developpeur les decidait, alors que les tests assertent en dur l'environnement et que create_app coupe /openapi.json hors developpement. Un poste portant APP_ENV=prod faisait tomber deux tests. Fixe aussi asyncio_default_fixture_loop_scope, que pytest-asyncio 1.4 reclame. --- apps/backend/pyproject.toml | 1 + apps/backend/tests/conftest.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index fee4044..be64aba 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -79,6 +79,7 @@ disallow_untyped_defs = false [tool.pytest.ini_options] 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`"] diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index ac0e89b..e855f1b 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -10,9 +10,19 @@ from app.db.session import get_engine, get_session_factory from app.main import create_app +# Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne +# pose pas ici, c'est le .env du poste qui les decide, et les assertions avec. @pytest.fixture(autouse=True, scope="session") def environment() -> Iterator[None]: - os.environ.setdefault("APP_SECRET_KEY", "secret-de-test") + os.environ.update( + { + "APP_ENV": "local", + "APP_DEBUG": "false", + "APP_LOG_LEVEL": "WARNING", + "APP_CORS_ORIGINS": "", + "APP_SECRET_KEY": "secret-de-test", + } + ) os.environ.setdefault( "DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test" ) From 3ca1866e93ba179bd3bf02140bd078393c0e5450 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:55:38 +0200 Subject: [PATCH 012/205] test(backend): factorise les doubles de session Chaque test reecrivait sa classe de session et sa fonction d'override, soit trois fois le meme decor pour un seul endpoint. FakeSession et la fixture fake_session portent ce decor, make_settings fabrique une Settings dont les valeurs priment sur l'environnement. --- apps/backend/tests/api/test_health.py | 40 ++++++--------------------- apps/backend/tests/conftest.py | 16 +++++++++-- apps/backend/tests/factories.py | 37 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 33 deletions(-) create mode 100644 apps/backend/tests/factories.py diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index f0d647f..a7a61b6 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -1,12 +1,9 @@ -from collections.abc import AsyncIterator +from collections.abc import Callable import pytest -from fastapi import FastAPI from httpx import AsyncClient from sqlalchemy.exc import OperationalError -from app.db.session import get_session - async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: response = await client.get("/api/v1/health/live") @@ -20,15 +17,10 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } -async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None: - class ReadySession: - async def scalar(self, *_: object, **__: object) -> str: - return "2.22.1" - - async def override() -> AsyncIterator[ReadySession]: - yield ReadySession() - - app.dependency_overrides[get_session] = override +async def test_readiness_reports_the_timescaledb_version( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result="2.22.1") response = await client.get("/api/v1/health/ready") @@ -41,16 +33,9 @@ async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: A async def test_readiness_returns_503_when_the_extension_is_missing( - app: FastAPI, client: AsyncClient + fake_session: Callable[..., None], client: AsyncClient ) -> None: - class SessionWithoutExtension: - async def scalar(self, *_: object, **__: object) -> None: - return None - - async def override() -> AsyncIterator[SessionWithoutExtension]: - yield SessionWithoutExtension() - - app.dependency_overrides[get_session] = override + fake_session(result=None) response = await client.get("/api/v1/health/ready") @@ -67,16 +52,9 @@ async def test_readiness_returns_503_when_the_extension_is_missing( ids=["erreur_sqlalchemy", "erreur_reseau_asyncpg"], ) async def test_readiness_returns_503_when_database_is_unreachable( - app: FastAPI, client: AsyncClient, failure: Exception + fake_session: Callable[..., None], client: AsyncClient, failure: Exception ) -> None: - class UnreachableSession: - async def scalar(self, *_: object, **__: object) -> None: - raise failure - - async def override() -> AsyncIterator[UnreachableSession]: - yield UnreachableSession() - - app.dependency_overrides[get_session] = override + fake_session(failure=failure) response = await client.get("/api/v1/health/ready") diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index e855f1b..950fc69 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,13 +1,14 @@ import os -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Callable, Iterator import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app.core.config import get_settings -from app.db.session import get_engine, get_session_factory +from app.db.session import get_engine, get_session, get_session_factory from app.main import create_app +from tests.factories import FakeSession # Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne @@ -52,3 +53,14 @@ async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as async_client: yield async_client + + +@pytest.fixture +def fake_session(app: FastAPI) -> Callable[..., None]: + def install(result: object = None, failure: Exception | None = None) -> None: + async def override() -> AsyncIterator[FakeSession]: + yield FakeSession(result=result, failure=failure) + + app.dependency_overrides[get_session] = override + + return install diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py new file mode 100644 index 0000000..05ba3fc --- /dev/null +++ b/apps/backend/tests/factories.py @@ -0,0 +1,37 @@ +from typing import Any + +from app.core.config import Settings + +SETTINGS_DE_TEST: dict[str, Any] = { + "env": "local", + "debug": False, + "log_level": "WARNING", + "cors_origins": "", + "secret_key": "secret-de-test", + "database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test", +} + + +class FakeSession: + """Session factice : renvoie `result`, ou leve `failure` si elle est fournie.""" + + def __init__(self, result: object = None, failure: Exception | None = None) -> None: + self._result = result + self._failure = failure + + async def scalar(self, *_: object, **__: object) -> object: + return self._repondre() + + async def execute(self, *_: object, **__: object) -> object: + return self._repondre() + + def _repondre(self) -> object: + if self._failure is not None: + raise self._failure + return self._result + + +# Piege : les arguments nommes priment sur l'environnement et sur .env, contrairement +# aux variables posees par la fixture `environment`, qui restent surchargeables. +def make_settings(**overrides: Any) -> Settings: + return Settings(**{**SETTINGS_DE_TEST, **overrides}) From 3db4419bdf57d86c7a34e510f5e8d9b06bff1ba2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:58:30 +0200 Subject: [PATCH 013/205] test(backend): calque l'arborescence des tests sur celle de app Le README annonce deja tests/ comme miroir de app/, mais seul tests/api existait. Les paquets core, db, services et repositories attendent le metier a venir, pour que personne n'ait a choisir ou poser son premier test. --- apps/backend/tests/core/__init__.py | 0 apps/backend/tests/db/__init__.py | 0 apps/backend/tests/repositories/__init__.py | 0 apps/backend/tests/services/__init__.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/backend/tests/core/__init__.py create mode 100644 apps/backend/tests/db/__init__.py create mode 100644 apps/backend/tests/repositories/__init__.py create mode 100644 apps/backend/tests/services/__init__.py diff --git a/apps/backend/tests/core/__init__.py b/apps/backend/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/db/__init__.py b/apps/backend/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/repositories/__init__.py b/apps/backend/tests/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/services/__init__.py b/apps/backend/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 From c95f4d38515c8215074a69d01ace57347e95d55b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:58:57 +0200 Subject: [PATCH 014/205] test(backend): mesure les branches et fixe un seuil de couverture app/main.py sort du omit : la fixture app l'exerce a chaque test, et l'exclure masquait ses seules conditions, les docs coupees hors developpement et le CORS monte selon les origines declarees. Il ressort a 78 %, le lifespan n'etant pas joue par ASGITransport. Seuil pose a 85 % pour 89 % mesures. --- apps/backend/pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index be64aba..87948f1 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -85,4 +85,9 @@ markers = ["integration: requiert une base PostgreSQL joignable, hors `make test [tool.coverage.run] source = ["app"] -omit = ["app/main.py", "alembic/*"] +branch = true +omit = ["alembic/*"] + +[tool.coverage.report] +show_missing = true +fail_under = 85 From d14b3afc8ea2095006b03503be0ada464f29c1e3 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:59:17 +0200 Subject: [PATCH 015/205] chore: ajoute une cible de rapports de tests make test-cov produit la couverture HTML et XML et les resultats au format JUnit, sans alourdir make test qui reste la boucle de developpement. Les trois artefacts sont ignores, contrairement au junit.xml versione cote frontend. --- .gitignore | 1 + Makefile | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index af1ea90..d1e16df 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ venv/ .coverage coverage.xml htmlcov/ +test-results/ dist/ build/ *.egg-info/ diff --git a/Makefile b/Makefile index aa29df8..1426c5a 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ BACKEND := apps/backend .DEFAULT_GOAL := help -.PHONY: help install dev lint format typecheck test test-integration check docker-build \ - db-up db-down db-reset db-logs db-psql migrate +.PHONY: help install dev lint format typecheck test test-cov test-integration check \ + docker-build db-up db-down db-reset db-logs db-psql migrate help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -25,6 +25,10 @@ typecheck: ## Verifie le typage du backend test: ## Execute les tests backend ne demandant pas de base cd $(BACKEND) && uv run pytest +test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit + cd $(BACKEND) && uv run pytest --cov-report=html --cov-report=xml \ + --junitxml=test-results/junit.xml + test-integration: ## Execute les tests exigeant une base joignable cd $(BACKEND) && uv run pytest -m integration From d58647cad4286e9b63ef61ae32a65310f749d03d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:00:42 +0200 Subject: [PATCH 016/205] test(backend): fournit une session reelle aux tests d'integration Les repositories a venir parlent du SQL : les eprouver sur un double ne prouve rien. La fixture ouvre une vraie connexion, d'ou le marqueur integration. --- apps/backend/tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 950fc69..4560b75 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator, Callable, Iterator import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.db.session import get_engine, get_session, get_session_factory @@ -64,3 +65,10 @@ def fake_session(app: FastAPI) -> Callable[..., None]: app.dependency_overrides[get_session] = override return install + + +# Contrainte : ouvre une vraie connexion, donc reservee aux tests `integration`. +@pytest.fixture +async def session() -> AsyncIterator[AsyncSession]: + async with get_session_factory()() as async_session: + yield async_session From 50dfa72c9dc75917aaf139e9d3e6773a51b4b447 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:01:09 +0200 Subject: [PATCH 017/205] test(backend): n'applique le seuil de couverture qu'aux suites completes Dans [tool.coverage.report], fail_under vaut aussi pour une execution partielle : make test-integration echouait a 71 % alors que son test passait, et un fichier joue seul aurait echoue des que le code aurait grossi. Le seuil passe donc en --cov-fail-under sur les cibles qui jouent toute la suite. --- Makefile | 6 +++--- apps/backend/pyproject.toml | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 1426c5a..81c3e6d 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +23,11 @@ typecheck: ## Verifie le typage du backend cd $(BACKEND) && uv run mypy app test: ## Execute les tests backend ne demandant pas de base - cd $(BACKEND) && uv run pytest + cd $(BACKEND) && uv run pytest --cov-fail-under=85 test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit - cd $(BACKEND) && uv run pytest --cov-report=html --cov-report=xml \ - --junitxml=test-results/junit.xml + cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \ + --cov-report=xml --junitxml=test-results/junit.xml test-integration: ## Execute les tests exigeant une base joignable cd $(BACKEND) && uv run pytest -m integration diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 87948f1..1c27c08 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -90,4 +90,3 @@ omit = ["alembic/*"] [tool.coverage.report] show_missing = true -fail_under = 85 From 08ad3bbe3486ba916dcf99ea44e268832599e9ec Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:01:21 +0200 Subject: [PATCH 018/205] docs(backend): consigne les conventions de tests unitaires Pendant de apps/frontend/TESTING.md : ou ecrire un test, comment le nommer, quoi tester selon la couche, les doubles par dependency_overrides, les marqueurs, et quatre gabarits copiables. --- apps/backend/TESTING.md | 136 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 apps/backend/TESTING.md diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md new file mode 100644 index 0000000..5cc8912 --- /dev/null +++ b/apps/backend/TESTING.md @@ -0,0 +1,136 @@ +# Conventions de tests unitaires : Backend + +## Outil + +pytest, avec pytest-asyncio en mode `auto` : un `async def test_*` est collecte sans +decorateur. Les appels HTTP passent par httpx sur `ASGITransport`, qui parle a +l'application en memoire, sans serveur ni port ouvert. + +## Ou ecrire les tests + +`tests/` est le miroir de `app/` : un test de `app/services/consumption.py` va dans +`tests/services/test_consumption.py`. Les paquets `core`, `db`, `services` et +`repositories` existent deja, vides, pour cette raison. + +## Nommage + +- Fonctions en anglais : `test___when_`. +- `ids=` de `parametrize` en francais : `ids=["erreur_sqlalchemy", "erreur_reseau"]`. +- Pas de docstring : le nom porte l'intention. + +## Structure attendue (Arrange / Act / Assert) + +Une ligne vide separe les trois temps, sans commentaire pour les annoncer. + +```python +async def test_readiness_returns_503_when_the_extension_is_missing( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 503 + assert response.json()["detail"] == "Extension TimescaleDB absente" +``` + +## Ce qui doit etre teste en priorite + +Le sens de dependance du backend est `endpoints -> services -> repositories -> models`. + +| Couche | Ce qu'on teste | +|---|---| +| `services/` | La logique metier, cas nominal et cas d'erreur. C'est la priorite. | +| `repositories/` | Chaque branche de decision, sous le marqueur `integration`. | +| `endpoints/` | Le code de statut et la forme de la reponse, pas la logique metier. | +| `schemas/` | Rien, sauf si le schema porte une validation ecrite a la main. | + +## Doubles + +On remplace une dependance FastAPI par `app.dependency_overrides`, jamais par +`unittest.mock`. `tests/factories.py` fournit le necessaire. + +- `fake_session(result=...)` : la session repond `result`. +- `fake_session(failure=...)` : la session leve l'exception. +- `make_settings(**overrides)` : fabrique une `Settings`, dont les valeurs priment sur + l'environnement et sur `.env`. C'est le moyen de tester `create_app` en `prod`. + +## Gabarit : un endpoint + +```python +from collections.abc import Callable + +from httpx import AsyncClient + + +async def test_endpoint_returns_the_expected_payload( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=42) + + response = await client.get("/api/v1/...") + + assert response.status_code == 200 + assert response.json() == {"valeur": 42} +``` + +## Gabarit : un service avec repository factice + +Un service ne connait que son repository : on lui en passe un faux, sans base ni session. + +```python +from app.services.consumption import ConsumptionService + + +class FakeRepository: + async def total_for(self, site_id: int) -> float: + return 12.5 + + +async def test_service_converts_the_total_to_kilowatt_hours() -> None: + service = ConsumptionService(FakeRepository()) + + total = await service.total_kwh(site_id=1) + + assert total == 12.5 +``` + +## Gabarit : un repository sur la vraie base + +Un repository parle du SQL : le tester sur un double ne prouve rien. Il porte donc le +marqueur `integration`, ecarte par defaut. + +```python +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.mark.integration +async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> None: + ... +``` + +## Marqueurs + +`integration` designe tout test exigeant une base joignable. `pytest` les ecarte par +defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre +declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus. + +## Couverture + +Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique +qu'aux cibles qui jouent toute la suite, `make test` et `make test-cov` : un fichier +joue seul affiche sa couverture sans jamais echouer dessus. Le detail se lit dans +`htmlcov/index.html` apres `make test-cov`. + +## Lancer les tests + +```bash +make test # suite unitaire, sans base +make test-cov # idem, plus les rapports HTML, XML et JUnit +make db-up && make test-integration # tests exigeant une base, demande Docker +make check # lint + typage + suite unitaire + +uv run pytest tests/api/test_health.py # un seul fichier +uv run pytest -k readiness # par motif de nom +``` From 6aeaca8ed118b03a5adc898249609fa4fd6800b5 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:01:34 +0200 Subject: [PATCH 019/205] docs: renvoie vers les conventions de tests Ajoute test/ a la liste des prefixes de branches, deja utilise par la branche d'outillage frontend, et remplace le corps a trous du gabarit de repository par un exemple complet, que ruff format acceptait mal. --- README.md | 2 +- apps/backend/README.md | 3 +++ apps/backend/TESTING.md | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 12342ac..7e2e4ae 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,6 @@ curl -s localhost:8000/api/v1/health/ready ## Conventions -- Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court. +- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Commits : Conventional Commits, portee = dossier de premier niveau concerne. - Toute decision structurante donne lieu a un ADR dans `docs/adr`. diff --git a/apps/backend/README.md b/apps/backend/README.md index d70b3ca..5d940b8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -41,6 +41,9 @@ uv run pytest # tests + couverture uv run pytest -m integration # tests exigeant une base joignable ``` +Les conventions de tests, les gabarits et le detail des marqueurs sont dans +[`TESTING.md`](TESTING.md). + `pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste jouable sans Docker. Ces tests visent la base `enervision_test`, creee par `db/init/110-test-database.sql` au premier demarrage du conteneur. diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index 5cc8912..f794421 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -104,10 +104,17 @@ marqueur `integration`, ecarte par defaut. import pytest from sqlalchemy.ext.asyncio import AsyncSession +from app.models.site import Site +from app.repositories.site import SiteRepository + @pytest.mark.integration async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> None: - ... + repository = SiteRepository(session) + + await repository.add(Site(name="Toulouse")) + + assert await repository.by_name("Toulouse") is not None ``` ## Marqueurs From a2727f9b5a7f8ca8b3ade68da6808437e0c9e364 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Tue, 15 Sep 2026 10:13:41 +0200 Subject: [PATCH 020/205] chore/k8s-single-node-k3s via Terraform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se connecte à la machine on-premise via SSH et installe k3s single-node puis instancie le module. apply reste à faire une fois la machine prête. --- .gitignore | 3 + .terraform.lock.hcl | 23 - .../docker/3.9.0/windows_386/CHANGELOG.md | 870 ------------------ .../docker/3.9.0/windows_386/LICENSE | 373 -------- .../docker/3.9.0/windows_386/README.md | 117 --- infra/README.md | 18 +- infra/terraform/environments/dev/.gitkeep | 0 infra/terraform/environments/dev/main.tf | 11 + infra/terraform/environments/dev/outputs.tf | 9 + .../environments/dev/terraform.tfvars.example | 7 + infra/terraform/environments/dev/variables.tf | 40 + infra/terraform/environments/dev/versions.tf | 14 + infra/terraform/modules/.gitkeep | 0 infra/terraform/modules/k3s/main.tf | 45 + infra/terraform/modules/k3s/outputs.tf | 9 + infra/terraform/modules/k3s/variables.tf | 39 + infra/terraform/modules/k3s/versions.tf | 10 + terraform.tf | 19 - test.txt | 1 - 19 files changed, 204 insertions(+), 1404 deletions(-) delete mode 100644 .terraform.lock.hcl delete mode 100644 .terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md delete mode 100644 .terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE delete mode 100644 .terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md delete mode 100644 infra/terraform/environments/dev/.gitkeep create mode 100644 infra/terraform/environments/dev/main.tf create mode 100644 infra/terraform/environments/dev/outputs.tf create mode 100644 infra/terraform/environments/dev/terraform.tfvars.example create mode 100644 infra/terraform/environments/dev/variables.tf create mode 100644 infra/terraform/environments/dev/versions.tf delete mode 100644 infra/terraform/modules/.gitkeep create mode 100644 infra/terraform/modules/k3s/main.tf create mode 100644 infra/terraform/modules/k3s/outputs.tf create mode 100644 infra/terraform/modules/k3s/variables.tf create mode 100644 infra/terraform/modules/k3s/versions.tf delete mode 100644 terraform.tf delete mode 100644 test.txt diff --git a/.gitignore b/.gitignore index af1ea90..a226853 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ override.tf override.tf.json *_override.tf *_override.tf.json +*.tfvars +!*.tfvars.example +kubeconfig # Airflow etl/airflow/logs/ diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl deleted file mode 100644 index da14477..0000000 --- a/.terraform.lock.hcl +++ /dev/null @@ -1,23 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/kreuzwerker/docker" { - version = "3.9.0" - constraints = "~> 3.0" - hashes = [ - "h1:MmhVJBgNpE2Fbksv/XObZJncwm4th4mFE7Ai+6LiIy4=", - "zh:0ead8281830e9b9496651282235d9a139ba1b1b6ff79e395eb8c78658dc446b9", - "zh:0f17d37d8d3872df3fb75c68b5272e0c981343f53b506a9675b4405191edd3ef", - "zh:11d50b37323874427c6d2a08b737d3c7707c8301fdd236c94485cf2828d0b14b", - "zh:32f6f9b847446054e2db3d72886ef2f1d1aa51a6d0dac42340b07dad18e3f28f", - "zh:5ea5c67668b5dcbda560dc6104b788a9bfc974d52f02f7886889b77cc0e5d248", - "zh:5fb19a0b07edc344cd3ddeeb9cfb3d183089deb7a6a94a7b22a583aa1712596b", - "zh:602a7ece444e2a142ec5245abb98e7a1a990a68afae2df63b6c85ec084f0c5d7", - "zh:693dce278524ad8a6d6c9dd7a01bcd63bb85189639198f8d0b044ab0e5099401", - "zh:72e9911568103576c6a78fa38841cfd45eeb88ad22a2c649eb140a377a5b3c26", - "zh:956b62b6857cbb467b50158601f01b1203daa34cbd447dcc7f044c327e878b68", - "zh:9d372bac0d4479868b34485fb4966ba7bb525938f818b6a625f4977004ea83f9", - "zh:e06658a51427f9f53dbdb06263406fc1bc56d1a4fb5e7eb660d7cdfc22f596bd", - "zh:eee38dadf672b946419af25160eae7c03fc2afbb14f39f2f1d2a7404d647e2f7", - ] -} diff --git a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md deleted file mode 100644 index 35b0c05..0000000 --- a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/CHANGELOG.md +++ /dev/null @@ -1,870 +0,0 @@ - - -## [v3.9.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.8.0...v3.9.0) (2025-11-09) - -### Chore - -* Add file requested by hashicorp ([#813](https://github.com/kreuzwerker/terraform-provider-docker/issues/813)) -* Prepare release v3.8.0 ([#806](https://github.com/kreuzwerker/terraform-provider-docker/issues/806)) - -### Feat - -* Implement caching of docker provider ([#808](https://github.com/kreuzwerker/terraform-provider-docker/issues/808)) - -### Fix - -* test attribute of docker_service healthcheck is not required ([#815](https://github.com/kreuzwerker/terraform-provider-docker/issues/815)) -* docker_service label can be updated without recreate ([#814](https://github.com/kreuzwerker/terraform-provider-docker/issues/814)) - - - -## [v3.8.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.7.0...v3.8.0) (2025-10-08) - -### Feat - -* Add build attribute for docker_registry_image ([#805](https://github.com/kreuzwerker/terraform-provider-docker/issues/805)) -* Add build option for additional contexts ([#798](https://github.com/kreuzwerker/terraform-provider-docker/issues/798)) -* implement mac_address for networks_advanced ([#794](https://github.com/kreuzwerker/terraform-provider-docker/issues/794)) -* Implement docker cluster volume ([#793](https://github.com/kreuzwerker/terraform-provider-docker/issues/793)) - - - -## [v3.7.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.2...v3.7.0) (2025-08-19) - -### Chore - -* Prepare release v3.7.0 ([#774](https://github.com/kreuzwerker/terraform-provider-docker/issues/774)) - -### Feat - -* Implement memory_reservation and network_mode enhancements ([#773](https://github.com/kreuzwerker/terraform-provider-docker/issues/773)) -* Implement cache_from and cache_to for docker_image ([#772](https://github.com/kreuzwerker/terraform-provider-docker/issues/772)) - -### Fix - -* Correctly get and set nanoCPUs for docker_container ([#771](https://github.com/kreuzwerker/terraform-provider-docker/issues/771)) - - - -## [v3.6.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.1...v3.6.2) (2025-06-13) - -### Chore - -* Prepare release v3.6.2 ([#750](https://github.com/kreuzwerker/terraform-provider-docker/issues/750)) - -### Feat - -* Allow digest in image name ([#744](https://github.com/kreuzwerker/terraform-provider-docker/issues/744)) - -### Fix - -* Remove wrong buildkit version assignment ([#747](https://github.com/kreuzwerker/terraform-provider-docker/issues/747)) -* Reading non existant volume should recreate ([#749](https://github.com/kreuzwerker/terraform-provider-docker/issues/749)) -* Typo in cgroup_parent handling ([#746](https://github.com/kreuzwerker/terraform-provider-docker/issues/746)) - - - -## [v3.6.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.0...v3.6.1) (2025-06-05) - -### Chore - -* Prepare release v3.6.1 ([#743](https://github.com/kreuzwerker/terraform-provider-docker/issues/743)) - -### Feat - -* allow to set the cgroup parent for container ([#609](https://github.com/kreuzwerker/terraform-provider-docker/issues/609)) - - - -## [v3.6.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.5.0...v3.6.0) (2025-05-25) - -### Chore - -* Prepare release v3.6.0 ([#735](https://github.com/kreuzwerker/terraform-provider-docker/issues/735)) - -### Feat - -* Implement correct cpu scheduler settings ([#732](https://github.com/kreuzwerker/terraform-provider-docker/issues/732)) -* Add implementaion of capabilities in docker servic ([#727](https://github.com/kreuzwerker/terraform-provider-docker/issues/727)) -* implement Buildx builder resource ([#724](https://github.com/kreuzwerker/terraform-provider-docker/issues/724)) - -### Fix - -* Implement buildx fixes for general buildkit support and platform handling ([#734](https://github.com/kreuzwerker/terraform-provider-docker/issues/734)) -* Make endpoint validation less strict ([#733](https://github.com/kreuzwerker/terraform-provider-docker/issues/733)) - - - -## [v3.5.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.4.0...v3.5.0) (2025-05-06) - -### Chore - -* Prepare release v3.5.0 ([#721](https://github.com/kreuzwerker/terraform-provider-docker/issues/721)) - -### Feat - -* Implement using of buildx for docker_image ([#717](https://github.com/kreuzwerker/terraform-provider-docker/issues/717)) -* Support registries that return empty auth scope [#646](https://github.com/kreuzwerker/terraform-provider-docker/issues/646) -* Implement registry_image_manifests data source ([#714](https://github.com/kreuzwerker/terraform-provider-docker/issues/714)) -* Implement healthcheck start interval ([#713](https://github.com/kreuzwerker/terraform-provider-docker/issues/713)) - - - -## [v3.4.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.3.0...v3.4.0) (2025-04-25) - -### Chore - -* Prepare release v3.4.0 ([#712](https://github.com/kreuzwerker/terraform-provider-docker/issues/712)) - -### Feat - -* Implement volume_options subpath ([#710](https://github.com/kreuzwerker/terraform-provider-docker/issues/710)) - -### Fix - -* Prevent recreation of image name is intentionally set to a fixed value ([#711](https://github.com/kreuzwerker/terraform-provider-docker/issues/711)) -* Improve container wait handling ([#709](https://github.com/kreuzwerker/terraform-provider-docker/issues/709)) -* Use auth_config block also for registry_image delete functionality ([#708](https://github.com/kreuzwerker/terraform-provider-docker/issues/708)) - - - -## [v3.3.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.2.0...v3.3.0) (2025-04-19) - -### Chore - -* Prepare release v3.3.0 ([#705](https://github.com/kreuzwerker/terraform-provider-docker/issues/705)) -* Update terraform-plugin-sdk/v2 dependency ([#699](https://github.com/kreuzwerker/terraform-provider-docker/issues/699)) -* Update docker/docker and docker/cli to newest stable ([#695](https://github.com/kreuzwerker/terraform-provider-docker/issues/695)) - -### Feat - -* Implement support for docker context ([#704](https://github.com/kreuzwerker/terraform-provider-docker/issues/704)) -* disable_docker_daemon_check for provider ([#703](https://github.com/kreuzwerker/terraform-provider-docker/issues/703)) -* Implement tag triggers for docker_tag resource ([#702](https://github.com/kreuzwerker/terraform-provider-docker/issues/702)) -* Implement auth_config for docker_registry_image ([#701](https://github.com/kreuzwerker/terraform-provider-docker/issues/701)) - -### Fix - -* Store correctly ports from server ([#698](https://github.com/kreuzwerker/terraform-provider-docker/issues/698)) - - - -## [v3.2.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.2...v3.2.0) (2025-04-16) - -### Chore - -* Prepare release v3.2.0 ([#694](https://github.com/kreuzwerker/terraform-provider-docker/issues/694)) -* Upgrade golangci-lint to next major version ([#686](https://github.com/kreuzwerker/terraform-provider-docker/issues/686)) - -### Docs - -* Consolidated update of docs from several PRs ([#691](https://github.com/kreuzwerker/terraform-provider-docker/issues/691)) - -### Feat - -* Implement upload permissions in docker_container resource ([#693](https://github.com/kreuzwerker/terraform-provider-docker/issues/693)) -* Implement docker_image timeouts ([#692](https://github.com/kreuzwerker/terraform-provider-docker/issues/692)) -* Add support for build-secrets ([#604](https://github.com/kreuzwerker/terraform-provider-docker/issues/604)) - -### Fix - -* Authentication to ECR public ([#690](https://github.com/kreuzwerker/terraform-provider-docker/issues/690)) - - - -## [v3.1.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.1...v3.1.2) (2025-04-15) - -### Chore - -* prepare release 3.1.2 ([#688](https://github.com/kreuzwerker/terraform-provider-docker/issues/688)) - - - -## [v3.1.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.0...v3.1.1) (2025-04-14) - -### Chore - -* Prepare release 3.1.1 ([#687](https://github.com/kreuzwerker/terraform-provider-docker/issues/687)) - - - -## [v3.1.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.2...v3.1.0) (2025-04-14) - -### Chore - -* Prepare release 3.1.0 ([#685](https://github.com/kreuzwerker/terraform-provider-docker/issues/685)) -* update Go version to 1.22 for consistency across workflows, jo… ([#613](https://github.com/kreuzwerker/terraform-provider-docker/issues/613)) - -### Feat - -* support setting cpu shares ([#575](https://github.com/kreuzwerker/terraform-provider-docker/issues/575)) - -### Fix - -* Use build_args everywhere and update documentation ([#681](https://github.com/kreuzwerker/terraform-provider-docker/issues/681)) -* Compress build context before sending it to Docker ([#461](https://github.com/kreuzwerker/terraform-provider-docker/issues/461)) -* Set correct default network driver and fix a test ([#677](https://github.com/kreuzwerker/terraform-provider-docker/issues/677)) - -### Typo - -* s/presend/present/ ([#606](https://github.com/kreuzwerker/terraform-provider-docker/issues/606)) - - - -## [v3.0.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.1...v3.0.2) (2023-03-17) - -### Chore - -* Prepare release v3.0.2 - -### Docs - -* correct spelling of "networks_advanced" ([#517](https://github.com/kreuzwerker/terraform-provider-docker/issues/517)) - -### Fix - -* Implement proxy support. ([#529](https://github.com/kreuzwerker/terraform-provider-docker/issues/529)) - - - -## [v3.0.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.0...v3.0.1) (2023-01-13) - -### Chore - -* Prepare release v3.0.1 - -### Fix - -* Access health of container correctly. ([#506](https://github.com/kreuzwerker/terraform-provider-docker/issues/506)) - - - -## [v3.0.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.25.0...v3.0.0) (2023-01-13) - -### Chore - -* Prepare release v3.0.0 - -### Docs - -* Update documentation. -* Add migration guide and update README ([#502](https://github.com/kreuzwerker/terraform-provider-docker/issues/502)) - -### Feat - -* Prepare v3 release ([#503](https://github.com/kreuzwerker/terraform-provider-docker/issues/503)) - - - -## [v2.25.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.24.0...v2.25.0) (2023-01-05) - -### Chore - -* Prepare release v2.25.0 - -### Docs - -* Add documentation of remote hosts. ([#498](https://github.com/kreuzwerker/terraform-provider-docker/issues/498)) - -### Feat - -* Migrate build block to `docker_image` ([#501](https://github.com/kreuzwerker/terraform-provider-docker/issues/501)) -* Add platform attribute to docker_image resource ([#500](https://github.com/kreuzwerker/terraform-provider-docker/issues/500)) -* Add sysctl implementation to container of docker_service. ([#499](https://github.com/kreuzwerker/terraform-provider-docker/issues/499)) - - - -## [v2.24.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.23.1...v2.24.0) (2022-12-23) - -### Chore - -* Prepare release v2.24.0 - -### Docs - -* Fix generated website. -* Update command typo ([#487](https://github.com/kreuzwerker/terraform-provider-docker/issues/487)) - -### Feat - -* cgroupns support ([#497](https://github.com/kreuzwerker/terraform-provider-docker/issues/497)) -* Add triggers attribute to docker_registry_image ([#496](https://github.com/kreuzwerker/terraform-provider-docker/issues/496)) -* Support registries with disabled auth ([#494](https://github.com/kreuzwerker/terraform-provider-docker/issues/494)) -* add IPAM options block for docker networks ([#491](https://github.com/kreuzwerker/terraform-provider-docker/issues/491)) - -### Fix - -* Pin data source specific tag test to older tag. - -### Tests - -* Add test for parsing auth headers. - - - -## [v2.23.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.23.0...v2.23.1) (2022-11-23) - -### Chore - -* Prepare release v2.23.1 - -### Fix - -* Update shasum of busybox:1.35.0 tag in test. -* Handle Auth Header Scopes ([#482](https://github.com/kreuzwerker/terraform-provider-docker/issues/482)) -* Set OS_ARCH from GOHOSTOS and GOHOSTARCH ([#477](https://github.com/kreuzwerker/terraform-provider-docker/issues/477)) - - - -## [v2.23.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.22.0...v2.23.0) (2022-11-02) - -### Chore - -* Prepare release v2.23.0 - -### Feat - -* wait container healthy state ([#467](https://github.com/kreuzwerker/terraform-provider-docker/issues/467)) -* add docker logs data source ([#471](https://github.com/kreuzwerker/terraform-provider-docker/issues/471)) - -### Fix - -* Update shasum of busybox:1.35.0 tag in test. -* Update shasum of busybox:1.35.0 tag -* Correct provider name to match the public registry ([#462](https://github.com/kreuzwerker/terraform-provider-docker/issues/462)) - - - -## [v2.22.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.21.0...v2.22.0) (2022-09-20) - -### Chore - -* Prepare release v2.22.0 - -### Feat - -* Configurable timeout for docker_container resource stateChangeConf ([#454](https://github.com/kreuzwerker/terraform-provider-docker/issues/454)) - -### Fix - -* oauth authorization support for azurecr ([#451](https://github.com/kreuzwerker/terraform-provider-docker/issues/451)) - - - -## [v2.21.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.3...v2.21.0) (2022-09-05) - -### Chore - -* Prepare release v2.21.0 - -### Docs - -* Fix docker config example. - -### Feat - -* Add image_id attribute to docker_image resource. ([#450](https://github.com/kreuzwerker/terraform-provider-docker/issues/450)) -* Update used goversion to 1.18. ([#449](https://github.com/kreuzwerker/terraform-provider-docker/issues/449)) - -### Fix - -* Replace deprecated .latest attribute with new image_id. ([#453](https://github.com/kreuzwerker/terraform-provider-docker/issues/453)) -* Remove reading part of docker_tag resource. ([#448](https://github.com/kreuzwerker/terraform-provider-docker/issues/448)) -* Fix repo_digest value for DockerImageDatasource test. - - - -## [v2.20.3](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.2...v2.20.3) (2022-08-31) - -### Chore - -* Prepare release v2.20.3 - -### Fix - -* Docker Registry Image data source use HEAD request to query image digest ([#433](https://github.com/kreuzwerker/terraform-provider-docker/issues/433)) -* Adding Support for Windows Paths in Bash ([#438](https://github.com/kreuzwerker/terraform-provider-docker/issues/438)) - - - -## [v2.20.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.1...v2.20.2) (2022-08-10) - -### Chore - -* Prepare release v2.20.2 - -### Fix - -* Check the operating system for determining the default Docker socket ([#427](https://github.com/kreuzwerker/terraform-provider-docker/issues/427)) - -### Reverts - -* fix(deps): update module github.com/golangci/golangci-lint to v1.48.0 ([#423](https://github.com/kreuzwerker/terraform-provider-docker/issues/423)) - - - -## [v2.20.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.0...v2.20.1) (2022-08-10) - -### Chore - -* Prepare release v2.20.1 -* Reduce time to setup AccTests ([#430](https://github.com/kreuzwerker/terraform-provider-docker/issues/430)) - -### Docs - -* Improve docker network usage documentation [skip-ci] - -### Feat - -* Implement triggers attribute for docker_image. ([#425](https://github.com/kreuzwerker/terraform-provider-docker/issues/425)) - -### Fix - -* Add ForceTrue to docker_image name attribute. ([#421](https://github.com/kreuzwerker/terraform-provider-docker/issues/421)) - - - -## [v2.20.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.19.0...v2.20.0) (2022-07-28) - -### Chore - -* Prepare release v2.20.0 -* Fix release targets in Makefile. - -### Feat - -* Implementation of `docker_tag` resource. ([#418](https://github.com/kreuzwerker/terraform-provider-docker/issues/418)) -* Implement support for insecure registries ([#414](https://github.com/kreuzwerker/terraform-provider-docker/issues/414)) - - - -## [v2.19.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.18.1...v2.19.0) (2022-07-15) - -### Chore - -* Prepare release v2.19.0 - -### Feat - -* Add gpu flag to docker_container resource ([#405](https://github.com/kreuzwerker/terraform-provider-docker/issues/405)) - -### Fix - -* Enable authentication to multiple registries again. ([#400](https://github.com/kreuzwerker/terraform-provider-docker/issues/400)) -* ECR authentication ([#409](https://github.com/kreuzwerker/terraform-provider-docker/issues/409)) - - - -## [v2.18.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.18.0...v2.18.1) (2022-07-14) - -### Chore - -* Prepare release v2.18.1 -* Automate changelog generation [skip ci] - -### Fix - -* Improve searchLocalImages error handling. ([#407](https://github.com/kreuzwerker/terraform-provider-docker/issues/407)) -* Throw errors when any part of docker config file handling goes wrong. ([#406](https://github.com/kreuzwerker/terraform-provider-docker/issues/406)) -* Enables having a Dockerfile outside the context ([#402](https://github.com/kreuzwerker/terraform-provider-docker/issues/402)) - - - -## [v2.18.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.17.0...v2.18.0) (2022-07-11) - -### Chore - -* prepare release v2.18.0 - -### Feat - -* add runtime, stop_signal and stop_timeout properties to the docker_container resource ([#364](https://github.com/kreuzwerker/terraform-provider-docker/issues/364)) - -### Fix - -* Correctly handle build files and context for docker_registry_image ([#398](https://github.com/kreuzwerker/terraform-provider-docker/issues/398)) -* Switch to proper go tools mechanism to fix website-* workflows. ([#399](https://github.com/kreuzwerker/terraform-provider-docker/issues/399)) -* compare relative paths when excluding, fixes kreuzwerker[#280](https://github.com/kreuzwerker/terraform-provider-docker/issues/280) ([#397](https://github.com/kreuzwerker/terraform-provider-docker/issues/397)) - - - -## [v2.17.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.16.0...v2.17.0) (2022-06-23) - -### Chore - -* prepare release v2.17.0 -* Exclude examples directory from renovate. -* remove the workflow to close stale issues and pull requests ([#371](https://github.com/kreuzwerker/terraform-provider-docker/issues/371)) - -### Fix - -* update go package files directly on master to fix build. -* correct authentication for ghcr.io registry([#349](https://github.com/kreuzwerker/terraform-provider-docker/issues/349)) - - - -## [v2.16.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.15.0...v2.16.0) (2022-01-24) - -### Chore - -* prepare release v2.16.0 - -### Docs - -* fix service options ([#337](https://github.com/kreuzwerker/terraform-provider-docker/issues/337)) -* update registry_image.md ([#321](https://github.com/kreuzwerker/terraform-provider-docker/issues/321)) -* fix r/registry_image truncated docs ([#304](https://github.com/kreuzwerker/terraform-provider-docker/issues/304)) - -### Feat - -* add parameter for SSH options ([#335](https://github.com/kreuzwerker/terraform-provider-docker/issues/335)) - -### Fix - -* pass container rm flag ([#322](https://github.com/kreuzwerker/terraform-provider-docker/issues/322)) -* add nil check of DriverConfig ([#315](https://github.com/kreuzwerker/terraform-provider-docker/issues/315)) -* fmt of go files for go 1.17 - - - -## [v2.15.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.14.0...v2.15.0) (2021-08-11) - -### Chore - -* prepare release v2.15.0 -* re go gets terraform-plugin-docs - -### Docs - -* corrects authentication misspell. Closes [#264](https://github.com/kreuzwerker/terraform-provider-docker/issues/264) - -### Feat - -* add container storage opts ([#258](https://github.com/kreuzwerker/terraform-provider-docker/issues/258)) - -### Fix - -* add current timestamp for file upload to container ([#259](https://github.com/kreuzwerker/terraform-provider-docker/issues/259)) - - - -## [v2.14.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.13.0...v2.14.0) (2021-07-09) - -### Chore - -* prepare release v2.14.0 - -### Docs - -* update to absolute path for registry image context ([#246](https://github.com/kreuzwerker/terraform-provider-docker/issues/246)) -* update readme with logos and subsections ([#235](https://github.com/kreuzwerker/terraform-provider-docker/issues/235)) - -### Feat - -* support terraform v1 ([#242](https://github.com/kreuzwerker/terraform-provider-docker/issues/242)) - -### Fix - -* Update the URL of the docker hub registry ([#230](https://github.com/kreuzwerker/terraform-provider-docker/issues/230)) - - - -## [v2.13.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.2...v2.13.0) (2021-06-22) - -### Chore - -* prepare release v2.13.0 - -### Docs - -* fix a few typos ([#216](https://github.com/kreuzwerker/terraform-provider-docker/issues/216)) -* fix typos in docker_image example usage ([#213](https://github.com/kreuzwerker/terraform-provider-docker/issues/213)) - - - -## [v2.12.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.1...v2.12.2) (2021-05-26) - -### Chore - -* prepare release v2.12.2 - - - -## [v2.12.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.0...v2.12.1) (2021-05-26) - -### Chore - -* update changelog for v2.12.1 - -### Fix - -* add service host flattener with space split ([#205](https://github.com/kreuzwerker/terraform-provider-docker/issues/205)) -* service state upgradeV2 for empty auth - - - -## [v2.12.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.11.0...v2.12.0) (2021-05-23) - -### Chore - -* update changelog for v2.12.0 -* ignore dist folder -* configure actions/stale ([#157](https://github.com/kreuzwerker/terraform-provider-docker/issues/157)) -* add the guide about Terraform Configuration in Bug Report ([#139](https://github.com/kreuzwerker/terraform-provider-docker/issues/139)) -* bump docker dependency to v20.10.5 ([#119](https://github.com/kreuzwerker/terraform-provider-docker/issues/119)) - -### Ci - -* run acceptance tests with multiple Terraform versions ([#129](https://github.com/kreuzwerker/terraform-provider-docker/issues/129)) - -### Docs - -* update for v2.12.0 -* add releasing steps -* format `Guide of Bug report` ([#159](https://github.com/kreuzwerker/terraform-provider-docker/issues/159)) -* add an example to build an image with docker_image ([#158](https://github.com/kreuzwerker/terraform-provider-docker/issues/158)) -* add a guide about writing issues to CONTRIBUTING.md ([#149](https://github.com/kreuzwerker/terraform-provider-docker/issues/149)) -* fix Github repository URL in README ([#136](https://github.com/kreuzwerker/terraform-provider-docker/issues/136)) - -### Feat - -* support darwin arm builds and golang 1.16 ([#140](https://github.com/kreuzwerker/terraform-provider-docker/issues/140)) -* migrate to terraform-sdk v2 ([#102](https://github.com/kreuzwerker/terraform-provider-docker/issues/102)) - -### Fix - -* rewriting tar header fields ([#198](https://github.com/kreuzwerker/terraform-provider-docker/issues/198)) -* test spaces for windows ([#190](https://github.com/kreuzwerker/terraform-provider-docker/issues/190)) -* replace for loops with StateChangeConf ([#182](https://github.com/kreuzwerker/terraform-provider-docker/issues/182)) -* skip sign on compile action -* assign map to rawState when it is nil to prevent panic ([#180](https://github.com/kreuzwerker/terraform-provider-docker/issues/180)) -* search local images with Docker image ID ([#151](https://github.com/kreuzwerker/terraform-provider-docker/issues/151)) -* set "ForceNew: true" to labelSchema ([#152](https://github.com/kreuzwerker/terraform-provider-docker/issues/152)) - - - -## [v2.11.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.10.0...v2.11.0) (2021-01-22) - -### Chore - -* update changelog for v2.11.0 -* updates changelog for v2.10.0 - -### Docs - -* fix legacy configuration style ([#126](https://github.com/kreuzwerker/terraform-provider-docker/issues/126)) - -### Feat - -* add properties -it (tty and stdin_opn) to docker container - - - -## [v2.10.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.9.0...v2.10.0) (2021-01-08) - -### Chore - -* updates changelog for 2.10.0 -* ignores testing folders -* adds separate bug and ft req templates - -### Ci - -* bumps to docker version 20.10.1 -* pins workflows to ubuntu:20.04 image - -### Docs - -* add labels to arguments of docker_service ([#105](https://github.com/kreuzwerker/terraform-provider-docker/issues/105)) -* cleans readme -* adds coc and contributing - -### Feat - -* supports Docker plugin ([#35](https://github.com/kreuzwerker/terraform-provider-docker/issues/35)) -* support max replicas of Docker Service Task Spec ([#112](https://github.com/kreuzwerker/terraform-provider-docker/issues/112)) -* add force_remove option to r/image ([#104](https://github.com/kreuzwerker/terraform-provider-docker/issues/104)) -* add local semantic commit validation ([#99](https://github.com/kreuzwerker/terraform-provider-docker/issues/99)) -* add ability to lint/check of links in documentation locally ([#98](https://github.com/kreuzwerker/terraform-provider-docker/issues/98)) - -### Fix - -* set "latest" to tag when tag isn't specified ([#117](https://github.com/kreuzwerker/terraform-provider-docker/issues/117)) -* image label for workflows -* remove all azure cps - -### Pull Requests - -* Merge pull request [#38](https://github.com/kreuzwerker/terraform-provider-docker/issues/38) from kreuzwerker/ci-ubuntu2004-workflow -* Merge pull request [#36](https://github.com/kreuzwerker/terraform-provider-docker/issues/36) from kreuzwerker/chore-gh-issue-tpl - - - -## [v2.9.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.8.0...v2.9.0) (2020-12-25) - -### Chore - -* updates changelog for 2.9.0 -* update changelog 2.8.0 release date -* introduces golangci-lint ([#32](https://github.com/kreuzwerker/terraform-provider-docker/issues/32)) -* fix changelog links - -### Ci - -* add gofmt's '-s' option -* remove unneeded make tasks -* fix test of website - -### Doc - -* devices is a block, not a boolean - -### Feat - -* adds support for OCI manifests ([#316](https://github.com/kreuzwerker/terraform-provider-docker/issues/316)) -* adds security_opts to container config. ([#308](https://github.com/kreuzwerker/terraform-provider-docker/issues/308)) -* adds support for init process injection for containers. ([#300](https://github.com/kreuzwerker/terraform-provider-docker/issues/300)) - -### Fix - -* changing mounts requires ForceNew ([#314](https://github.com/kreuzwerker/terraform-provider-docker/issues/314)) -* allow healthcheck to be computed as container can specify ([#312](https://github.com/kreuzwerker/terraform-provider-docker/issues/312)) -* treat null user as a no-op ([#318](https://github.com/kreuzwerker/terraform-provider-docker/issues/318)) -* workdir null behavior ([#320](https://github.com/kreuzwerker/terraform-provider-docker/issues/320)) - -### Style - -* format with gofumpt - -### Pull Requests - -* Merge pull request [#33](https://github.com/kreuzwerker/terraform-provider-docker/issues/33) from brandonros/patch-1 -* Merge pull request [#11](https://github.com/kreuzwerker/terraform-provider-docker/issues/11) from suzuki-shunsuke/format-with-gofumpt -* Merge pull request [#26](https://github.com/kreuzwerker/terraform-provider-docker/issues/26) from kreuzwerker/ci/fix-website-ci -* Merge pull request [#8](https://github.com/kreuzwerker/terraform-provider-docker/issues/8) from dubo-dubon-duponey/patch1 - - - -## v2.8.0 (2020-11-11) - -### Chore - -* updates changelog for 2.8.0 -* removes travis.yml -* deactivates travis -* removes vendor dir ([#298](https://github.com/kreuzwerker/terraform-provider-docker/issues/298)) -* bump go 115 ([#297](https://github.com/kreuzwerker/terraform-provider-docker/issues/297)) -* documentation updates ([#286](https://github.com/kreuzwerker/terraform-provider-docker/issues/286)) -* updates link syntax ([#287](https://github.com/kreuzwerker/terraform-provider-docker/issues/287)) -* fix typo ([#292](https://github.com/kreuzwerker/terraform-provider-docker/issues/292)) - -### Ci - -* reactivats all workflows -* fix website -* only run website workflow -* exports gopath manually -* fix absolute gopath for website -* make website check separate workflow -* fix workflow names -* adds website test to unit test -* adds acc test -* adds compile -* adds go version and goproxy env -* enables unit tests for master branch -* adds unit test workflow -* adds goreleaser and gh action -* bumps docker and ubuntu versions ([#241](https://github.com/kreuzwerker/terraform-provider-docker/issues/241)) -* removes debug option from acc tests -* skips test which is flaky only on travis - -### Deps - -* github.com/hashicorp/terraform[@sdk](https://github.com/sdk)-v0.11-with-go-modules Updated via: go get github.com/hashicorp/terraform[@sdk](https://github.com/sdk)-v0.11-with-go-modules and go mod tidy -* use go modules for dep mgmt run go mod tidy remove govendor from makefile and travis config set appropriate env vars for go modules - -### Docker - -* improve validation of runtime constraints - -### Docs - -* update container.html.markdown ([#278](https://github.com/kreuzwerker/terraform-provider-docker/issues/278)) -* update service.html.markdown ([#281](https://github.com/kreuzwerker/terraform-provider-docker/issues/281)) -* update restart_policy for service. Closes [#228](https://github.com/kreuzwerker/terraform-provider-docker/issues/228) -* adds new label structure. Closes [#214](https://github.com/kreuzwerker/terraform-provider-docker/issues/214) -* update anchors with -1 suffix ([#178](https://github.com/kreuzwerker/terraform-provider-docker/issues/178)) -* Fix misspelled words -* Fix exported attribute name in docker_registry_image -* Fix example for docker_registry_image ([#8308](https://github.com/kreuzwerker/terraform-provider-docker/issues/8308)) -* provider/docker - network settings attrs - -### Feat - -* conditionally adding port binding ([#293](https://github.com/kreuzwerker/terraform-provider-docker/issues/293)). -* adds docker Image build feature ([#283](https://github.com/kreuzwerker/terraform-provider-docker/issues/283)) -* adds complete support for Docker credential helpers ([#253](https://github.com/kreuzwerker/terraform-provider-docker/issues/253)) -* Expose IPv6 properties as attributes -* allow use of source file instead of content / content_base64 ([#240](https://github.com/kreuzwerker/terraform-provider-docker/issues/240)) -* supports to update docker_container ([#236](https://github.com/kreuzwerker/terraform-provider-docker/issues/236)) -* support to import some docker_container's attributes ([#234](https://github.com/kreuzwerker/terraform-provider-docker/issues/234)) -* adds config file content as plain string ([#232](https://github.com/kreuzwerker/terraform-provider-docker/issues/232)) -* make UID, GID, & mode for secrets and configs configurable ([#231](https://github.com/kreuzwerker/terraform-provider-docker/issues/231)) -* adds import for resources ([#196](https://github.com/kreuzwerker/terraform-provider-docker/issues/196)) -* add container ipc mode. ([#182](https://github.com/kreuzwerker/terraform-provider-docker/issues/182)) -* adds container working dir ([#181](https://github.com/kreuzwerker/terraform-provider-docker/issues/181)) - -### Fix - -* ignores 'remove_volumes' on container import -* duplicated buildImage function -* port objects with the same internal port but different protocol trigger recreation of container ([#274](https://github.com/kreuzwerker/terraform-provider-docker/issues/274)) -* panic to migrate schema of docker_container from v1 to v2 ([#271](https://github.com/kreuzwerker/terraform-provider-docker/issues/271)). Closes [#264](https://github.com/kreuzwerker/terraform-provider-docker/issues/264) -* pins docker registry for tests to v2.7.0 -* prevent force recreate of container about some attributes ([#269](https://github.com/kreuzwerker/terraform-provider-docker/issues/269)) -* service endpoint spec flattening -* corrects IPAM config read on the data provider ([#229](https://github.com/kreuzwerker/terraform-provider-docker/issues/229)) -* replica to 0 in current schema. Closes [#221](https://github.com/kreuzwerker/terraform-provider-docker/issues/221) -* label for network and volume after improt -* binary upload as base 64 content ([#194](https://github.com/kreuzwerker/terraform-provider-docker/issues/194)) -* service env truncation for multiple delimiters ([#193](https://github.com/kreuzwerker/terraform-provider-docker/issues/193)) -* destroy_grace_seconds are considered ([#179](https://github.com/kreuzwerker/terraform-provider-docker/issues/179)) - -### Make - -* Add website + website-test targets - -### Provider - -* Ensured Go 1.11 in TravisCI and README provider: Run go fix provider: Run go fmt provider: Encode go version 1.11.5 to .go-version file -* Require Go 1.11 in TravisCI and README provider: Run go fix provider: Run go fmt - -### Tests - -* Skip test if swap limit isn't available ([#136](https://github.com/kreuzwerker/terraform-provider-docker/issues/136)) -* Simplify Dockerfile(s) - -### Vendor - -* github.com/hashicorp/terraform/...[@v0](https://github.com/v0).10.0 -* Ignore github.com/hashicorp/terraform/backend - -### Website - -* Docs sweep for lists & maps -* note on docker -* docker docs - -### Pull Requests - -* Merge pull request [#134](https://github.com/kreuzwerker/terraform-provider-docker/issues/134) from terraform-providers/go-modules-2019-03-01 -* Merge pull request [#135](https://github.com/kreuzwerker/terraform-provider-docker/issues/135) from terraform-providers/t-simplify-dockerfile -* Merge pull request [#47](https://github.com/kreuzwerker/terraform-provider-docker/issues/47) from captn3m0/docker-link-warning -* Merge pull request [#60](https://github.com/kreuzwerker/terraform-provider-docker/issues/60) from terraform-providers/f-make-website -* Merge pull request [#23](https://github.com/kreuzwerker/terraform-provider-docker/issues/23) from JamesLaverack/patch-1 -* Merge pull request [#18](https://github.com/kreuzwerker/terraform-provider-docker/issues/18) from terraform-providers/vendor-tf-0.10 -* Merge pull request [#5046](https://github.com/kreuzwerker/terraform-provider-docker/issues/5046) from tpounds/use-built-in-schema-string-hash -* Merge pull request [#3761](https://github.com/kreuzwerker/terraform-provider-docker/issues/3761) from ryane/f-provider-docker-improvements -* Merge pull request [#3383](https://github.com/kreuzwerker/terraform-provider-docker/issues/3383) from apparentlymart/docker-container-command-docs -* Merge pull request [#1564](https://github.com/kreuzwerker/terraform-provider-docker/issues/1564) from nickryand/docker_links - diff --git a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE deleted file mode 100644 index a612ad9..0000000 --- a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md b/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md deleted file mode 100644 index 6be3b5f..0000000 --- a/.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/windows_386/README.md +++ /dev/null @@ -1,117 +0,0 @@ - - Docker logo - - - Terraform logo - - - Kreuzwerker logo - - -# Terraform Provider for Docker - -[![Release](https://img.shields.io/github/v/release/kreuzwerker/terraform-provider-docker)](https://github.com/kreuzwerker/terraform-provider-docker/releases) -[![Installs](https://img.shields.io/badge/dynamic/json?logo=terraform&label=installs&query=$.data.attributes.downloads&url=https%3A%2F%2Fregistry.terraform.io%2Fv2%2Fproviders%2F713)](https://registry.terraform.io/providers/kreuzwerker/docker) -[![Registry](https://img.shields.io/badge/registry-doc%40latest-lightgrey?logo=terraform)](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kreuzwerker/terraform-provider-docker/blob/main/LICENSE) -[![Go Status](https://github.com/kreuzwerker/terraform-provider-docker/workflows/Acc%20Tests/badge.svg)](https://github.com/kreuzwerker/terraform-provider-docker/actions) -[![Lint Status](https://github.com/kreuzwerker/terraform-provider-docker/workflows/golangci-lint/badge.svg)](https://github.com/kreuzwerker/terraform-provider-docker/actions) -[![Go Report Card](https://goreportcard.com/badge/github.com/kreuzwerker/terraform-provider-docker)](https://goreportcard.com/report/github.com/kreuzwerker/terraform-provider-docker) - -## Documentation - -The documentation for the provider is available on the [Terraform Registry](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). - -Do you want to migrate from `v2.x` to `v3.x`? Please read the [migration guide](docs/v2_v3_migration.md) - -## Example usage - -Take a look at the examples in the [documentation](https://registry.terraform.io/providers/kreuzwerker/docker/3.9.0/docs) of the registry -or use the following example: - - -```hcl -# Set the required provider and versions -terraform { - required_providers { - # We recommend pinning to the specific version of the Docker Provider you're using - # since new versions are released frequently - docker = { - source = "kreuzwerker/docker" - version = "3.9.0" - } - } -} - -# Configure the docker provider -provider "docker" { -} - -# Create a docker image resource -# -> docker pull nginx:latest -resource "docker_image" "nginx" { - name = "nginx:latest" - keep_locally = true -} - -# Create a docker container resource -# -> same as 'docker run --name nginx -p8080:80 -d nginx:latest' -resource "docker_container" "nginx" { - name = "nginx" - image = docker_image.nginx.image_id - - ports { - external = 8080 - internal = 80 - } -} - -# Or create a service resource -# -> same as 'docker service create -d -p 8081:80 --name nginx-service --replicas 2 nginx:latest' -resource "docker_service" "nginx_service" { - name = "nginx-service" - task_spec { - container_spec { - image = docker_image.nginx.repo_digest - } - } - - mode { - replicated { - replicas = 2 - } - } - - endpoint_spec { - ports { - published_port = 8081 - target_port = 80 - } - } -} -``` - -## Building The Provider - -[Go](https://golang.org/doc/install) 1.18.x (to build the provider plugin) - - -```sh -$ git clone git@github.com:kreuzwerker/terraform-provider-docker -$ make build -``` - -## Contributing - -The Terraform Docker Provider is the work of many of contributors. We appreciate your help! - -To contribute, please read the contribution guidelines: [Contributing to Terraform - Docker Provider](CONTRIBUTING.md) - -## License - -The Terraform Provider Docker is available to everyone under the terms of the Mozilla Public License Version 2.0. [Take a look the LICENSE file](LICENSE). - - -## Stargazers over time - -[![Stargazers over time](https://starchart.cc/kreuzwerker/terraform-provider-docker.svg)](https://starchart.cc/kreuzwerker/terraform-provider-docker) diff --git a/infra/README.md b/infra/README.md index bff8fbe..4866222 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,6 +1,22 @@ # Infrastructure -Provisionnement Terraform de la machine on-premise. Non initialise, voir le ticket dedie. +Provisionnement Terraform de la machine on-premise (serveur physique, accessible en SSH). - `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. + +## Usage (environments/dev) + +```bash +cd infra/terraform/environments/dev +cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path +terraform init +terraform apply +``` + +Le kubeconfig est ecrit localement au chemin defini par `kubeconfig_output_path` +(par defaut `./kubeconfig`, ignore par git). diff --git a/infra/terraform/environments/dev/.gitkeep b/infra/terraform/environments/dev/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf new file mode 100644 index 0000000..1361f4d --- /dev/null +++ b/infra/terraform/environments/dev/main.tf @@ -0,0 +1,11 @@ +module "k3s" { + source = "../../modules/k3s" + + ssh_host = var.ssh_host + ssh_port = var.ssh_port + ssh_user = var.ssh_user + ssh_private_key_path = var.ssh_private_key_path + k3s_version = var.k3s_version + k3s_disable_components = var.k3s_disable_components + kubeconfig_output_path = var.kubeconfig_output_path +} diff --git a/infra/terraform/environments/dev/outputs.tf b/infra/terraform/environments/dev/outputs.tf new file mode 100644 index 0000000..69cd419 --- /dev/null +++ b/infra/terraform/environments/dev/outputs.tf @@ -0,0 +1,9 @@ +output "kubeconfig_path" { + description = "Chemin local du kubeconfig recupere apres installation." + value = module.k3s.kubeconfig_path +} + +output "node_host" { + description = "Adresse du serveur sur lequel k3s est installe." + value = module.k3s.node_host +} diff --git a/infra/terraform/environments/dev/terraform.tfvars.example b/infra/terraform/environments/dev/terraform.tfvars.example new file mode 100644 index 0000000..ba86a56 --- /dev/null +++ b/infra/terraform/environments/dev/terraform.tfvars.example @@ -0,0 +1,7 @@ +ssh_host = "10.0.0.10" +ssh_port = 22 +ssh_user = "root" +ssh_private_key_path = "~/.ssh/id_ed25519_enervision" +k3s_version = "" +k3s_disable_components = ["traefik"] +kubeconfig_output_path = "./kubeconfig" diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf new file mode 100644 index 0000000..b5f90db --- /dev/null +++ b/infra/terraform/environments/dev/variables.tf @@ -0,0 +1,40 @@ +variable "ssh_host" { + type = string + description = "Adresse IP ou nom d'hote du serveur on-premise de l'ecole." +} + +variable "ssh_port" { + type = number + description = "Port SSH du serveur." + default = 22 +} + +variable "ssh_user" { + type = string + description = "Utilisateur SSH utilise pour l'installation." + default = "root" +} + +variable "ssh_private_key_path" { + type = string + description = "Chemin local vers la cle privee SSH." + sensitive = true +} + +variable "k3s_version" { + type = string + description = "Version k3s a installer. Chaine vide = derniere version stable." + default = "" +} + +variable "k3s_disable_components" { + type = list(string) + description = "Composants embarques k3s a desactiver." + default = ["traefik"] +} + +variable "kubeconfig_output_path" { + type = string + description = "Chemin local ou ecrire le kubeconfig recupere apres installation." + default = "./kubeconfig" +} diff --git a/infra/terraform/environments/dev/versions.tf b/infra/terraform/environments/dev/versions.tf new file mode 100644 index 0000000..d793df3 --- /dev/null +++ b/infra/terraform/environments/dev/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/modules/.gitkeep b/infra/terraform/modules/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/infra/terraform/modules/k3s/main.tf b/infra/terraform/modules/k3s/main.tf new file mode 100644 index 0000000..24be659 --- /dev/null +++ b/infra/terraform/modules/k3s/main.tf @@ -0,0 +1,45 @@ +locals { + sudo_prefix = var.ssh_user == "root" ? "" : "sudo " + install_env = var.k3s_version != "" ? "INSTALL_K3S_VERSION=${var.k3s_version} " : "" + disable_flags = join(" ", [for c in var.k3s_disable_components : "--disable=${c}"]) + kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml" +} + +resource "null_resource" "k3s_install" { + triggers = { + ssh_host = var.ssh_host + k3s_version = var.k3s_version + disable_components = join(",", var.k3s_disable_components) + } + + connection { + type = "ssh" + host = var.ssh_host + port = var.ssh_port + user = var.ssh_user + private_key = file(var.ssh_private_key_path) + } + + provisioner "remote-exec" { + inline = [ + "${local.sudo_prefix}sh -c 'curl -sfL https://get.k3s.io | ${local.install_env}sh -s - server --write-kubeconfig-mode 644 ${local.disable_flags}'", + "until ${local.sudo_prefix}test -f /etc/rancher/k3s/k3s.yaml; do sleep 2; done", + ] + } +} + +resource "null_resource" "fetch_kubeconfig" { + depends_on = [null_resource.k3s_install] + + triggers = { + install_id = null_resource.k3s_install.id + } + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = <<-EOT + ssh -i "${var.ssh_private_key_path}" -p ${var.ssh_port} -o StrictHostKeyChecking=accept-new ${var.ssh_user}@${var.ssh_host} '${local.kubeconfig_cmd}' \ + | sed 's/127.0.0.1/${var.ssh_host}/' > "${var.kubeconfig_output_path}" + EOT + } +} diff --git a/infra/terraform/modules/k3s/outputs.tf b/infra/terraform/modules/k3s/outputs.tf new file mode 100644 index 0000000..6b92aad --- /dev/null +++ b/infra/terraform/modules/k3s/outputs.tf @@ -0,0 +1,9 @@ +output "kubeconfig_path" { + description = "Chemin local du kubeconfig recupere apres installation." + value = var.kubeconfig_output_path +} + +output "node_host" { + description = "Adresse de la machine sur laquelle k3s est installe." + value = var.ssh_host +} diff --git a/infra/terraform/modules/k3s/variables.tf b/infra/terraform/modules/k3s/variables.tf new file mode 100644 index 0000000..7f2f91d --- /dev/null +++ b/infra/terraform/modules/k3s/variables.tf @@ -0,0 +1,39 @@ +variable "ssh_host" { + type = string + description = "Adresse IP ou nom d'hote de la machine on-premise cible." +} + +variable "ssh_port" { + type = number + description = "Port SSH de la machine cible." + default = 22 +} + +variable "ssh_user" { + type = string + description = "Utilisateur SSH. Si different de root, les commandes d'installation sont prefixees par sudo." + default = "root" +} + +variable "ssh_private_key_path" { + type = string + description = "Chemin local vers la cle privee SSH utilisee pour se connecter a la machine cible." + sensitive = true +} + +variable "k3s_version" { + type = string + description = "Version k3s a installer (ex: v1.31.2+k3s1). Chaine vide = derniere version stable." + default = "" +} + +variable "k3s_disable_components" { + type = list(string) + description = "Composants embarques a desactiver a l'installation (ex: traefik, servicelb)." + default = ["traefik"] +} + +variable "kubeconfig_output_path" { + type = string + description = "Chemin local ou ecrire le kubeconfig recupere apres installation." +} diff --git a/infra/terraform/modules/k3s/versions.tf b/infra/terraform/modules/k3s/versions.tf new file mode 100644 index 0000000..90c2aa0 --- /dev/null +++ b/infra/terraform/modules/k3s/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + null = { + source = "hashicorp/null" + version = "~> 3.2" + } + } +} diff --git a/terraform.tf b/terraform.tf deleted file mode 100644 index 0994a4d..0000000 --- a/terraform.tf +++ /dev/null @@ -1,19 +0,0 @@ -# Provider Docker -terraform { - required_providers { - docker = { - source = "kreuzwerker/docker" - version = "~> 3.0" - } - } -} - -provider "docker" { - host = "unix:///var/run/docker.sock" -} - -# Image Docker -resource "docker_image" "python" { - name = "python:3.14.7" - keep_locally = true -} diff --git a/test.txt b/test.txt deleted file mode 100644 index 28d0af9..0000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -coucou From 239efc8ee63bd89cf7a9a84dfdc562643ccc3474 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Tue, 15 Sep 2026 10:32:22 +0200 Subject: [PATCH 021/205] docs/update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b6ab9c8..22b01ab 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ series temporelles energetiques, deployee sur une machine on-premise. | Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser | | Base | PostgreSQL + TimescaleDB | `db` | A initialiser | | ETL | Apache Airflow | `etl/airflow` | A initialiser | -| Infra | Terraform | `infra/terraform` | A initialiser | +| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | CI/CD | GitHub Actions | `.github/workflows` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | -Seul le backend est initialise a ce stade. Les autres dossiers portent l'arborescence et -un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +Le backend et l'infrastructure (Terraform/k3s) sont initialises. Les autres dossiers +portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. ## Arborescence From b910e747eced50832d1bcf42245a2a95a18c4b81 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Tue, 15 Sep 2026 11:29:34 +0200 Subject: [PATCH 022/205] fix: terraform & module --- .gitignore | 2 +- .../environments/dev/terraform.tfvars.example | 3 ++- infra/terraform/environments/dev/variables.tf | 3 +-- infra/terraform/modules/k3s/main.tf | 14 ++++++++++++-- infra/terraform/modules/k3s/variables.tf | 8 ++++++-- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index a226853..777b2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ yarn-error.log* # Terraform .terraform/ -.terraform.lock.hcl +# .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI *.tfstate *.tfstate.* *.tfplan diff --git a/infra/terraform/environments/dev/terraform.tfvars.example b/infra/terraform/environments/dev/terraform.tfvars.example index ba86a56..323145a 100644 --- a/infra/terraform/environments/dev/terraform.tfvars.example +++ b/infra/terraform/environments/dev/terraform.tfvars.example @@ -2,6 +2,7 @@ ssh_host = "10.0.0.10" ssh_port = 22 ssh_user = "root" ssh_private_key_path = "~/.ssh/id_ed25519_enervision" -k3s_version = "" +# Epingler une version reelle avant apply : https://github.com/k3s-io/k3s/releases +k3s_version = "v1.31.5+k3s1" k3s_disable_components = ["traefik"] kubeconfig_output_path = "./kubeconfig" diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf index b5f90db..9d507e3 100644 --- a/infra/terraform/environments/dev/variables.tf +++ b/infra/terraform/environments/dev/variables.tf @@ -23,8 +23,7 @@ variable "ssh_private_key_path" { variable "k3s_version" { type = string - description = "Version k3s a installer. Chaine vide = derniere version stable." - default = "" + description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases." } variable "k3s_disable_components" { diff --git a/infra/terraform/modules/k3s/main.tf b/infra/terraform/modules/k3s/main.tf index 24be659..a8c23de 100644 --- a/infra/terraform/modules/k3s/main.tf +++ b/infra/terraform/modules/k3s/main.tf @@ -1,6 +1,6 @@ locals { sudo_prefix = var.ssh_user == "root" ? "" : "sudo " - install_env = var.k3s_version != "" ? "INSTALL_K3S_VERSION=${var.k3s_version} " : "" + install_env = "INSTALL_K3S_VERSION=${var.k3s_version} " disable_flags = join(" ", [for c in var.k3s_disable_components : "--disable=${c}"]) kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml" } @@ -22,10 +22,20 @@ resource "null_resource" "k3s_install" { provisioner "remote-exec" { inline = [ - "${local.sudo_prefix}sh -c 'curl -sfL https://get.k3s.io | ${local.install_env}sh -s - server --write-kubeconfig-mode 644 ${local.disable_flags}'", + "${local.sudo_prefix}sh -c 'curl -sfL https://get.k3s.io | ${local.install_env}sh -s - server ${local.disable_flags}'", "until ${local.sudo_prefix}test -f /etc/rancher/k3s/k3s.yaml; do sleep 2; done", ] } + + # Le kubeconfig est lu via sudo (fetch_kubeconfig), pas besoin de --write-kubeconfig-mode : + # il reste 600/root par defaut, ce qui evite d'exposer les droits cluster-admin a tout utilisateur local. + provisioner "remote-exec" { + when = destroy + on_failure = continue + inline = [ + "${local.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'", + ] + } } resource "null_resource" "fetch_kubeconfig" { diff --git a/infra/terraform/modules/k3s/variables.tf b/infra/terraform/modules/k3s/variables.tf index 7f2f91d..08c0e1c 100644 --- a/infra/terraform/modules/k3s/variables.tf +++ b/infra/terraform/modules/k3s/variables.tf @@ -23,8 +23,12 @@ variable "ssh_private_key_path" { variable "k3s_version" { type = string - description = "Version k3s a installer (ex: v1.31.2+k3s1). Chaine vide = derniere version stable." - default = "" + description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases." + + validation { + condition = length(trimspace(var.k3s_version)) > 0 + error_message = "k3s_version doit etre epinglee explicitement, pas de valeur vide (sinon k3s.io installerait la derniere version a chaque run, non reproductible)." + } } variable "k3s_disable_components" { From 4c72fbbb69d8362e63e6e413d87d0a9a5736f292 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 11:56:18 +0200 Subject: [PATCH 023/205] docs: fonde les vues d'architecture du monorepo Cinq vues Mermaid dans docs/architecture (vue d'ensemble, infra, backend, frontend, donnees), plus leur index, les conventions de statut et la regle de maintenance en PR. Reprend les jalons J1-J4, disparus de dev lors de la reecriture du README (2670483) et restes seulement sur main : plus rien sur la branche de travail ne disait ce que le projet doit prouver. Fige les decisions du module Terraform k3s, qui ne vivaient jusqu'ici que dans des commentaires de code et des description de variables : version epinglee obligatoire, Traefik desactive, kubeconfig en 600/root, state local. Corrige trois affirmations devenues fausses : le frontend classe "a initialiser" alors que le squelette existe depuis 49f4697, le port 4200 dit attendu par docker-compose.yml qui n'a aucun service frontend, et l'arborescence core/ prescrite par TESTING.md sans exister. --- README.md | 20 +++- apps/frontend/README.md | 3 +- apps/frontend/TESTING.md | 4 + docs/README.md | 2 +- docs/architecture/.gitkeep | 0 docs/architecture/00-vue-ensemble.md | 136 ++++++++++++++++++++++ docs/architecture/10-infra.md | 132 ++++++++++++++++++++++ docs/architecture/20-backend.md | 163 +++++++++++++++++++++++++++ docs/architecture/30-frontend.md | 114 +++++++++++++++++++ docs/architecture/40-data.md | 143 +++++++++++++++++++++++ docs/architecture/README.md | 58 ++++++++++ 11 files changed, 771 insertions(+), 4 deletions(-) delete mode 100644 docs/architecture/.gitkeep create mode 100644 docs/architecture/00-vue-ensemble.md create mode 100644 docs/architecture/10-infra.md create mode 100644 docs/architecture/20-backend.md create mode 100644 docs/architecture/30-frontend.md create mode 100644 docs/architecture/40-data.md create mode 100644 docs/architecture/README.md diff --git a/README.md b/README.md index 3302c64..20181cc 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,36 @@ Monorepo de la plateforme EnerVision : collecte, stockage, analyse et restitution de series temporelles energetiques, deployee sur une machine on-premise. +## Jalons + +| Jalon | Intitulé | +|-------|----------------------------------------------------------| +| 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 | + +Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md). + ## Stack cible | Domaine | Technologie | Emplacement | Etat | |------------|-------------------------------------|---------------------|---------------| | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | -| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser | +| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | CI/CD | GitHub Actions | `.github/workflows` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | -Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Les autres dossiers +Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend +porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +L'etat detaille de chaque brique et les vues d'architecture sont dans +[docs/architecture](docs/architecture/README.md). + ## Arborescence ``` @@ -82,3 +97,4 @@ curl -s localhost:8000/api/v1/health/ready - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Commits : Conventional Commits, portee = dossier de premier niveau concerne. - Toute decision structurante donne lieu a un ADR dans `docs/adr`. +- Toute PR qui change un composant met a jour sa vue dans `docs/architecture`, dans la meme PR. diff --git a/apps/frontend/README.md b/apps/frontend/README.md index ce9c73c..aeaf788 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -72,7 +72,8 @@ Points à vérifier après toute regénération : 1. Pointer l'API dans `src/environments/` sur `http://localhost:8000/api/v1`. 2. Ajouter le proxy de développement (`proxy.conf.json`) vers le backend. -3. Vérifier que `npm start` sert bien sur le port 4200 attendu par `docker-compose.yml`. +3. Vérifier que `npm start` sert bien sur le port 4200, valeur par défaut d'`APP_CORS_ORIGINS` + côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). ## Additional Resources diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index e23ed7a..d4e92bf 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -27,6 +27,10 @@ it('devrait faire X quand Y', () => { - Composants avec logique (formulaires, conditions d'affichage) — pas nécessaire pour un composant 100% template, sans logique +`core/services/`, `core/guards/` et `core/interceptors/` n'existent pas encore : c'est +l'arborescence cible, décrite dans +[docs/architecture/30-frontend.md](../../docs/architecture/30-frontend.md). + ## Gabarit — tester un service avec appel HTTP ```typescript import { TestBed } from '@angular/core/testing'; diff --git a/docs/README.md b/docs/README.md index b4ad74d..938a78a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ # Documentation - `adr` : decisions d'architecture, une par fichier, numerotees et immuables. -- `architecture` : schemas et vues d'ensemble. +- `architecture` : les vues du systeme. Point d'entree : [architecture/README.md](architecture/README.md). diff --git a/docs/architecture/.gitkeep b/docs/architecture/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md new file mode 100644 index 0000000..8da3f0f --- /dev/null +++ b/docs/architecture/00-vue-ensemble.md @@ -0,0 +1,136 @@ +# Vue d'ensemble + +EnerVision collecte, stocke, analyse et restitue des séries temporelles énergétiques, sur une +machine on-premise. + +## Cadre du projet + +Quatre jalons ont été posés à l'ouverture du projet. Ils ont disparu du `README.md` lors de la +réécriture de l'arborescence (`2670483`) et ne subsistaient que sur `main`. Ils sont repris ici +parce qu'ils disent ce que le projet doit prouver, et donc à quoi sert chaque décision technique. + +| Jalon | Intitulé | Ce que la documentation apporte | +|---|---|---| +| J1 | Valider la préparation de l'environnement et du repo | `10-infra.md` décrit la stack du poste de développement et la commande qui la démarre | +| J2 | Valider le périmètre retenu et les choix technologiques | Les ADR (`../adr/`) portent les choix ; `40-data.md` liste les questions de périmètre encore ouvertes | +| J3 | Valider l'architecture et la gestion de la sécurité | Les cinq vues, et la section « Sécurité » ci-dessous qui consolide les surfaces exposées | +| J4 | Valider la robustesse et assurer les livrables | `20-backend.md` et `30-frontend.md` renvoient aux conventions de tests de chaque application | + +## Contexte + +Statut : `Cible`. Les acteurs et les sources de mesures ne sont pas arrêtés, c'est l'objet du +jalon J2. + +```mermaid +flowchart LR + exploitant["Exploitant
consulte les courbes"] + admin["Administrateur
exploite la plateforme"] + sources["Sources de mesures
à définir en J2"] + + subgraph systeme["EnerVision"] + plateforme["Collecte, stockage,
analyse et restitution
de séries temporelles"] + end + + sources -.-> plateforme + exploitant -.-> plateforme + admin -.-> plateforme +``` + +## Conteneurs + +Trait plein pour ce qui tourne, pointillé pour ce qui est cible. + +```mermaid +flowchart TB + navigateur["Navigateur"] + + subgraph machine["Machine on-premise"] + front["Frontend Angular 22
apps/frontend"] + api["API FastAPI
apps/backend"] + db[("PostgreSQL 17
TimescaleDB")] + airflow["Airflow
etl/airflow"] + prom["Prometheus"] + grafana["Grafana"] + end + + navigateur --> front + front -.-> api + api --> db + airflow -.-> db + prom -.-> api + grafana -.-> db + grafana -.-> prom +``` + +Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune +API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md). + +Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun +collecteur ne vient le lire. + +## État de la stack + +| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | +|---|---|---|---|---| +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP | +| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | +| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | +| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | +| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | +| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien | + +## Flux bout en bout + +Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base. + +```mermaid +sequenceDiagram + participant S as Source de mesures + participant A as Airflow + participant T as TimescaleDB + participant API as FastAPI + participant U as Angular + + S->>A: mesures horodatées + A->>T: insertion dans l'hypertable + T->>T: rafraîchissement de l'agrégat continu + U->>API: GET /api/v1/... + API->>T: agrégation sur la fenêtre demandée + T-->>API: lignes + API-->>U: JSON +``` + +## Sécurité + +Section rattachée au jalon J3. Le détail par brique est dans chaque document ; voici la vue +consolidée. + +### En place + +- **Les secrets n'ont pas de valeur par défaut.** `APP_SECRET_KEY` et `DATABASE_URL` sont requis + sans repli : l'application refuse de démarrer si l'un manque, plutôt que de tourner avec une + valeur de démonstration. `.env` reste hors dépôt, `.env.example` est versionné. +- **CORS conditionnel** : le middleware n'est ajouté que si `APP_CORS_ORIGINS` est renseigné. +- **Documentation interactive fermée en production** : `/docs`, `/redoc` et `/openapi.json` sont + désactivés dès que `APP_ENV=prod`. +- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. +- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root` + sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les + `.example`. + +### Absent + +- **Aucune authentification ni autorisation.** Les deux endpoints exposés sont publics. Rien + n'est encore décidé sur ce point. +- Pas de TLS, pas de limitation de débit, pas de journalisation des accès, pas de rotation des + secrets. +- Aucune analyse de dépendances ni de conteneur, faute de CI. + +## Décisions structurantes + +Elles vivent dans `../adr/`, pas ici. + +| ADR | Objet | +|---|---| +| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` | diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md new file mode 100644 index 0000000..4e82445 --- /dev/null +++ b/docs/architecture/10-infra.md @@ -0,0 +1,132 @@ +# Infrastructure + +Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans +quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux. + +| Topologie | Sert à | Statut | +|---|---|---| +| Docker Compose | Développer et recetter sur le poste | `Fait` | +| k3s single-node | Déployer sur le serveur on-premise | `En cours` | + +## Poste de développement + +Statut : `Fait`. Défini par `docker-compose.yml`, projet `enervision`. + +```mermaid +flowchart TB + subgraph poste["Poste de développement"] + ng["ng serve
:4200"] + api["uvicorn --reload
:8000"] + end + + subgraph compose["docker compose"] + back["service backend
image construite depuis apps/backend"] + db[("service db
timescale/timescaledb-ha:pg17")] + end + + ng -.->|"proxy /api"| api + api -->|"hôte :5433 vers conteneur :5432"| db + back -->|"réseau interne, db:5432"| db +``` + +| Service | Image | Points notables | +|---|---|---| +| `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s | +| `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | + +**La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` : +seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le +service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se +lancent donc pas ensemble. + +Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : + +- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de + l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message. +- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de + l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans + le compose. Voir [`db/README.md`](../../db/README.md). + +## Cible de déploiement + +Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais +été appliqué. + +```mermaid +flowchart LR + poste["Poste
terraform apply"] + kube["kubeconfig local"] + + subgraph serveur["Serveur on-premise"] + k3s["k3s server single-node
Traefik désactivé"] + charges["Charges de travail
aucune déclarée"] + end + + poste -->|"SSH, get.k3s.io"| k3s + k3s -->|"cat /etc/rancher/k3s/k3s.yaml"| kube + k3s -.-> charges +``` + +### Ce que le Terraform fait + +```mermaid +sequenceDiagram + participant TF as terraform apply + participant SRV as Serveur on-premise + participant L as Poste local + + TF->>SRV: SSH, curl get.k3s.io puis install server + TF->>SRV: attend /etc/rancher/k3s/k3s.yaml + TF->>SRV: ssh cat k3s.yaml + SRV-->>L: kubeconfig, 127.0.0.1 réécrit en ssh_host +``` + +### Ce que le Terraform ne fait pas + +Il déclare le provider `null` et **lui seul** : ni `kubernetes`, ni `helm`. Aucun namespace, +aucun déploiement, aucun service, aucun ingress. À l'issue d'un `apply`, on dispose d'un cluster +vide et d'un kubeconfig, rien de plus. + +## Décisions figées + +Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de code et des +`description` de variables, c'est-à-dire qu'ils ne survivaient pas au premier remaniement. + +| Décision | Raison | Où elle est appliquée | +|---|---|---| +| k3s single-node plutôt que Kubernetes complet | Une seule machine on-premise, pas de plan de contrôle à répartir | `modules/k3s/main.tf` | +| `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` | +| `.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/` | + +## Ports et noms + +| Quoi | Valeur | Remarque | +|---|---|---| +| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris | +| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` | +| API | `8000` | Identique en conteneur et hors conteneur | +| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend | +| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable | +| Base applicative | `enervision` | Variable `POSTGRES_DB` | +| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` | + +## Le trou entre les deux topologies + +Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose +construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première +question à trancher, avant toute ressource Kubernetes. + +## Questions ouvertes + +- **Quel ingress** remplace Traefik, et qui termine le TLS. +- **Quel registre d'images**, et comment il est alimenté sans CI. +- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le + cluster ou à côté. +- **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 new file mode 100644 index 0000000..d975da6 --- /dev/null +++ b/docs/architecture/20-backend.md @@ -0,0 +1,163 @@ +# Backend + +API FastAPI, Python 3.14, SQLAlchemy asynchrone sur `asyncpg`. Source dans `apps/backend`. + +## Couches + +La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README.md) et +[`TESTING.md`](../../apps/backend/TESTING.md) : `endpoints` appelle `services`, qui appelle +`repositories`, qui seuls touchent les `models`. Le sens de dépendance ne s'inverse jamais. + +Dans les faits, trois de ces couches sont des dossiers vides. + +```mermaid +flowchart TB + ep["endpoints
2 routes"] + sc["schemas
2 modèles Pydantic"] + sv["services
vide"] + rp["repositories
vide"] + md["models
vide"] + db[("PostgreSQL")] + + ep --> sc + ep -.-> sv + sv -.-> rp + rp -.-> md + ep -->|"SQL brut, état actuel"| db + rp -.-> db +``` + +Le trait plein de `endpoints` vers la base n'est pas une erreur de dessin : `/health/ready` +exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable pour une sonde +d'infrastructure, qui vérifie la base elle-même et non une donnée métier. Ce raccourci ne doit +pas servir de modèle au premier endpoint métier. + +`app/models/__init__.py` ne contient qu'un avertissement, qui mérite d'être connu avant la +première migration : tout modèle absent de ce module reste invisible d'un +`alembic revision --autogenerate`, qui produirait alors un `drop` de sa table. + +## Démarrage + +Point d'entrée : **une factory**, `uvicorn app.main:create_app --factory`. Aucune configuration +n'est lue à l'import du module, ce qui rend l'application testable et les migrations +indépendantes de l'environnement d'exécution. + +```mermaid +sequenceDiagram + participant U as uvicorn --factory + participant F as create_app + participant S as get_settings + participant A as FastAPI + + U->>F: create_app() + F->>S: Settings depuis .env et variables APP_* + S-->>F: resolved + F->>F: configure_logging(resolved) + F->>A: FastAPI, docs fermés si prod + F->>A: CORSMiddleware, seulement si allowed_origins + F->>A: Instrumentator, expose /metrics + F->>A: include_router, préfixe /api/v1 + A-->>U: application +``` + +**Le `lifespan` n'ouvre aucune connexion.** Au démarrage il journalise le nom, la version et +l'environnement ; à l'arrêt il libère l'engine. L'engine lui-même est construit paresseusement au +premier appel de `get_engine()`, mis en cache par `lru_cache`. Conséquence directe : une API qui +démarre ne prouve rien sur la base, la première connexion réelle a lieu au premier +`GET /api/v1/health/ready`. C'est ce qui rend cette sonde indispensable. + +## Configuration + +`Settings` est un `BaseSettings` Pydantic, lu depuis `.env` avec le préfixe `APP_`. + +| Variable | Défaut | Rôle | +|---|---|---| +| `APP_SECRET_KEY` | **aucun** | Secret applicatif, `SecretStr` | +| `DATABASE_URL` | **aucun** | Chaîne de connexion, `postgresql+asyncpg://...` | +| `APP_ENV` | `local` | `local`, `dev`, `staging` ou `prod` | +| `APP_DEBUG` | `false` | Active aussi l'écho SQL de l'engine | +| `APP_LOG_LEVEL` | `INFO` | | +| `APP_CORS_ORIGINS` | `""` | Liste séparée par des virgules. Vide, aucun middleware CORS n'est posé | +| `APP_API_PREFIX` | `/api/v1` | | +| `APP_DATABASE_POOL_SIZE` | `5` | | +| `APP_DATABASE_MAX_OVERFLOW` | `10` | | + +Deux pièges : + +- **`DATABASE_URL` ne prend pas le préfixe `APP_`.** C'est le seul réglage dans ce cas, par + `validation_alias`, pour rester compatible avec la convention d'Alembic et des hébergeurs. +- **`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par défaut.** L'application refuse de + démarrer si l'un manque. C'est délibéré : mieux vaut un échec au démarrage qu'un service qui + tourne avec un secret de démonstration. + +Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docker-compose.yml`, +`apps/backend/.env` alimente l'API lancée sur le poste. + +## Routes exposées + +| Méthode | Chemin | Dans l'OpenAPI | Rôle | +|---|---|---|---| +| GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base | +| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée | +| GET | `/metrics` | non | Format Prometheus, exposé par l'instrumentator | +| GET | `/docs`, `/redoc`, `/openapi.json` | non | Désactivés quand `APP_ENV=prod` | + +Aucune route métier n'existe à ce jour. + +### `/health/ready` + +Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un +bootstrap de base sauté ne se voit pas au démarrage de l'API, elle le rend visible. + +```mermaid +sequenceDiagram + participant C as Client + participant R as readiness + participant E as get_engine + participant D as PostgreSQL + + C->>R: GET /api/v1/health/ready + R->>E: session, engine créé au premier appel + R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb' + alt base injoignable + D--xR: SQLAlchemyError ou OSError + R-->>C: 503 Base de donnees injoignable + else extension absente + D-->>R: NULL + R-->>C: 503 Extension TimescaleDB absente + else + D-->>R: version de l'extension + R-->>C: 200 status ready + end +``` + +## Sécurité + +Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté backend : + +- **Aucune authentification, aucune autorisation.** Les deux routes sont publiques. Le premier + endpoint métier imposera de trancher ce point. +- Le CORS n'autorise que les origines listées, et n'existe pas si la liste est vide. +- `/docs`, `/redoc` et `/openapi.json` disparaissent en production. +- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. +- Ni limitation de débit, ni journalisation des accès, ni en-têtes de sécurité. + +## Observabilité + +- Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`. + `sqlalchemy.engine` est forcé à `WARNING` pour ne pas noyer les journaux. +- `/metrics` au format Prometheus. **Aucun collecteur ne le lit** : `monitoring/` est vide. + +## Tests + +Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). +Deux points structurants y sont fixés : les doubles passent par `app.dependency_overrides` et +jamais par `unittest.mock`, et les tests qui touchent la vraie base portent le marqueur +`integration`, exclu par défaut. + +## Questions ouvertes + +- **Authentification et autorisation** : quel mécanisme, quelle granularité. +- **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des + endpoints métier. +- **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`. diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md new file mode 100644 index 0000000..98da40a --- /dev/null +++ b/docs/architecture/30-frontend.md @@ -0,0 +1,114 @@ +# Frontend + +Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps/frontend`. + +## État actuel + +Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la +[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le +disait encore « à initialiser » alors que le squelette existe depuis `49f4697`. + +Ce qui est en place : + +- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. +- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`. +- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test. +- Prettier configuré, parser `angular` pour les gabarits HTML. + +Ce qui n'existe pas encore : + +- `routes` est un tableau vide. Aucune page, aucune navigation. +- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part : + l'application n'appelle aucune API. +- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris. +- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état. +- Aucun lint : ESLint n'est pas installé. + +## Arborescence cible + +Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la +prescrit déjà dans ses gabarits de tests. + +```mermaid +flowchart TB + subgraph src["src/app"] + core["core/
services, guards, interceptors"] + features["features/
un dossier par domaine"] + shared["shared/
composants réutilisables"] + end + + features -.-> core + features -.-> shared + core -.-> env["environments/
apiUrl"] +``` + +Un service HTTP par domaine dans `core/services`, les composants de page dans `features`, et rien +d'autre que du réutilisable dans `shared`. Les composants n'appellent jamais `HttpClient` +directement : ils passent par un service, ce qui rend le double de test trivial. + +## Flux HTTP + +Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore. + +```mermaid +sequenceDiagram + participant C as Composant + participant S as Service Angular + participant P as ng serve, proxy + participant A as FastAPI + + C->>S: appel de méthode + S->>P: GET /api/v1/... + P->>A: http://localhost:8000/api/v1/... + A-->>S: JSON + S-->>C: modèle typé +``` + +En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce +qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un +`apiUrl` relatif, `/api/v1`. + +En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue +le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. + +**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, +pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : +telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier +déploiement, en même temps que sera tranchée la question de l'ingress dans +[10-infra.md](10-infra.md). + +## Exécution + +| Commande | Effet | +|---|---| +| `npm ci` | Installe les dépendances. `node_modules/` n'est pas présent par défaut | +| `npm start` | `ng serve` sur le port 4200, proxy actif | +| `npm run build` | Build de production | +| `npm run test` | Vitest en mode observateur | +| `npm run test:ci` | Vitest en une passe | + +Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans +`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 +n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. + +Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans +`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage +avec un service statique, il reste à écrire. + +## Sécurité + +- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. +- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce + stade. `core/guards` et `core/interceptors` sont prévus pour cela. + +## Tests + +Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md). + +## Questions ouvertes + +- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà + une partie du besoin. +- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée. +- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue + au démarrage. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md new file mode 100644 index 0000000..2d53844 --- /dev/null +++ b/docs/architecture/40-data.md @@ -0,0 +1,143 @@ +# Données + +PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses conséquences sont +dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le +système qui en découle. + +## Avertissement + +**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne +contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable +n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un +relevé du code. Le modèle sera arrêté au jalon J2. + +## Trois emplacements, trois rôles + +C'est la règle que l'ADR 0001 existe surtout pour fixer. La confondre coûte cher : un script placé +au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois. + +| Emplacement | Contenu | Quand ça s'exécute | +|---|---|---| +| `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais | +| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : rétention, compression | À la main, aujourd'hui vide | +| `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi | + +Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()` +vit dans la même révision**. Les séparer rendrait le schéma irreproductible depuis un seul +`alembic upgrade head`. + +Détail de `db/init/` et du piège de montage : [`db/README.md`](../../db/README.md). + +## Ce qui existe + +Statut : `Fait`. + +- `db/init/100-extensions.sql` crée l'extension `timescaledb`. +- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par + `apps/backend/tests/conftest.py`. +- Une révision Alembic, `5353c0e4f094`, qui **ne crée aucune table**. Elle établit + `alembic_version` et refuse de s'appliquer si l'extension manque : + +```sql +IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN + RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md'; +END IF; +``` + +Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas +au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. + +## Cycle de vie d'une mesure + +Statut : `Cible`. Aucun de ces maillons n'existe. + +```mermaid +flowchart LR + src["Source de mesures"] -.-> ing["Ingestion Airflow"] + ing -.-> hy[("Hypertable mesure")] + hy -.-> agg[("Agrégat continu")] + hy -.-> comp["Compression"] + hy -.-> ret["Rétention"] + agg -.-> api["API FastAPI"] + agg -.-> graf["Grafana"] +``` + +Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout +l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. + +## Modèle + +Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles +s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui +évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un +`total_kwh(site_id)`. + +```mermaid +erDiagram + SITE ||--o{ POINT_DE_MESURE : porte + POINT_DE_MESURE ||--o{ MESURE : produit + + SITE { + int id PK + string nom + } + POINT_DE_MESURE { + int id PK + int site_id FK + string libelle + string unite + } + MESURE { + timestamptz horodatage PK + int point_id PK + double valeur + } +``` + +`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé +primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant +de point serait refusée. + +## Gabarit de révision créant une hypertable + +Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. + +```python +def upgrade() -> None: + op.create_table( + "mesure", + sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False), + sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False), + sa.Column("valeur", sa.Float(), nullable=False), + sa.PrimaryKeyConstraint("horodatage", "point_id"), + ) + op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))") + + +def downgrade() -> None: + op.drop_table("mesure") +``` + +`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions. + +## Conventions + +- **Noms au singulier**, en minuscules, sans préfixe de table. +- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau + devient ininterprétable dès le premier changement d'heure. +- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire. +- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic : + elles ne découlent pas du schéma applicatif. +- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi + `alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table. + +## Questions ouvertes + +Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif. + +- **Quelles sources de mesures**, et selon quel protocole elles sont collectées. +- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure. +- **Quels agrégats continus**, et sur quelles fenêtres. +- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. +- **Quelles unités** sont manipulées, et si une même table les mélange. +- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..a23be40 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,58 @@ +# Architecture + +Les vues d'architecture d'EnerVision. Un ADR (`../adr/`) **décide** et date une décision +structurante ; une vue d'architecture **décrit** le système qui en résulte. Quand les deux se +contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. + +## Les documents + +| Document | Ce qu'il couvre | +|---|---| +| [00-vue-ensemble.md](00-vue-ensemble.md) | Jalons du projet, contexte, conteneurs, sécurité, flux bout en bout | +| [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms | +| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration | +| [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | +| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | + +L'observabilité, la sécurité et la CI/CD n'ont pas de document propre : ce sont des sections des +cinq ci-dessus, tant que `monitoring/`, `.github/workflows/` et `etl/airflow/` ne contiennent que +des `.gitkeep`. Elles en sortiront le jour où elles auront de la matière. Un fichier vide de plus +n'aide personne. + +## Conventions + +### Mermaid, et rien d'autre + +GitHub rend Mermaid nativement dans les fichiers `.md`. Un diagramme est donc du texte : il se +relit en revue, il se diffe, et il ne se périme pas dans un binaire que plus personne ne sait +rouvrir six mois plus tard. Aucune image exportée, aucun `.drawio`, aucun `.png`. + +### Chaque section porte son statut + +Une large part de la stack n'est pas écrite. Une vue qui mélange l'existant et la cible sans le +dire devient fausse sans prévenir. + +| Statut | Sens | +|---|---| +| `Fait` | Le code existe et tourne | +| `En cours` | Commencé, incomplet | +| `Cible` | Décidé, pas encore écrit | + +### Légende des diagrammes + +Trait plein pour ce qui tourne, trait pointillé pour ce qui est cible. + +```mermaid +flowchart LR + A[Composant en place] --> B[Composant en place] + B -.-> C[Composant cible] +``` + +## Maintenance + +**Toute PR qui change un composant met à jour sa vue dans la même PR.** Une vue qu'on promet de +mettre à jour plus tard ne l'est jamais. + +Une documentation fausse coûte plus cher qu'une documentation absente : on la lit, on la croit, et +on construit dessus. Si une section ne peut plus être tenue à jour, elle est supprimée plutôt que +laissée à dériver. From e47235bd7ff6e58b093984fcf7a2e7abcc0c6751 Mon Sep 17 00:00:00 2001 From: Phyrios <107575703+phyri0s@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:33:15 +0200 Subject: [PATCH 024/205] =?UTF-8?q?Mise=20=C3=A0=20jour=20des=20jalons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/00-vue-ensemble.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 8da3f0f..0794bfb 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -13,8 +13,9 @@ parce qu'ils disent ce que le projet doit prouver, et donc à quoi sert chaque d |---|---|---| | J1 | Valider la préparation de l'environnement et du repo | `10-infra.md` décrit la stack du poste de développement et la commande qui la démarre | | J2 | Valider le périmètre retenu et les choix technologiques | Les ADR (`../adr/`) portent les choix ; `40-data.md` liste les questions de périmètre encore ouvertes | -| J3 | Valider l'architecture et la gestion de la sécurité | Les cinq vues, et la section « Sécurité » ci-dessous qui consolide les surfaces exposées | -| J4 | Valider la robustesse et assurer les livrables | `20-backend.md` et `30-frontend.md` renvoient aux conventions de tests de chaque application | +| J3 | Ingestion & backend | `20-backend.md` | +| J4 | Architecture, sécurité & frontend | Les cinq vues, et la section « Sécurité » ci-dessous qui consolide les surfaces exposées | +| J5 | Valider la robustesse et assurer les livrables | `20-backend.md` et `30-frontend.md` renvoient aux conventions de tests de chaque application | ## Contexte From e4d1b43a44d88ba5bed1fb9e930e4ca5854aad7e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:21:46 +0200 Subject: [PATCH 025/205] =?UTF-8?q?style(backend):=20r=C3=A9tablit=20les?= =?UTF-8?q?=20accents=20dans=20les=20messages=20et=20commentaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le français du dépôt s'écrit accentué. Harmonise les commentaires d'en-tête, les docstrings, le message de démarrage et les deux détails d'erreur de la sonde de disponibilité, avec leurs assertions. --- apps/backend/app/api/v1/endpoints/health.py | 4 ++-- apps/backend/app/db/base.py | 2 +- apps/backend/app/main.py | 2 +- apps/backend/app/models/__init__.py | 4 ++-- apps/backend/tests/api/test_health.py | 2 +- apps/backend/tests/conftest.py | 10 +++++----- apps/backend/tests/factories.py | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index be3abf8..f97caaf 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -27,10 +27,10 @@ async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) except SQLAlchemyError, OSError: - logger.exception("Base de donnees injoignable") + logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Base de donnees injoignable", + detail="Base de données injoignable", ) from None if version is None: diff --git a/apps/backend/app/db/base.py b/apps/backend/app/db/base.py index a1a552c..1830f3e 100644 --- a/apps/backend/app/db/base.py +++ b/apps/backend/app/db/base.py @@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - """Base declarative commune a tous les modeles.""" + """Base déclarative commune à tous les modèles.""" diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index fa717f5..1008100 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -17,7 +17,7 @@ logger = get_logger(__name__) async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings = get_settings() logger.info( - "Demarrage de %s %s en environnement %s", settings.name, settings.version, settings.env + "Démarrage de %s %s en environnement %s", settings.name, settings.version, settings.env ) yield await get_engine().dispose() diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 6d71227..2ac405d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,2 @@ -# Piege : tout modele absent de ce module reste invisible de `alembic revision -# --autogenerate`, qui genererait alors un drop de sa table. +# Piège : tout modèle absent de ce module reste invisible de `alembic revision +# --autogenerate`, qui générerait alors un drop de sa table. diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index a7a61b6..d5ba9bc 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -59,7 +59,7 @@ async def test_readiness_returns_503_when_database_is_unreachable( response = await client.get("/api/v1/health/ready") assert response.status_code == 503 - assert response.json()["detail"] == "Base de donnees injoignable" + assert response.json()["detail"] == "Base de données injoignable" @pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 4560b75..70ba87a 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -12,8 +12,8 @@ from app.main import create_app from tests.factories import FakeSession -# Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne -# pose pas ici, c'est le .env du poste qui les decide, et les assertions avec. +# Piège : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne +# pose pas ici, c'est le .env du poste qui les décide, et les assertions avec. @pytest.fixture(autouse=True, scope="session") def environment() -> Iterator[None]: os.environ.update( @@ -33,8 +33,8 @@ def environment() -> Iterator[None]: get_settings.cache_clear() -# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce -# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte. +# Piège : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce +# recyclage, le 2e test touchant vraiment la base hériterait d'une boucle morte. @pytest.fixture(autouse=True) async def engine_per_test() -> AsyncIterator[None]: yield @@ -67,7 +67,7 @@ def fake_session(app: FastAPI) -> Callable[..., None]: return install -# Contrainte : ouvre une vraie connexion, donc reservee aux tests `integration`. +# Contrainte : ouvre une vraie connexion, donc réservée aux tests `integration`. @pytest.fixture async def session() -> AsyncIterator[AsyncSession]: async with get_session_factory()() as async_session: diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 05ba3fc..3098863 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -31,7 +31,7 @@ class FakeSession: return self._result -# Piege : les arguments nommes priment sur l'environnement et sur .env, contrairement -# aux variables posees par la fixture `environment`, qui restent surchargeables. +# Piège : les arguments nommés priment sur l'environnement et sur .env, contrairement +# aux variables posées par la fixture `environment`, qui restent surchargeables. def make_settings(**overrides: Any) -> Settings: return Settings(**{**SETTINGS_DE_TEST, **overrides}) From 008cf581a7c0c131c1ef10fffc343e03326cfe27 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:22:09 +0200 Subject: [PATCH 026/205] =?UTF-8?q?ci(backend):=20v=C3=A9rifie=20format,?= =?UTF-8?q?=20lint,=20typage=20et=20tests=20=C3=A0=20chaque=20pouss=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.github/workflows/` ne contenait qu'un `.gitkeep` alors que l'EC03 évalue la CI en continu. Périmètre volontairement minimal, aligné sur `make check` : le scan de sécurité et la construction d'image relèvent du chantier CI/CD et viendront l'étendre. --- .github/workflows/backend.yml | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/backend.yml diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..b146eb5 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,58 @@ +name: Backend + +# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester +# en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler. + +on: + push: + paths: + - "apps/backend/**" + - ".github/workflows/backend.yml" + pull_request: + paths: + - "apps/backend/**" + - ".github/workflows/backend.yml" + +permissions: + contents: read + +concurrency: + group: backend-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint, typage et tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + - name: Vérifie le formatage + run: uv run ruff format --check . + + - name: Analyse statique + run: uv run ruff check --output-format=github . + + - name: Typage + run: uv run mypy app + + # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. + - name: Tests et couverture + run: uv run pytest --cov-fail-under=85 From 53af7a76d8ac16d21ed2ef97554fc5fbc649ff65 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:25:41 +0200 Subject: [PATCH 027/205] =?UTF-8?q?feat(backend):=20pose=20les=20primitive?= =?UTF-8?q?s=20de=20s=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couche pure, sans FastAPI ni session : rôles ordonnés, `Principal`, encodage et décodage des jetons d'accès, empreinte des jetons de rafraîchissement, et hachage Argon2id poussé dans un fil borné. Aucun de ces modules ne lit `get_settings()`, mis en cache par `lru_cache` et donc contaminé entre tests : les paramètres arrivent par `TokenPolicy` et par `build_hasher()`. Argon2id est calibré à m=19456 KiB, t=2, p=1, soit 17 ms mesurés sur un poste de développement. --- apps/backend/app/core/hashing.py | 63 ++++++++ apps/backend/app/core/principal.py | 18 +++ apps/backend/app/core/roles.py | 26 +++ apps/backend/app/core/security.py | 117 ++++++++++++++ apps/backend/pyproject.toml | 6 +- apps/backend/tests/core/test_hashing.py | 61 +++++++ apps/backend/tests/core/test_roles.py | 40 +++++ apps/backend/tests/core/test_security.py | 194 +++++++++++++++++++++++ apps/backend/uv.lock | 103 ++++++++++++ 9 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 apps/backend/app/core/hashing.py create mode 100644 apps/backend/app/core/principal.py create mode 100644 apps/backend/app/core/roles.py create mode 100644 apps/backend/app/core/security.py create mode 100644 apps/backend/tests/core/test_hashing.py create mode 100644 apps/backend/tests/core/test_roles.py create mode 100644 apps/backend/tests/core/test_security.py diff --git a/apps/backend/app/core/hashing.py b/apps/backend/app/core/hashing.py new file mode 100644 index 0000000..0cbc975 --- /dev/null +++ b/apps/backend/app/core/hashing.py @@ -0,0 +1,63 @@ +# Piège : `PasswordHasher.verify()` bloque 17 ms. Appelé tel quel dans un `async def`, il fige +# la boucle d'événements et gèle toutes les requêtes en cours, pas seulement la connexion. +# `Argon2Hasher` le pousse donc dans un fil, sous un `CapacityLimiter` : le pool par défaut +# d'anyio accepte 40 fils, soit 40 x 19 Mio dans le pire cas sur une machine qui héberge aussi +# PostgreSQL, Prometheus et Grafana. +# Piège : `verify_dummy()` doit être appelé quand l'utilisateur est introuvable. Sans lui, +# l'écart entre 2 ms et 17 ms est un oracle d'existence de compte, mesurable à distance. + +import secrets + +import anyio +import anyio.to_thread +from argon2 import PasswordHasher +from argon2.exceptions import Argon2Error, InvalidHashError, VerificationError + +_ERREURS_DE_VERIFICATION = (VerificationError, InvalidHashError, Argon2Error) + + +class Argon2Hasher: + def __init__(self, hasher: PasswordHasher, *, max_concurrency: int) -> None: + self._hasher = hasher + self._limiter = anyio.CapacityLimiter(max_concurrency) + self._leurre = hasher.hash(secrets.token_urlsafe(32)) + + async def hash(self, password: str) -> str: + return await anyio.to_thread.run_sync(self._hasher.hash, password, limiter=self._limiter) + + async def verify(self, stored: str, password: str) -> bool: + return await anyio.to_thread.run_sync(self._verify, stored, password, limiter=self._limiter) + + async def verify_dummy(self) -> None: + await self.verify(self._leurre, "") + + def needs_rehash(self, stored: str) -> bool: + try: + return self._hasher.check_needs_rehash(stored) + except _ERREURS_DE_VERIFICATION: + return True + + def _verify(self, stored: str, password: str) -> bool: + try: + return self._hasher.verify(stored, password) + except _ERREURS_DE_VERIFICATION: + return False + + +def build_hasher( + *, + time_cost: int, + memory_cost_kib: int, + parallelism: int, + max_concurrency: int, +) -> Argon2Hasher: + return Argon2Hasher( + PasswordHasher( + time_cost=time_cost, + memory_cost=memory_cost_kib, + parallelism=parallelism, + hash_len=32, + salt_len=16, + ), + max_concurrency=max_concurrency, + ) diff --git a/apps/backend/app/core/principal.py b/apps/backend/app/core/principal.py new file mode 100644 index 0000000..af69bdc --- /dev/null +++ b/apps/backend/app/core/principal.py @@ -0,0 +1,18 @@ +# Pourquoi : tout le code métier dépend de `Principal` et jamais du modèle ORM ni des claims +# du jeton. C'est ce qui garde la bascule vers un fournisseur OIDC locale à +# `get_current_principal()` et à `AuthService.authenticate()`, au lieu de la répandre dans +# chaque endpoint. + +from dataclasses import dataclass +from uuid import UUID + +from app.core.roles import AccountKind, Role + + +@dataclass(frozen=True, slots=True) +class Principal: + id: UUID + email: str + role: Role + kind: AccountKind + must_change_password: bool diff --git a/apps/backend/app/core/roles.py b/apps/backend/app/core/roles.py new file mode 100644 index 0000000..211b187 --- /dev/null +++ b/apps/backend/app/core/roles.py @@ -0,0 +1,26 @@ +from enum import StrEnum +from typing import Final + + +class Role(StrEnum): + # Contrainte : ces valeurs voyagent en base, en JSON et dans les jetons. Elles restent + # en ASCII, contrairement au libellé « opérateur » affiché à l'utilisateur. + LECTEUR = "lecteur" + OPERATEUR = "operateur" + ADMIN = "admin" + + +class AccountKind(StrEnum): + HUMAIN = "human" + SERVICE = "service" + + +ROLE_RANK: Final[dict[Role, int]] = { + Role.LECTEUR: 0, + Role.OPERATEUR: 1, + Role.ADMIN: 2, +} + + +def has_at_least(actual: Role, required: Role) -> bool: + return ROLE_RANK[actual] >= ROLE_RANK[required] diff --git a/apps/backend/app/core/security.py b/apps/backend/app/core/security.py new file mode 100644 index 0000000..a9b71e5 --- /dev/null +++ b/apps/backend/app/core/security.py @@ -0,0 +1,117 @@ +# Piège : `decode_access_token()` porte trois barrières indépendantes, et retirer l'une +# d'elles ne casse aucun test évident. L'algorithme est épinglé, sinon un jeton forgé en +# `alg: none` passerait. L'audience et l'émetteur sont vérifiés, sinon un jeton émis pour +# un autre service serait accepté. Le claim `typ` est comparé, sinon un jeton de +# rafraîchissement servirait de jeton d'accès, ce qui transformerait une fenêtre de +# 15 minutes en fenêtre de 7 jours. +# Contrainte : ce module ne lit jamais `get_settings()`, qui est mis en cache par +# `lru_cache` et se contaminerait entre tests. Tout paramètre arrive par `TokenPolicy`. + +import hashlib +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Final +from uuid import UUID, uuid4 + +import jwt + +ACCESS_TOKEN_TYPE: Final = "access" # noqa: S105 +REFRESH_SECRET_BYTES: Final = 32 + +_ALGORITHME: Final = "HS256" +_CLAIMS_REQUIS: Final = ["iss", "aud", "sub", "iat", "exp", "jti", "typ", "role", "kind"] + + +class TokenInvalidError(Exception): + pass + + +class TokenExpiredError(TokenInvalidError): + pass + + +@dataclass(frozen=True, slots=True) +class TokenPolicy: + secret: str + issuer: str + audience: str + access_ttl: timedelta + + +@dataclass(frozen=True, slots=True) +class AccessClaims: + subject: UUID + role: str + kind: str + token_id: UUID + issued_at: datetime + + +def encode_access_token( + policy: TokenPolicy, + *, + subject: UUID, + role: str, + kind: str, + now: datetime | None = None, +) -> str: + emis_a = now or datetime.now(UTC) + return jwt.encode( + { + "iss": policy.issuer, + "aud": policy.audience, + "sub": str(subject), + "iat": emis_a, + "exp": emis_a + policy.access_ttl, + "jti": str(uuid4()), + "typ": ACCESS_TOKEN_TYPE, + "role": role, + "kind": kind, + }, + policy.secret, + algorithm=_ALGORITHME, + ) + + +def decode_access_token(policy: TokenPolicy, token: str) -> AccessClaims: + try: + charge = jwt.decode( + token, + policy.secret, + algorithms=[_ALGORITHME], + audience=policy.audience, + issuer=policy.issuer, + options={"require": _CLAIMS_REQUIS}, + ) + except jwt.ExpiredSignatureError as erreur: + raise TokenExpiredError("Jeton expiré") from erreur + except jwt.InvalidTokenError as erreur: + raise TokenInvalidError("Jeton invalide") from erreur + + if charge["typ"] != ACCESS_TOKEN_TYPE: + raise TokenInvalidError("Type de jeton inattendu") + + try: + sujet = UUID(charge["sub"]) + identifiant = UUID(charge["jti"]) + except (AttributeError, TypeError, ValueError) as erreur: + raise TokenInvalidError("Identifiants du jeton illisibles") from erreur + + return AccessClaims( + subject=sujet, + role=str(charge["role"]), + kind=str(charge["kind"]), + token_id=identifiant, + issued_at=datetime.fromtimestamp(charge["iat"], tz=UTC), + ) + + +def generate_refresh_secret() -> str: + return secrets.token_urlsafe(REFRESH_SECRET_BYTES) + + +# SHA-256 nu, pas Argon2id : 256 bits de CSPRNG n'ont ni dictionnaire ni préimage atteignable, +# et une KDF lente coûterait 17 ms à chaque rafraîchissement pour aucun gain. +def fingerprint_refresh(secret: str) -> bytes: + return hashlib.sha256(secret.encode("utf-8")).digest() diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 1c27c08..684524d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "alembic>=1.20.0", "prometheus-fastapi-instrumentator>=8.1.0", "python-json-logger>=4.2.0", + "pyjwt>=2.10", + "argon2-cffi>=23.1", + "anyio>=4.0", ] [dependency-groups] @@ -57,7 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["S101"] +# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/core/test_hashing.py b/apps/backend/tests/core/test_hashing.py new file mode 100644 index 0000000..c56c713 --- /dev/null +++ b/apps/backend/tests/core/test_hashing.py @@ -0,0 +1,61 @@ +from app.core.hashing import Argon2Hasher, build_hasher + +MOT_DE_PASSE = "un-mot-de-passe-de-test-assez-long" + + +def fabrique(time_cost: int = 1, max_concurrency: int = 2) -> Argon2Hasher: + return build_hasher( + time_cost=time_cost, + memory_cost_kib=8192, + parallelism=1, + max_concurrency=max_concurrency, + ) + + +async def test_hash_produces_a_distinct_digest_for_the_same_password() -> None: + hacheur = fabrique() + + premier = await hacheur.hash(MOT_DE_PASSE) + second = await hacheur.hash(MOT_DE_PASSE) + + assert premier != second + assert premier.startswith("$argon2id$") + + +async def test_verify_accepts_the_right_password_and_rejects_the_others() -> None: + hacheur = fabrique() + + empreinte = await hacheur.hash(MOT_DE_PASSE) + + assert await hacheur.verify(empreinte, MOT_DE_PASSE) is True + assert await hacheur.verify(empreinte, "un-autre-mot-de-passe") is False + + +async def test_verify_returns_false_when_the_stored_digest_is_malformed() -> None: + hacheur = fabrique() + + accorde = await hacheur.verify("pas-une-empreinte-argon2", MOT_DE_PASSE) + + assert accorde is False + + +async def test_needs_rehash_is_true_when_the_parameters_changed() -> None: + ancien = fabrique(time_cost=1) + recent = fabrique(time_cost=3) + + empreinte = await ancien.hash(MOT_DE_PASSE) + + assert ancien.needs_rehash(empreinte) is False + assert recent.needs_rehash(empreinte) is True + + +def test_needs_rehash_is_true_when_the_stored_digest_is_malformed() -> None: + hacheur = fabrique() + + assert hacheur.needs_rehash("pas-une-empreinte-argon2") is True + + +async def test_verify_dummy_completes_without_revealing_anything() -> None: + hacheur = fabrique() + + await hacheur.verify_dummy() diff --git a/apps/backend/tests/core/test_roles.py b/apps/backend/tests/core/test_roles.py new file mode 100644 index 0000000..fdcafe7 --- /dev/null +++ b/apps/backend/tests/core/test_roles.py @@ -0,0 +1,40 @@ +import pytest + +from app.core.roles import Role, has_at_least + + +@pytest.mark.parametrize( + ("actual", "required", "expected"), + [ + (Role.LECTEUR, Role.LECTEUR, True), + (Role.LECTEUR, Role.OPERATEUR, False), + (Role.LECTEUR, Role.ADMIN, False), + (Role.OPERATEUR, Role.LECTEUR, True), + (Role.OPERATEUR, Role.OPERATEUR, True), + (Role.OPERATEUR, Role.ADMIN, False), + (Role.ADMIN, Role.LECTEUR, True), + (Role.ADMIN, Role.OPERATEUR, True), + (Role.ADMIN, Role.ADMIN, True), + ], + ids=[ + "lecteur_sur_lecteur", + "lecteur_sur_operateur", + "lecteur_sur_admin", + "operateur_sur_lecteur", + "operateur_sur_operateur", + "operateur_sur_admin", + "admin_sur_lecteur", + "admin_sur_operateur", + "admin_sur_admin", + ], +) +def test_has_at_least_orders_the_three_roles(actual: Role, required: Role, expected: bool) -> None: + accorde = has_at_least(actual, required) + + assert accorde is expected + + +def test_role_values_stay_ascii_for_the_wire_format() -> None: + valeurs = [role.value for role in Role] + + assert all(valeur.isascii() for valeur in valeurs) diff --git a/apps/backend/tests/core/test_security.py b/apps/backend/tests/core/test_security.py new file mode 100644 index 0000000..88a8611 --- /dev/null +++ b/apps/backend/tests/core/test_security.py @@ -0,0 +1,194 @@ +import base64 +import json +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import jwt +import pytest + +from app.core.security import ( + AccessClaims, + TokenExpiredError, + TokenInvalidError, + TokenPolicy, + decode_access_token, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) + +POLITIQUE = TokenPolicy( + secret="un-secret-de-test-de-plus-de-trente-deux-caracteres", + issuer="enervision-api", + audience="enervision-web", + access_ttl=timedelta(minutes=15), +) + + +def emets(**surcharges: object) -> str: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "lecteur", + "kind": "human", + } + charge.update(surcharges) + return jwt.encode(charge, POLITIQUE.secret, algorithm="HS256") + + +def test_decode_access_token_returns_the_claims_when_the_token_is_valid() -> None: + sujet = uuid4() + + jeton = encode_access_token(POLITIQUE, subject=sujet, role="operateur", kind="human") + claims = decode_access_token(POLITIQUE, jeton) + + assert isinstance(claims, AccessClaims) + assert claims.subject == sujet + assert claims.role == "operateur" + assert claims.kind == "human" + + +def test_decode_access_token_raises_expired_when_the_lifetime_has_passed() -> None: + passe = datetime.now(UTC) - timedelta(hours=2) + + jeton = encode_access_token(POLITIQUE, subject=uuid4(), role="lecteur", kind="human", now=passe) + + with pytest.raises(TokenExpiredError): + decode_access_token(POLITIQUE, jeton) + + +def test_decode_access_token_raises_invalid_when_the_signature_was_forged() -> None: + autre = TokenPolicy( + secret="un-autre-secret-tout-aussi-long-que-le-premier", + issuer=POLITIQUE.issuer, + audience=POLITIQUE.audience, + access_ttl=POLITIQUE.access_ttl, + ) + + jeton = encode_access_token(autre, subject=uuid4(), role="lecteur", kind="human") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "surcharges", + [ + {"aud": "un-autre-public"}, + {"iss": "un-autre-emetteur"}, + {"typ": "refresh"}, + ], + ids=["audience_invalide", "emetteur_invalide", "jeton_de_rafraichissement"], +) +def test_decode_access_token_raises_invalid_when_a_claim_is_wrong( + surcharges: dict[str, object], +) -> None: + jeton = emets(**surcharges) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "claim", + ["jti", "typ", "role", "kind"], + ids=["identifiant", "type", "role", "nature_du_compte"], +) +def test_decode_access_token_raises_invalid_when_a_required_claim_is_missing(claim: str) -> None: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "lecteur", + "kind": "human", + } + del charge[claim] + + jeton = jwt.encode(charge, POLITIQUE.secret, algorithm="HS256") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +def test_decode_access_token_rejects_a_token_forged_with_the_none_algorithm() -> None: + def encode(donnees: dict[str, object]) -> str: + brut = json.dumps(donnees, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(brut).rstrip(b"=").decode() + + entete = encode({"alg": "none", "typ": "JWT"}) + charge = encode( + { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": int(datetime.now(UTC).timestamp()), + "exp": int((datetime.now(UTC) + timedelta(minutes=15)).timestamp()), + "jti": str(uuid4()), + "typ": "access", + "role": "admin", + "kind": "human", + } + ) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, f"{entete}.{charge}.") + + +def test_decode_access_token_rejects_a_token_signed_with_another_algorithm() -> None: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "admin", + "kind": "human", + } + + jeton = jwt.encode(charge, POLITIQUE.secret * 2, algorithm="HS512") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "surcharges", + [{"sub": "pas-un-uuid"}, {"jti": "pas-un-uuid"}], + ids=["sujet_illisible", "identifiant_illisible"], +) +def test_decode_access_token_raises_invalid_when_an_identifier_is_not_a_uuid( + surcharges: dict[str, object], +) -> None: + jeton = emets(**surcharges) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +def test_generate_refresh_secret_returns_distinct_url_safe_values() -> None: + secrets_generes = {generate_refresh_secret() for _ in range(100)} + + assert len(secrets_generes) == 100 + assert all(len(valeur) >= 43 for valeur in secrets_generes) + + +def test_fingerprint_refresh_is_stable_and_distinguishes_two_secrets() -> None: + premier = generate_refresh_secret() + second = generate_refresh_secret() + + empreinte = fingerprint_refresh(premier) + + assert len(empreinte) == 32 + assert empreinte == fingerprint_refresh(premier) + assert empreinte != fingerprint_refresh(second) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index f799110..edc09c2 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -47,6 +47,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, +] + [[package]] name = "ast-serialize" version = "0.11.2" @@ -143,6 +187,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -206,11 +285,14 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "anyio" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-json-logger" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, @@ -229,11 +311,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.20.0" }, + { name = "anyio", specifier = ">=4.0" }, + { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyjwt", specifier = ">=2.10" }, { name = "python-json-logger", specifier = ">=4.2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.52" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" }, @@ -537,6 +622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/b9/91a2246e6cf01b7ccb14479803c8a50f9c258ae5c6a0f16ba3294820632b/prometheus_fastapi_instrumentator-8.1.0-py3-none-any.whl", hash = "sha256:b9f40b2cff3f7891ca0610b3ae4fc6ec723fd326b04bb659819aaeb821a0fc7d", size = 19649, upload-time = "2026-07-26T11:12:45.168Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +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" @@ -616,6 +710,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 = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + [[package]] name = "pytest" version = "9.1.1" From a8f59e6e76fca98de7ab3946c130cdc6591f4ade Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:30:05 +0200 Subject: [PATCH 028/205] =?UTF-8?q?feat(backend):=20ajoute=20les=20comptes?= =?UTF-8?q?=20applicatifs=20et=20l'amor=C3=A7age=20du=20premier=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table `app_user`, son dépôt, et la commande `create-admin`. Le nom évite `user`, mot réservé de PostgreSQL, et rappelle qu'il s'agit d'un compte applicatif, par opposition au rôle PostgreSQL qui portera le cantonnement des accès ETL et ML. `credentials_changed_at` couvre à elle seule le changement de mot de passe, le changement de rôle et la désactivation : tout jeton émis avant cet instant sera refusé, sans attendre son expiration. La configuration refuse désormais de démarrer sur cinq erreurs silencieuses : secret trop court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors local, et cookie `SameSite=None` sans `Secure`. Les fixtures de test et les deux `.env.example` suivent, sans quoi rien ne démarrerait. Le mot de passe de l'admin ne transite jamais par `argv`, visible de tout `ps` : il est saisi par `getpass` ou tiré au sort. Une révision Alembic qui insérerait ce compte graverait son empreinte dans Git pour toujours. --- .env.example | 12 +- Makefile | 25 +++-- apps/backend/.env.example | 6 +- .../b1a7c3d9e240_comptes_applicatifs.py | 72 ++++++++++++ apps/backend/app/cli.py | 104 ++++++++++++++++++ apps/backend/app/core/config.py | 72 +++++++++++- apps/backend/app/core/cookies.py | 54 +++++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/user.py | 50 +++++++++ apps/backend/app/repositories/user.py | 97 ++++++++++++++++ apps/backend/tests/conftest.py | 2 +- apps/backend/tests/core/test_config.py | 78 +++++++++++++ apps/backend/tests/core/test_cookies.py | 58 ++++++++++ apps/backend/tests/factories.py | 2 +- 14 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py create mode 100644 apps/backend/app/cli.py create mode 100644 apps/backend/app/core/cookies.py create mode 100644 apps/backend/app/models/user.py create mode 100644 apps/backend/app/repositories/user.py create mode 100644 apps/backend/tests/core/test_config.py create mode 100644 apps/backend/tests/core/test_cookies.py diff --git a/.env.example b/.env.example index a5fba5a..54dc3d8 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,19 @@ -# Variables lues par docker-compose.yml a la racine. -# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. +# Variables lues par docker-compose.yml à la racine. +# Le backend lancé hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. POSTGRES_USER=enervision POSTGRES_PASSWORD=change_me POSTGRES_DB=enervision -# 5432 est souvent deja pris par une autre base du poste. +# 5432 est souvent déjà pris par une autre base du poste. POSTGRES_PORT=5433 -# `basic` renvoie des statistiques d'usage a Timescale. +# `basic` renvoie des statistiques d'usage à Timescale. TIMESCALEDB_TELEMETRY=off APP_ENV=local -APP_DEBUG=true +APP_DEBUG=false APP_LOG_LEVEL=INFO +# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de +# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 diff --git a/Makefile b/Makefile index 81c3e6d..bf45b61 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,15 @@ BACKEND := apps/backend .DEFAULT_GOAL := help .PHONY: help install dev lint format typecheck test test-cov test-integration check \ - docker-build db-up db-down db-reset db-logs db-psql migrate + docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: ## Installe les dependances du backend +install: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups -dev: ## Lance l'API en rechargement a chaud +dev: ## Lance l'API en rechargement à chaud cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 lint: ## Analyse statique du backend @@ -19,31 +19,31 @@ lint: ## Analyse statique du backend format: ## Formate et corrige le backend cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix . -typecheck: ## Verifie le typage du backend +typecheck: ## Vérifie le typage du backend cd $(BACKEND) && uv run mypy app -test: ## Execute les tests backend ne demandant pas de base +test: ## Exécute les tests backend ne demandant pas de base cd $(BACKEND) && uv run pytest --cov-fail-under=85 -test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit +test-cov: ## Rapports de couverture HTML et XML, plus les résultats au format JUnit cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \ --cov-report=xml --junitxml=test-results/junit.xml -test-integration: ## Execute les tests exigeant une base joignable +test-integration: ## Exécute les tests exigeant une base joignable cd $(BACKEND) && uv run pytest -m integration -check: lint typecheck test ## Chaine de verification complete +check: lint typecheck test ## Chaîne de vérification complète docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) -db-up: ## Demarre la base PostgreSQL TimescaleDB +db-up: ## Démarre la base PostgreSQL TimescaleDB docker compose up -d db -db-down: ## Arrete la base en conservant ses donnees +db-down: ## Arrête la base en conservant ses données docker compose stop db -db-reset: ## Detruit la base et rejoue db/init +db-reset: ## Détruit la base et rejoue db/init docker compose down -v && docker compose up -d db db-logs: ## Suit les journaux de la base @@ -54,3 +54,6 @@ db-psql: ## Ouvre une session psql sur la base applicative migrate: ## Applique les migrations Alembic cd $(BACKEND) && 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} diff --git a/apps/backend/.env.example b/apps/backend/.env.example index cd96463..f36551e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,6 +1,10 @@ APP_ENV=local -APP_DEBUG=true +APP_DEBUG=false APP_LOG_LEVEL=INFO + +# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de +# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" APP_SECRET_KEY=change_me + APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision diff --git a/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py b/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py new file mode 100644 index 0000000..db50a12 --- /dev/null +++ b/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py @@ -0,0 +1,72 @@ +"""comptes applicatifs + +Revision ID: b1a7c3d9e240 +Revises: 5353c0e4f094 +Create Date: 2026-09-15 14:40:00.000000 + +Cree `app_user`, la table des comptes humains et de service. Le nom evite `user`, +mot reserve de PostgreSQL. `gen_random_uuid()` est au coeur de PG17, aucune +extension n'est necessaire. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "b1a7c3d9e240" +down_revision: str | Sequence[str] | None = "5353c0e4f094" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "app_user", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("kind", sa.Text(), server_default=sa.text("'human'"), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column( + "must_change_password", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "credentials_changed_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("full_name", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"), + sa.CheckConstraint( + "role in ('lecteur', 'operateur', 'admin')", name="ck_app_user_role" + ), + sa.CheckConstraint("kind in ('human', 'service')", name="ck_app_user_kind"), + sa.PrimaryKeyConstraint("id", name="pk_app_user"), + sa.UniqueConstraint("email", name="uq_app_user_email"), + ) + + +def downgrade() -> None: + op.drop_table("app_user") diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py new file mode 100644 index 0000000..74d7a50 --- /dev/null +++ b/apps/backend/app/cli.py @@ -0,0 +1,104 @@ +# Pourquoi : `create_admin()` est une commande et non une révision Alembic. Une révision qui +# insérerait un compte graverait son empreinte dans Git pour toujours, et son mot de passe +# serait connu de quiconque lit le dépôt. L'ADR 0001 pose par ailleurs qu'Alembic porte le +# schéma, pas les données. +# Piège : le mot de passe ne transite jamais par `argv`, visible de tout `ps`, ni par +# l'historique du shell. Il est saisi par `getpass` ou tiré au sort par la commande. + +import argparse +import asyncio +import secrets +import sys +from getpass import getpass + +from app.core.config import Settings, get_settings +from app.core.hashing import build_hasher +from app.core.roles import Role +from app.db.session import get_session_factory +from app.repositories.user import UserRepository + +LONGUEUR_MOT_DE_PASSE_GENERE = 24 +LONGUEUR_MINIMALE = 12 + + +async def create_admin( + settings: Settings, *, email: str, password: str, force: bool +) -> tuple[bool, str]: + hacheur = build_hasher( + time_cost=settings.argon2_time_cost, + memory_cost_kib=settings.argon2_memory_cost_kib, + parallelism=settings.argon2_parallelism, + max_concurrency=settings.argon2_max_concurrency, + ) + empreinte = await hacheur.hash(password) + + async with get_session_factory()() as session: + depot = UserRepository(session) + + if not force and await depot.count_active_admins() > 0: + return False, "Un administrateur actif existe déjà, relancer avec --force pour forcer" + + if await depot.get_by_email(email) is not None: + return False, f"Le compte {email} existe déjà" + + await depot.create( + email=email, + password_hash=empreinte, + role=Role.ADMIN, + must_change_password=True, + ) + await session.commit() + + return ( + True, + f"Administrateur {email.strip().lower()} créé, mot de passe à changer à la connexion", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision") + sous_commandes = parser.add_subparsers(dest="commande", required=True) + + admin = sous_commandes.add_parser("create-admin", help="Crée le premier administrateur") + admin.add_argument("--email", required=True) + admin.add_argument( + "--generate", action="store_true", help="Tire un mot de passe au sort et l'affiche une fois" + ) + admin.add_argument( + "--force", action="store_true", help="Crée le compte même si un administrateur existe" + ) + return parser + + +def read_password(*, generate: bool) -> str: + if generate: + mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") + return mot_de_passe + + mot_de_passe = getpass("Mot de passe : ") + if len(mot_de_passe) < LONGUEUR_MINIMALE: + raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if mot_de_passe != getpass("Confirmation : "): + raise SystemExit("Les deux saisies diffèrent") + return mot_de_passe + + +def main(argv: list[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + mot_de_passe = read_password(generate=arguments.generate) + + succes, message = asyncio.run( + create_admin( + get_settings(), + email=arguments.email, + password=mot_de_passe, + force=arguments.force, + ) + ) + print(message) + return 0 if succes else 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index c3dbbe2..4731f81 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -1,10 +1,16 @@ from functools import lru_cache -from typing import Literal +from typing import Literal, Self -from pydantic import Field, SecretStr +from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict Environment = Literal["local", "dev", "staging", "prod"] +SameSite = Literal["lax", "strict", "none"] + +SECRET_KEY_MIN_LENGTH = 32 +SENTINELLES_INTERDITES = frozenset( + {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} +) class Settings(BaseSettings): @@ -27,6 +33,30 @@ class Settings(BaseSettings): database_pool_size: int = 5 database_max_overflow: int = 10 + jwt_issuer: str = "enervision-api" + jwt_audience: str = "enervision-web" + access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) + refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000) + + refresh_cookie_name: str = "ev_refresh" + cookie_path: str = "/api/v1/auth" + cookie_samesite: SameSite = "strict" + cookie_secure: bool | None = None + + argon2_time_cost: int = Field(default=2, ge=1, le=10) + argon2_memory_cost_kib: int = Field(default=19456, ge=8192) + argon2_parallelism: int = Field(default=1, ge=1, le=4) + argon2_max_concurrency: int = Field(default=4, ge=1, le=32) + + login_window_seconds: int = Field(default=900, ge=60) + login_max_failures_per_identifier_and_ip: int = Field(default=5, ge=1) + login_max_failures_per_ip: int = Field(default=20, ge=1) + login_max_failures_per_identifier: int = Field(default=50, ge=1) + + trust_proxy_headers: bool = False + expose_api_docs: bool | None = None + metrics_token: SecretStr | None = None + @property def allowed_origins(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] @@ -35,6 +65,44 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.env == "prod" + @property + def cookies_are_secure(self) -> bool: + return self.env != "local" if self.cookie_secure is None else self.cookie_secure + + @property + def api_docs_are_exposed(self) -> bool: + if self.expose_api_docs is not None: + return self.expose_api_docs + return self.env not in ("staging", "prod") + + @model_validator(mode="after") + def _refuse_les_configurations_dangereuses(self) -> Self: + secret = self.secret_key.get_secret_value() + if len(secret) < SECRET_KEY_MIN_LENGTH: + raise ValueError( + f"APP_SECRET_KEY doit faire au moins {SECRET_KEY_MIN_LENGTH} caractères" + ) + if secret.strip().lower() in SENTINELLES_INTERDITES: + raise ValueError("APP_SECRET_KEY est une valeur d'exemple, il faut en générer une") + + # Piège : `create_app()` passe `debug` à FastAPI, qui renvoie alors la trace complète + # au client, et à l'engine, qui journalise le SQL et ses paramètres. + if self.debug and self.env in ("staging", "prod"): + raise ValueError("APP_DEBUG doit rester faux hors des environnements locaux") + + if "*" in self.cors_origins: + raise ValueError("APP_CORS_ORIGINS n'accepte pas de joker, les origines sont listées") + + # Sans origines, aucun middleware CORS n'est monté et la vérification d'`Origin` des + # routes d'authentification n'a plus de référentiel auquel comparer. + if self.env != "local" and not self.allowed_origins: + raise ValueError("APP_CORS_ORIGINS doit lister au moins une origine hors local") + + if self.cookie_samesite == "none" and not self.cookies_are_secure: + raise ValueError("Un cookie SameSite=None est rejeté par les navigateurs sans Secure") + + return self + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py new file mode 100644 index 0000000..f54dbff --- /dev/null +++ b/apps/backend/app/core/cookies.py @@ -0,0 +1,54 @@ +# Piège : le cookie de suppression doit reprendre exactement le nom et le `Path` du cookie +# posé, sinon le navigateur en garde une copie et la déconnexion n'est que cosmétique. +# `RefreshCookie.expired()` existe pour que les deux ne puissent pas diverger. + +from dataclasses import asdict, dataclass +from typing import Any, Self + +from app.core.config import SameSite, Settings + +SECURE_PREFIX = "__Secure-" + + +@dataclass(frozen=True, slots=True) +class RefreshCookie: + key: str + value: str + max_age: int + path: str + secure: bool + httponly: bool + samesite: SameSite + + @classmethod + def build(cls, settings: Settings, value: str) -> Self: + return cls( + key=cookie_name(settings), + value=value, + max_age=settings.refresh_token_ttl_seconds, + path=settings.cookie_path, + secure=settings.cookies_are_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + @classmethod + def expired(cls, settings: Settings) -> Self: + return cls( + key=cookie_name(settings), + value="", + max_age=0, + path=settings.cookie_path, + secure=settings.cookies_are_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + def as_kwargs(self) -> dict[str, Any]: + return asdict(self) + + +def cookie_name(settings: Settings) -> str: + if settings.cookies_are_secure: + return f"{SECURE_PREFIX}{settings.refresh_cookie_name}" + return settings.refresh_cookie_name diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 2ac405d..dd6cc73 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,6 @@ # Piège : tout modèle absent de ce module reste invisible de `alembic revision # --autogenerate`, qui générerait alors un drop de sa table. + +from app.models.user import AppUser + +__all__ = ["AppUser"] diff --git a/apps/backend/app/models/user.py b/apps/backend/app/models/user.py new file mode 100644 index 0000000..b2dcf4b --- /dev/null +++ b/apps/backend/app/models/user.py @@ -0,0 +1,50 @@ +# Contrainte : la table s'appelle `app_user` et non `user`, qui est un mot réservé PostgreSQL, +# raccourci de `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif, par +# opposition au rôle PostgreSQL qui porte, lui, le cantonnement des accès. + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, CheckConstraint, DateTime, String, Text, func, text +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.roles import AccountKind, Role +from app.db.base import Base + +ROLES_AUTORISES = ", ".join(f"'{role.value}'" for role in Role) +NATURES_AUTORISEES = ", ".join(f"'{nature.value}'" for nature in AccountKind) + + +class AppUser(Base): + __tablename__ = "app_user" + __table_args__ = ( + CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"), + CheckConstraint(f"role in ({ROLES_AUTORISES})", name="ck_app_user_role"), + CheckConstraint(f"kind in ({NATURES_AUTORISEES})", name="ck_app_user_kind"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(Text, nullable=False) + kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'human'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + must_change_password: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + # Une seule colonne couvre le changement de mot de passe, le changement de rôle et la + # désactivation : tout jeton émis avant cet instant est périmé. + credentials_changed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + full_name: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py new file mode 100644 index 0000000..9155db1 --- /dev/null +++ b/apps/backend/app/repositories/user.py @@ -0,0 +1,97 @@ +# Piège : `set_role()` et `set_active()` avancent `credentials_changed_at`. C'est ce qui rend +# un changement de rôle ou une désactivation effectifs à la requête suivante au lieu d'attendre +# l'expiration du jeton d'accès. Une mise à jour qui l'oublierait laisserait 15 minutes de +# privilèges périmés. + +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import AccountKind, Role +from app.models.user import AppUser + + +class UserRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_email(self, email: str) -> AppUser | None: + requete = select(AppUser).where(AppUser.email == email.strip().lower()) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def get_by_id(self, user_id: UUID) -> AppUser | None: + return await self._session.get(AppUser, user_id) + + async def list_all(self) -> Sequence[AppUser]: + requete = select(AppUser).order_by(AppUser.email) + return (await self._session.execute(requete)).scalars().all() + + async def count_active_admins(self) -> int: + requete = ( + select(func.count()) + .select_from(AppUser) + .where(AppUser.role == Role.ADMIN.value, AppUser.is_active.is_(True)) + ) + return (await self._session.execute(requete)).scalar_one() + + async def create( + self, + *, + email: str, + password_hash: str, + role: Role, + kind: AccountKind = AccountKind.HUMAIN, + full_name: str | None = None, + must_change_password: bool = False, + ) -> AppUser: + compte = AppUser( + email=email.strip().lower(), + password_hash=password_hash, + role=role.value, + kind=kind.value, + full_name=full_name, + must_change_password=must_change_password, + ) + self._session.add(compte) + await self._session.flush() + return compte + + async def update_password( + self, user_id: UUID, password_hash: str, *, must_change_password: bool + ) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values( + password_hash=password_hash, + must_change_password=must_change_password, + credentials_changed_at=func.now(), + ) + ) + + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: + # Un simple recalcul avec des paramètres Argon2 plus récents ne périme aucun jeton. + await self._session.execute( + update(AppUser).where(AppUser.id == user_id).values(password_hash=password_hash) + ) + + async def touch_last_login(self, user_id: UUID) -> None: + await self._session.execute( + update(AppUser).where(AppUser.id == user_id).values(last_login_at=func.now()) + ) + + async def set_role(self, user_id: UUID, role: Role) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values(role=role.value, credentials_changed_at=func.now()) + ) + + async def set_active(self, user_id: UUID, *, is_active: bool) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values(is_active=is_active, credentials_changed_at=func.now()) + ) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 70ba87a..bc8ccfb 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -22,7 +22,7 @@ def environment() -> Iterator[None]: "APP_DEBUG": "false", "APP_LOG_LEVEL": "WARNING", "APP_CORS_ORIGINS": "", - "APP_SECRET_KEY": "secret-de-test", + "APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur", } ) os.environ.setdefault( diff --git a/apps/backend/tests/core/test_config.py b/apps/backend/tests/core/test_config.py new file mode 100644 index 0000000..6c67120 --- /dev/null +++ b/apps/backend/tests/core/test_config.py @@ -0,0 +1,78 @@ +import pytest +from pydantic import ValidationError + +from tests.factories import make_settings + +SECRET_VALIDE = "un-secret-de-test-de-plus-de-trente-deux-caracteres" + + +@pytest.mark.parametrize( + "surcharges", + [ + {"secret_key": "trop-court"}, + {"secret_key": "change_me"}, + {"env": "prod", "debug": True, "cors_origins": "https://enervision.fr"}, + {"cors_origins": "*"}, + {"env": "prod", "cors_origins": ""}, + {"cookie_samesite": "none", "cookie_secure": False}, + ], + ids=[ + "secret_trop_court", + "secret_sentinelle", + "debug_en_production", + "joker_dans_les_origines", + "origines_vides_hors_local", + "samesite_none_sans_secure", + ], +) +def test_settings_refuses_to_build_when_the_configuration_is_unsafe( + surcharges: dict[str, object], +) -> None: + with pytest.raises(ValidationError): + make_settings(**surcharges) + + +def test_settings_accepts_debug_in_local_environment() -> None: + settings = make_settings(env="local", debug=True) + + assert settings.debug is True + + +@pytest.mark.parametrize( + ("env", "attendu"), + [("local", False), ("dev", True), ("staging", True), ("prod", True)], + ids=["local", "dev", "staging", "production"], +) +def test_cookies_are_secure_follows_the_environment(env: str, attendu: bool) -> None: + settings = make_settings(env=env, cors_origins="https://enervision.fr") + + assert settings.cookies_are_secure is attendu + + +def test_cookies_are_secure_honours_an_explicit_override() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr", cookie_secure=False) + + assert settings.cookies_are_secure is False + + +@pytest.mark.parametrize( + ("env", "attendu"), + [("local", True), ("dev", True), ("staging", False), ("prod", False)], + ids=["local", "dev", "staging", "production"], +) +def test_api_docs_are_exposed_closes_staging_and_production(env: str, attendu: bool) -> None: + settings = make_settings(env=env, cors_origins="https://enervision.fr") + + assert settings.api_docs_are_exposed is attendu + + +def test_api_docs_are_exposed_honours_an_explicit_override() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr", expose_api_docs=True) + + assert settings.api_docs_are_exposed is True + + +def test_allowed_origins_splits_and_trims_the_list() -> None: + settings = make_settings(cors_origins=" http://localhost:4200 , https://enervision.fr ") + + assert settings.allowed_origins == ["http://localhost:4200", "https://enervision.fr"] diff --git a/apps/backend/tests/core/test_cookies.py b/apps/backend/tests/core/test_cookies.py new file mode 100644 index 0000000..7454e47 --- /dev/null +++ b/apps/backend/tests/core/test_cookies.py @@ -0,0 +1,58 @@ +from app.core.cookies import RefreshCookie, cookie_name +from tests.factories import make_settings + + +def test_build_marks_the_cookie_http_only_and_scopes_it_to_the_auth_routes() -> None: + settings = make_settings(env="local") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.httponly is True + assert cookie.samesite == "strict" + assert cookie.path == "/api/v1/auth" + assert cookie.max_age == settings.refresh_token_ttl_seconds + + +def test_build_prefixes_and_secures_the_cookie_outside_local() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.secure is True + assert cookie.key.startswith("__Secure-") + + +def test_build_leaves_the_cookie_unprefixed_in_local() -> None: + settings = make_settings(env="local") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.key == "ev_refresh" + + +def test_expired_reuses_the_exact_name_and_path_of_the_posted_cookie() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr") + + pose = RefreshCookie.build(settings, "un-secret-opaque") + suppression = RefreshCookie.expired(settings) + + assert suppression.key == pose.key + assert suppression.path == pose.path + assert suppression.secure == pose.secure + assert suppression.samesite == pose.samesite + assert suppression.max_age == 0 + assert suppression.value == "" + + +def test_as_kwargs_matches_the_starlette_set_cookie_signature() -> None: + settings = make_settings(env="local") + + arguments = RefreshCookie.build(settings, "un-secret-opaque").as_kwargs() + + assert set(arguments) == {"key", "value", "max_age", "path", "secure", "httponly", "samesite"} + + +def test_cookie_name_follows_the_configured_name() -> None: + settings = make_settings(env="local", refresh_cookie_name="autre_nom") + + assert cookie_name(settings) == "autre_nom" diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 3098863..17433c5 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -7,7 +7,7 @@ SETTINGS_DE_TEST: dict[str, Any] = { "debug": False, "log_level": "WARNING", "cors_origins": "", - "secret_key": "secret-de-test", + "secret_key": "secret-de-test-assez-long-pour-le-validateur", "database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test", } From ef933bea1ab59623ad63e5b0cfe27926619c3883 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:41:25 +0200 Subject: [PATCH 029/205] =?UTF-8?q?feat(backend):=20authentifie=20par=20mo?= =?UTF-8?q?t=20de=20passe=20et=20refuse=20les=20routes=20par=20d=C3=A9faut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connexion, lecture du compte connecté, RBAC à trois rôles ordonnés et limitation de débit à fenêtre glissante. Ajoute `login_attempt`, le compteur de la limitation, et `audit_log`, en ajout seul. Trois ordres d'exécution portent la sécurité de ce commit, et chacun a son test : - les compteurs sont lus AVANT le hachage Argon2, sinon chaque requête rejetée coûterait quand même 17 ms et 19 Mio, et la protection serait l'amplificateur de déni de service qu'elle doit empêcher ; - un haché leurre est vérifié quand l'adresse est inconnue, sinon l'écart entre 2 ms et 17 ms est un oracle d'existence de compte ; - la tentative échouée est validée en base avant que l'erreur ne soit levée, `get_session()` ne validant pas de lui-même. Pas de verrouillage de compte : il suffirait de cinq requêtes pour mettre un administrateur dehors, et il ne fait rien contre le bourrage d'identifiants horizontal. Trois seuils le remplacent, dont un par couple (identifiant, IP) qui garantit qu'un attaquant ne peut pas empêcher la victime de se connecter depuis sa propre adresse. `audit_log` est en ajout seul au niveau de PostgreSQL, par deux déclencheurs. Le second n'est pas redondant : TRUNCATE ne passe pas par les déclencheurs de ligne. `test_route_protection.py` interroge réellement chaque route sans jeton. Rendre une route publique impose donc de modifier une liste dans un fichier de test, ce qui se voit en revue. Le gestionnaire de 422 arrive ici et non plus tard : la réponse par défaut de FastAPI contient la valeur rejetée, donc le mot de passe. Le test qui le prouve serait rouge sans lui. --- ...tatives_de_connexion_et_journal_d_audit.py | 119 +++++++++ apps/backend/app/api/deps.py | 166 ++++++++++++- apps/backend/app/api/errors.py | 47 ++++ apps/backend/app/api/v1/endpoints/auth.py | 56 +++++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/main.py | 3 + apps/backend/app/models/__init__.py | 4 +- apps/backend/app/models/audit_log.py | 64 +++++ apps/backend/app/models/login_attempt.py | 44 ++++ apps/backend/app/repositories/audit_log.py | 62 +++++ .../backend/app/repositories/login_attempt.py | 67 +++++ apps/backend/app/repositories/user.py | 6 +- apps/backend/app/schemas/auth.py | 44 ++++ apps/backend/app/services/auth.py | 171 +++++++++++++ apps/backend/pyproject.toml | 2 +- apps/backend/tests/api/test_auth.py | 106 ++++++++ apps/backend/tests/api/test_authorization.py | 113 +++++++++ .../tests/api/test_route_protection.py | 74 ++++++ .../tests/repositories/test_audit_log.py | 125 ++++++++++ .../tests/repositories/test_login_attempt.py | 118 +++++++++ apps/backend/tests/repositories/test_user.py | 196 +++++++++++++++ apps/backend/tests/services/test_auth.py | 234 ++++++++++++++++++ apps/backend/tests/test_cli.py | 57 +++++ apps/backend/uv.lock | 31 ++- 24 files changed, 1904 insertions(+), 10 deletions(-) create mode 100644 apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py create mode 100644 apps/backend/app/api/errors.py create mode 100644 apps/backend/app/api/v1/endpoints/auth.py create mode 100644 apps/backend/app/models/audit_log.py create mode 100644 apps/backend/app/models/login_attempt.py create mode 100644 apps/backend/app/repositories/audit_log.py create mode 100644 apps/backend/app/repositories/login_attempt.py create mode 100644 apps/backend/app/schemas/auth.py create mode 100644 apps/backend/app/services/auth.py create mode 100644 apps/backend/tests/api/test_auth.py create mode 100644 apps/backend/tests/api/test_authorization.py create mode 100644 apps/backend/tests/api/test_route_protection.py create mode 100644 apps/backend/tests/repositories/test_audit_log.py create mode 100644 apps/backend/tests/repositories/test_login_attempt.py create mode 100644 apps/backend/tests/repositories/test_user.py create mode 100644 apps/backend/tests/services/test_auth.py create mode 100644 apps/backend/tests/test_cli.py diff --git a/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py new file mode 100644 index 0000000..59ffb85 --- /dev/null +++ b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py @@ -0,0 +1,119 @@ +"""tentatives de connexion et journal d audit + +Revision ID: 517053a3c044 +Revises: b1a7c3d9e240 +Create Date: 2026-09-15 14:31:07.966180 + +Deux tables aux vocations opposees. `login_attempt` est le compteur de la limitation +de debit : son volume est pilote par l'attaquant, donc elle se purge. `audit_log` est +en ajout seul, garanti par deux declencheurs. + +Le declencheur TRUNCATE n'est pas redondant : TRUNCATE ne passe pas par les +declencheurs de ligne. Et RAISE EXCEPTION plutot qu'un RETURN NULL, qui annulerait +l'operation silencieusement. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "517053a3c044" +down_revision: str | Sequence[str] | None = "b1a7c3d9e240" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +FONCTION_AJOUT_SEUL = """ +CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'audit_log est en ajout seul : % interdit', TG_OP; +END +$$ LANGUAGE plpgsql; +""" + +DECLENCHEUR_LIGNE = """ +CREATE TRIGGER audit_log_no_update_delete + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_append_only(); +""" + +DECLENCHEUR_TRUNCATE = """ +CREATE TRIGGER audit_log_no_truncate + BEFORE TRUNCATE ON audit_log + FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only(); +""" + + +def upgrade() -> None: + op.create_table( + "login_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=True), + sa.CheckConstraint( + "outcome in ('success', 'bad_credentials', 'throttled', 'inactive')", + name="ck_login_attempt_outcome", + ), + sa.PrimaryKeyConstraint("id", name="pk_login_attempt"), + ) + op.create_index( + "ix_login_attempt_email_date", "login_attempt", ["email_tried", "occurred_at"] + ) + op.create_index("ix_login_attempt_ip_date", "login_attempt", ["client_ip", "occurred_at"]) + + op.create_table( + "audit_log", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("actor_id", sa.UUID(), nullable=True), + sa.Column("actor_email", sa.Text(), nullable=True), + sa.Column("actor_role", sa.Text(), nullable=True), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("target_type", sa.Text(), nullable=True), + sa.Column("target_id", sa.Text(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column( + "detail", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("jsonb_build_object()"), + nullable=False, + ), + sa.CheckConstraint("outcome in ('success', 'failure')", name="ck_audit_log_outcome"), + sa.PrimaryKeyConstraint("id", name="pk_audit_log"), + ) + op.create_index("ix_audit_log_date", "audit_log", ["occurred_at"]) + op.create_index("ix_audit_log_action_date", "audit_log", ["action", "occurred_at"]) + + op.execute(FONCTION_AJOUT_SEUL) + op.execute(DECLENCHEUR_LIGNE) + op.execute(DECLENCHEUR_TRUNCATE) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS audit_log_no_truncate ON audit_log;") + op.execute("DROP TRIGGER IF EXISTS audit_log_no_update_delete ON audit_log;") + op.execute("DROP FUNCTION IF EXISTS audit_log_append_only();") + + op.drop_index("ix_audit_log_action_date", table_name="audit_log") + op.drop_index("ix_audit_log_date", table_name="audit_log") + op.drop_table("audit_log") + + op.drop_index("ix_login_attempt_ip_date", table_name="login_attempt") + op.drop_index("ix_login_attempt_email_date", table_name="login_attempt") + op.drop_table("login_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a25b1e1..a35d628 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,10 +1,174 @@ +# Piège : `get_current_principal()` relit le compte en base à chaque requête au lieu de faire +# confiance aux claims. C'est le renoncement assumé à la propriété « sans état » : sur un seul +# service et une seule base, elle n'achetait rien, et la lecture par clé primaire coûte moins +# d'un pour cent du budget d'une requête. Ce qu'elle achète, c'est la révocation immédiate. +# Piège : le `Principal` est construit depuis la ligne, jamais depuis le claim `role`. Un claim +# périmé ne peut donc pas provoquer d'élévation de privilège. + +from collections.abc import Callable +from datetime import timedelta +from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings +from app.core.hashing import Argon2Hasher, build_hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role, has_at_least +from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy +from app.core.security import decode_access_token as decode_token from app.db.session import get_session +from app.repositories.audit_log import AuditLogRepository +from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.user import UserRepository +from app.services.auth import AuthService, LoginPolicy SessionDep = Annotated[AsyncSession, Depends(get_session)] SettingsDep = Annotated[Settings, Depends(get_settings)] + +CODE_CHANGEMENT_REQUIS = "password_change_required" + +_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès") +CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)] + + +def _non_authentifie(description: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise", + headers={"WWW-Authenticate": f'Bearer error="{description}"'}, + ) + + +def get_token_policy(settings: SettingsDep) -> TokenPolicy: + return TokenPolicy( + secret=settings.secret_key.get_secret_value(), + issuer=settings.jwt_issuer, + audience=settings.jwt_audience, + access_ttl=timedelta(seconds=settings.access_token_ttl_seconds), + ) + + +# Construire un `Argon2Hasher` calcule un haché leurre, donc 17 ms : il est mis en cache sur +# les paramètres plutôt que reconstruit à chaque requête. +@lru_cache +def _hasher_cache( + time_cost: int, memory_cost_kib: int, parallelism: int, max_concurrency: int +) -> Argon2Hasher: + return build_hasher( + time_cost=time_cost, + memory_cost_kib=memory_cost_kib, + parallelism=parallelism, + max_concurrency=max_concurrency, + ) + + +def get_hasher(settings: SettingsDep) -> Argon2Hasher: + return _hasher_cache( + settings.argon2_time_cost, + settings.argon2_memory_cost_kib, + settings.argon2_parallelism, + settings.argon2_max_concurrency, + ) + + +def get_client_ip(request: Request, settings: SettingsDep) -> str | None: + # Derrière un proxy, `request.client.host` vaut l'IP du proxy : le compteur par IP + # deviendrait global, donc un déni de service auto-infligé. Le dernier élément est le seul + # qu'un proxy de confiance ait écrit, les précédents sont fournis par le client. + if settings.trust_proxy_headers: + transmis = request.headers.get("x-forwarded-for") + if transmis: + return transmis.split(",")[-1].strip() + return request.client.host if request.client else None + + +def get_auth_service( + session: SessionDep, + settings: SettingsDep, + hasher: Annotated[Argon2Hasher, Depends(get_hasher)], + token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], +) -> AuthService: + return AuthService( + users=UserRepository(session), + attempts=LoginAttemptRepository(session), + audit=AuditLogRepository(session), + hasher=hasher, + transaction=session, + token_policy=token_policy, + login_policy=LoginPolicy( + window_seconds=settings.login_window_seconds, + max_failures_per_identifier_and_ip=(settings.login_max_failures_per_identifier_and_ip), + max_failures_per_ip=settings.login_max_failures_per_ip, + max_failures_per_identifier=settings.login_max_failures_per_identifier, + ), + ) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] + + +async def get_current_principal( + credentials: CredentialsDep, + session: SessionDep, + token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], +) -> Principal: + if credentials is None: + raise _non_authentifie("invalid_request") + + try: + claims = decode_token(token_policy, credentials.credentials) + except TokenExpiredError as erreur: + raise _non_authentifie("expired") from erreur + except TokenInvalidError as erreur: + raise _non_authentifie("invalid_token") from erreur + + compte = await UserRepository(session).get_by_id(claims.subject) + if compte is None or not compte.is_active: + raise _non_authentifie("invalid_token") + if claims.issued_at < compte.credentials_changed_at: + raise _non_authentifie("token_stale") + if claims.role != compte.role: + raise _non_authentifie("token_stale") + + return Principal( + id=compte.id, + email=compte.email, + role=Role(compte.role), + kind=AccountKind(compte.kind), + must_change_password=compte.must_change_password, + ) + + +CurrentPrincipalDep = Annotated[Principal, Depends(get_current_principal)] + + +def require_role(minimum: Role) -> Callable[[Principal], Principal]: + def garde(principal: CurrentPrincipalDep) -> Principal: + if principal.must_change_password: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=CODE_CHANGEMENT_REQUIS + ) + if not has_at_least(principal.role, minimum): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Droits insuffisants") + return principal + + return garde + + +LecteurDep = Annotated[Principal, Depends(require_role(Role.LECTEUR))] +OperateurDep = Annotated[Principal, Depends(require_role(Role.OPERATEUR))] +AdminDep = Annotated[Principal, Depends(require_role(Role.ADMIN))] + + +def require_trusted_origin(request: Request, settings: SettingsDep) -> None: + # Un navigateur envoie toujours `Origin` sur une requête non sûre. Son absence signale un + # client hors navigateur, qui ne détient aucun cookie de victime : rien à protéger. + origine = request.headers.get("origin") + if origine is None: + return + if origine not in settings.allowed_origins: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Origine refusée") diff --git a/apps/backend/app/api/errors.py b/apps/backend/app/api/errors.py new file mode 100644 index 0000000..7485b55 --- /dev/null +++ b/apps/backend/app/api/errors.py @@ -0,0 +1,47 @@ +# Piège : la réponse 422 par défaut de FastAPI contient la clé `input`, c'est-à-dire la valeur +# rejetée. Sur `/auth/login`, un corps malformé renverrait donc le mot de passe au client et le +# déposerait dans les journaux d'erreur. `validation_error_handler()` ne laisse passer que le +# champ fautif et le type d'erreur. + +import uuid +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +async def validation_error_handler(_: Request, exception: RequestValidationError) -> JSONResponse: + champs: list[dict[str, Any]] = [ + { + "champ": ".".join(str(element) for element in erreur["loc"]), + "type": erreur["type"], + } + for erreur in exception.errors() + ] + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": champs} + ) + + +async def unhandled_error_handler(request: Request, exception: Exception) -> JSONResponse: + correlation = uuid.uuid4().hex + logger.exception( + "erreur non gérée correlation=%s methode=%s chemin=%s", + correlation, + request.method, + request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Erreur interne", "correlation": correlation}, + ) + + +def register_error_handlers(application: FastAPI) -> None: + application.add_exception_handler(RequestValidationError, validation_error_handler) # type: ignore[arg-type] + application.add_exception_handler(Exception, unhandled_error_handler) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..0def0d4 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.core.logging import get_logger +from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse +from app.services.auth import InvalidCredentialsError, RateLimitedError + +router = APIRouter() +logger = get_logger(__name__) + +DETAIL_IDENTIFIANTS = "Identifiants invalides" + + +@router.post("/login", response_model=TokenResponse, summary="Ouvre une session") +async def login( + payload: LoginRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. + response.headers["Cache-Control"] = "no-store" + agent = request.headers.get("user-agent") + + try: + session = await service.authenticate( + email=payload.email, + password=payload.password, + client_ip=client_ip, + user_agent=agent, + ) + except RateLimitedError as erreur: + logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de tentatives, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + except InvalidCredentialsError as erreur: + logger.warning("auth.login.failure email=%s ip=%s", payload.email, client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS + ) from erreur + + logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") +async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: + return PrincipalResponse.from_principal(principal) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8571d8f..473a024 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.v1.endpoints import health +from app.api.v1.endpoints import auth, health api_router = APIRouter() -api_router.include_router(health.router, prefix="/health") +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 1008100..2ddf1dd 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_fastapi_instrumentator import Instrumentator +from app.api.errors import register_error_handlers from app.api.v1.router import api_router from app.core.config import Settings, get_settings from app.core.logging import configure_logging, get_logger @@ -46,6 +47,8 @@ def create_app(settings: Settings | None = None) -> FastAPI: allow_headers=["*"], ) + register_error_handlers(application) + Instrumentator().instrument(application).expose( application, endpoint="/metrics", include_in_schema=False ) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index dd6cc73..222295d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,6 +1,8 @@ # Piège : tout modèle absent de ce module reste invisible de `alembic revision # --autogenerate`, qui générerait alors un drop de sa table. +from app.models.audit_log import AuditLog +from app.models.login_attempt import LoginAttempt from app.models.user import AppUser -__all__ = ["AppUser"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt"] diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py new file mode 100644 index 0000000..5775f5e --- /dev/null +++ b/apps/backend/app/models/audit_log.py @@ -0,0 +1,64 @@ +# Pourquoi : `actor_id` ne porte volontairement aucune clé étrangère. Une contrainte +# `ON DELETE SET NULL` déclencherait un UPDATE que le déclencheur d'ajout seul refuserait, donc +# la suppression d'un compte échouerait ; une contrainte `NO ACTION` interdirait toute +# suppression. `actor_email` et `actor_role` sont dénormalisés pour la même raison : le journal +# dit ce qui était vrai au moment de l'acte, pas ce qui est vrai aujourd'hui. + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, Text, func +from sqlalchemy.dialects.postgresql import INET, JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuditOutcome(StrEnum): + SUCCES = "success" + ECHEC = "failure" + + +class AuditAction(StrEnum): + COMPTE_CREE = "user.created" + COMPTE_ROLE_CHANGE = "user.role_changed" + COMPTE_DESACTIVE = "user.disabled" + COMPTE_ACTIVE = "user.enabled" + COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" + COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + REFRESH_REUTILISE = "auth.refresh_reuse_detected" + SESSIONS_REVOQUEES = "auth.all_sessions_revoked" + LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" + ADMIN_AMORCE = "bootstrap.admin_created" + + +ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in AuditOutcome) + + +class AuditLog(Base): + __tablename__ = "audit_log" + __table_args__ = ( + CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_audit_log_outcome"), + Index("ix_audit_log_date", "occurred_at"), + Index("ix_audit_log_action_date", "action", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + actor_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + actor_email: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_role: Mapped[str | None] = mapped_column(Text, nullable=True) + action: Mapped[str] = mapped_column(Text, nullable=False) + target_type: Mapped[str | None] = mapped_column(Text, nullable=True) + target_id: Mapped[str | None] = mapped_column(Text, nullable=True) + outcome: Mapped[str] = mapped_column(Text, nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + detail: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, server_default=func.jsonb_build_object() + ) diff --git a/apps/backend/app/models/login_attempt.py b/apps/backend/app/models/login_attempt.py new file mode 100644 index 0000000..f4b7701 --- /dev/null +++ b/apps/backend/app/models/login_attempt.py @@ -0,0 +1,44 @@ +# Pourquoi : les tentatives vivent ici et non dans `audit_log`, qui est en ajout seul. Leur +# volume est piloté par l'attaquant : une force brute y écrirait des millions de lignes +# indestructibles. Cette table-ci se purge, et c'est aussi le compteur de la limitation. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 dirait +# qu'un compte existe. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, String, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class LoginOutcome(StrEnum): + SUCCES = "success" + IDENTIFIANTS_INVALIDES = "bad_credentials" + LIMITE = "throttled" + COMPTE_INDISPONIBLE = "inactive" + + +ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in LoginOutcome) + + +class LoginAttempt(Base): + __tablename__ = "login_attempt" + __table_args__ = ( + CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_login_attempt_outcome"), + Index("ix_login_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_login_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + outcome: Mapped[str] = mapped_column(Text, nullable=False) + user_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) diff --git a/apps/backend/app/repositories/audit_log.py b/apps/backend/app/repositories/audit_log.py new file mode 100644 index 0000000..aa00f72 --- /dev/null +++ b/apps/backend/app/repositories/audit_log.py @@ -0,0 +1,62 @@ +# Piège : `detail` passe par une liste blanche de clés et jamais par un `dict(**kwargs)`. La +# table est en ajout seul : une clé inattendue qui porterait un secret ou une donnée +# personnelle ne pourrait plus en être retirée. + +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.principal import Principal +from app.models.audit_log import AuditAction, AuditLog, AuditOutcome + +CLES_DE_DETAIL_AUTORISEES = frozenset( + { + "email", + "role_avant", + "role_apres", + "famille", + "motif", + "source", + "sessions_revoquees", + } +) + + +def assemble_detail(brut: Mapping[str, Any] | None) -> dict[str, Any]: + if not brut: + return {} + return {cle: valeur for cle, valeur in brut.items() if cle in CLES_DE_DETAIL_AUTORISEES} + + +class AuditLogRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( + self, + *, + action: AuditAction, + outcome: AuditOutcome = AuditOutcome.SUCCES, + actor: Principal | None = None, + actor_label: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + client_ip: str | None = None, + user_agent: str | None = None, + detail: Mapping[str, Any] | None = None, + ) -> None: + self._session.add( + AuditLog( + actor_id=actor.id if actor else None, + actor_email=actor.email if actor else actor_label, + actor_role=actor.role.value if actor else None, + action=action.value, + target_type=target_type, + target_id=target_id, + outcome=outcome.value, + client_ip=client_ip, + user_agent=user_agent, + detail=assemble_detail(detail), + ) + ) diff --git a/apps/backend/app/repositories/login_attempt.py b/apps/backend/app/repositories/login_attempt.py new file mode 100644 index 0000000..8f8df09 --- /dev/null +++ b/apps/backend/app/repositories/login_attempt.py @@ -0,0 +1,67 @@ +# Pourquoi : les trois compteurs tiennent en une seule requête, grâce aux clauses FILTER de +# PostgreSQL. Trois `count(*)` séparés feraient trois allers-retours sur le chemin critique de +# la connexion, qui est justement celui qu'un attaquant martèle. + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.login_attempt import LoginAttempt, LoginOutcome + + +@dataclass(frozen=True, slots=True) +class FailureCounts: + per_identifier_and_ip: int + per_ip: int + per_identifier: int + + +class LoginAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( + self, + *, + email: str, + client_ip: str | None, + outcome: LoginOutcome, + user_id: UUID | None = None, + ) -> None: + self._session.add( + LoginAttempt( + email_tried=email.strip().lower(), + client_ip=client_ip, + outcome=outcome.value, + user_id=user_id, + ) + ) + + async def count_recent_failures( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> FailureCounts: + identifiant = email.strip().lower() + meme_email = LoginAttempt.email_tried == identifiant + meme_ip = LoginAttempt.client_ip == client_ip + + requete = select( + func.count().filter(and_(meme_email, meme_ip)), + func.count().filter(meme_ip), + func.count().filter(meme_email), + ).where( + LoginAttempt.outcome != LoginOutcome.SUCCES.value, + LoginAttempt.occurred_at > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant_et_ip, par_ip, par_identifiant = ( + await self._session.execute(requete) + ).one() + return FailureCounts( + per_identifier_and_ip=par_identifiant_et_ip, + per_ip=par_ip, + per_identifier=par_identifiant, + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py index 9155db1..eaac079 100644 --- a/apps/backend/app/repositories/user.py +++ b/apps/backend/app/repositories/user.py @@ -67,7 +67,7 @@ class UserRepository: .values( password_hash=password_hash, must_change_password=must_change_password, - credentials_changed_at=func.now(), + credentials_changed_at=func.clock_timestamp(), ) ) @@ -86,12 +86,12 @@ class UserRepository: await self._session.execute( update(AppUser) .where(AppUser.id == user_id) - .values(role=role.value, credentials_changed_at=func.now()) + .values(role=role.value, credentials_changed_at=func.clock_timestamp()) ) async def set_active(self, user_id: UUID, *, is_active: bool) -> None: await self._session.execute( update(AppUser) .where(AppUser.id == user_id) - .values(is_active=is_active, credentials_changed_at=func.now()) + .values(is_active=is_active, credentials_changed_at=func.clock_timestamp()) ) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 0000000..522b4c5 --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -0,0 +1,44 @@ +# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix +# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. + +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role + +PASSWORD_MIN_LENGTH = 12 +PASSWORD_MAX_LENGTH = 128 + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) + + +class PasswordChangeRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + +class PrincipalResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + role: Role + kind: AccountKind + must_change_password: bool + + @classmethod + def from_principal(cls, principal: Principal) -> Self: + return cls.model_validate(principal) + + +class TokenResponse(BaseModel): + access_token: str + token_type: Literal["bearer"] = "bearer" # noqa: S105 + expires_in: int + principal: PrincipalResponse diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py new file mode 100644 index 0000000..19a4e26 --- /dev/null +++ b/apps/backend/app/services/auth.py @@ -0,0 +1,171 @@ +# Piège : les compteurs de limitation sont lus AVANT le hachage Argon2. Dans l'autre ordre, +# chaque requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la +# protection deviendrait l'amplificateur de déni de service qu'elle est censée empêcher. +# Piège : quand l'email est inconnu, `verify_dummy()` consomme le même temps qu'une +# vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. +# Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. +# `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. + +from dataclasses import dataclass +from typing import NoReturn, Protocol +from uuid import UUID + +from app.core.hashing import Argon2Hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import TokenPolicy, encode_access_token +from app.models.audit_log import AuditAction +from app.models.login_attempt import LoginOutcome +from app.repositories.audit_log import AuditLogRepository +from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.user import UserRepository + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class AuthError(Exception): + pass + + +class InvalidCredentialsError(AuthError): + pass + + +class RateLimitedError(AuthError): + def __init__(self, retry_after: int) -> None: + super().__init__("Trop de tentatives") + self.retry_after = retry_after + + +@dataclass(frozen=True, slots=True) +class LoginPolicy: + window_seconds: int + max_failures_per_identifier_and_ip: int + max_failures_per_ip: int + max_failures_per_identifier: int + + +@dataclass(frozen=True, slots=True) +class AuthenticatedSession: + principal: Principal + access_token: str + expires_in: int + + +class AuthService: + def __init__( + self, + *, + users: UserRepository, + attempts: LoginAttemptRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + token_policy: TokenPolicy, + login_policy: LoginPolicy, + ) -> None: + self._users = users + self._attempts = attempts + self._audit = audit + self._hasher = hasher + self._transaction = transaction + self._token_policy = token_policy + self._login_policy = login_policy + + async def authenticate( + self, *, email: str, password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + await self._refuse_si_limite(email=email, client_ip=client_ip, user_agent=user_agent) + + compte = await self._users.get_by_email(email) + if compte is None: + await self._hasher.verify_dummy() + await self._echoue(email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES) + + if not await self._hasher.verify(compte.password_hash, password): + await self._echoue( + email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES, user_id=compte.id + ) + + if not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._echoue( + email, client_ip, LoginOutcome.COMPTE_INDISPONIBLE, user_id=compte.id + ) + + if self._hasher.needs_rehash(compte.password_hash): + await self._users.rehash_password(compte.id, await self._hasher.hash(password)) + + await self._users.touch_last_login(compte.id) + await self._attempts.record( + email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id + ) + await self._transaction.commit() + + return self.issue_access_token( + Principal( + id=compte.id, + email=compte.email, + role=Role(compte.role), + kind=AccountKind(compte.kind), + must_change_password=compte.must_change_password, + ) + ) + + def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + jeton = encode_access_token( + self._token_policy, + subject=principal.id, + role=principal.role.value, + kind=principal.kind.value, + ) + return AuthenticatedSession( + principal=principal, + access_token=jeton, + expires_in=int(self._token_policy.access_ttl.total_seconds()), + ) + + async def _refuse_si_limite( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + politique = self._login_policy + compteurs = await self._attempts.count_recent_failures( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier_and_ip >= politique.max_failures_per_identifier_and_ip + or compteurs.per_ip >= politique.max_failures_per_ip + or compteurs.per_identifier >= politique.max_failures_per_identifier + ) + if not depasse: + return + + await self._attempts.record(email=email, client_ip=client_ip, outcome=LoginOutcome.LIMITE) + # Un blocage déclenché par l'identifiant seul signe une attaque distribuée : lui seul + # mérite une trace durable, les échecs ordinaires restent dans `login_attempt`. + if compteurs.per_identifier >= politique.max_failures_per_identifier: + await self._audit.record( + action=AuditAction.LIMITE_PAR_IDENTIFIANT, + actor_label=email.strip().lower(), + client_ip=client_ip, + user_agent=user_agent, + detail={"motif": "seuil par identifiant depasse"}, + ) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + + async def _echoue( + self, + email: str, + client_ip: str | None, + outcome: LoginOutcome, + *, + user_id: UUID | None = None, + ) -> NoReturn: + await self._attempts.record( + email=email, client_ip=client_ip, outcome=outcome, user_id=user_id + ) + await self._transaction.commit() + raise InvalidCredentialsError("Identifiants invalides") diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 684524d..f0e4f22 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.14,<3.15" dependencies = [ "fastapi>=0.141.1", "uvicorn[standard]>=0.53.0", - "pydantic>=2.13.5", + "pydantic[email]>=2.13.5", "pydantic-settings>=2.15.0", "sqlalchemy[asyncio]>=2.0.52", "asyncpg>=0.31.0", diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py new file mode 100644 index 0000000..08ceddd --- /dev/null +++ b/apps/backend/tests/api/test_auth.py @@ -0,0 +1,106 @@ +from collections.abc import Iterator +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_auth_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + RateLimitedError, +) + +IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"} + +PRINCIPAL = Principal( + id=uuid4(), + email="operateur@enervision.fr", + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + + async def authenticate(self, **_: object) -> AuthenticatedSession: + if self._erreur is not None: + raise self._erreur + return AuthenticatedSession( + principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900 + ) + + +@pytest.fixture +def fake_auth_service(app: FastAPI) -> Iterator[list[Exception | None]]: + programme: list[Exception | None] = [None] + app.dependency_overrides[get_auth_service] = lambda: FauxService(programme[0]) + yield programme + app.dependency_overrides.pop(get_auth_service, None) + + +async def test_login_returns_the_token_and_the_principal_when_credentials_match( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.status_code == 200 + corps = response.json() + assert corps["access_token"] == "un.jeton.factice" + assert corps["token_type"] == "bearer" + assert corps["principal"]["role"] == "operateur" + + +async def test_login_forbids_intermediaries_from_caching_the_response( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.headers["cache-control"] == "no-store" + + +async def test_login_never_reveals_which_half_of_the_credentials_was_wrong( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = InvalidCredentialsError("Identifiants invalides") + + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.status_code == 401 + assert response.json() == {"detail": "Identifiants invalides"} + + +async def test_login_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +@pytest.mark.parametrize( + "corps", + [ + {"email": "pas-une-adresse", "password": "un-mot-de-passe-valide"}, + {"email": "operateur@enervision.fr"}, + {"email": "operateur@enervision.fr", "password": "x" * 129}, + ], + ids=["adresse_invalide", "mot_de_passe_absent", "mot_de_passe_trop_long"], +) +async def test_login_rejects_a_malformed_body_without_echoing_the_password( + fake_auth_service: list[Exception | None], client: AsyncClient, corps: dict[str, str] +) -> None: + response = await client.post("/api/v1/auth/login", json=corps) + + assert response.status_code == 422 + assert "un-mot-de-passe-valide" not in response.text + assert "x" * 129 not in response.text diff --git a/apps/backend/tests/api/test_authorization.py b/apps/backend/tests/api/test_authorization.py new file mode 100644 index 0000000..05c9e28 --- /dev/null +++ b/apps/backend/tests/api/test_authorization.py @@ -0,0 +1,113 @@ +from collections.abc import Callable, Iterator +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import AdminDep, get_current_principal, require_role +from app.core.principal import Principal +from app.core.roles import AccountKind, Role + +CHEMIN_ADMIN = "/api/v1/essai-admin" + + +def principal(role: Role = Role.LECTEUR, *, must_change_password: bool = False) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=must_change_password, + ) + + +@pytest.fixture +def route_admin(app: FastAPI) -> None: + @app.get(CHEMIN_ADMIN) + async def _reserve_aux_admins(acteur: AdminDep) -> dict[str, str]: + return {"email": acteur.email} + + +@pytest.fixture +def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]: + def installe(acteur: Principal) -> None: + app.dependency_overrides[get_current_principal] = lambda: acteur + + yield installe + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_me_returns_401_when_no_credentials_are_sent(client: AsyncClient) -> None: + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 401 + assert "Bearer" in response.headers["www-authenticate"] + + +async def test_me_returns_401_when_the_token_is_not_readable(client: AsyncClient) -> None: + response = await client.get( + "/api/v1/auth/me", headers={"Authorization": "Bearer nimporte.quoi.ici"} + ) + + assert response.status_code == 401 + assert 'error="invalid_token"' in response.headers["www-authenticate"] + + +async def test_me_describes_the_connected_account( + connecte: Callable[[Principal], None], client: AsyncClient +) -> None: + acteur = principal(Role.OPERATEUR) + connecte(acteur) + + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 200 + assert response.json()["email"] == acteur.email + + +@pytest.mark.parametrize( + ("role", "attendu"), + [(Role.LECTEUR, 403), (Role.OPERATEUR, 403), (Role.ADMIN, 200)], + ids=["lecteur_refuse", "operateur_refuse", "admin_accepte"], +) +async def test_an_admin_route_only_answers_to_an_admin( + route_admin: None, + connecte: Callable[[Principal], None], + client: AsyncClient, + role: Role, + attendu: int, +) -> None: + connecte(principal(role)) + + response = await client.get(CHEMIN_ADMIN) + + assert response.status_code == attendu + + +async def test_a_pending_password_change_blocks_every_business_route( + route_admin: None, connecte: Callable[[Principal], None], client: AsyncClient +) -> None: + connecte(principal(Role.ADMIN, must_change_password=True)) + + response = await client.get(CHEMIN_ADMIN) + + assert response.status_code == 403 + assert response.json()["detail"] == "password_change_required" + + +async def test_a_pending_password_change_still_allows_reading_ones_own_account( + connecte: Callable[[Principal], None], client: AsyncClient +) -> None: + connecte(principal(Role.LECTEUR, must_change_password=True)) + + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 200 + assert response.json()["must_change_password"] is True + + +def test_require_role_builds_one_guard_per_minimum_level() -> None: + garde = require_role(Role.OPERATEUR) + + assert callable(garde) diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py new file mode 100644 index 0000000..bfdd2fe --- /dev/null +++ b/apps/backend/tests/api/test_route_protection.py @@ -0,0 +1,74 @@ +# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier +# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et +# demande une justification au relecteur. +# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de +# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route +# peut porter la bonne dépendance tout en répondant quand même. + +from typing import Any + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +ROUTES_PUBLIQUES = frozenset( + { + ("GET", "/api/v1/health/live"), + ("GET", "/api/v1/health/ready"), + ("POST", "/api/v1/auth/login"), + ("GET", "/metrics"), + } +) + +VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000" +STATUTS_DE_REFUS = {401, 403} + + +def routes_declarees(app: FastAPI) -> list[tuple[str, str]]: + schema: dict[str, Any] = app.openapi() + return [ + (methode.upper(), chemin) + for chemin, operations in schema["paths"].items() + for methode in operations + if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} + ] + + +def routes_protegees(app: FastAPI) -> list[tuple[str, str]]: + return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES] + + +def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None: + declarees = set(routes_declarees(app)) | {("GET", "/metrics")} + + inconnues = ROUTES_PUBLIQUES - declarees + + assert inconnues == set() + + +async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public( + app: FastAPI, client: AsyncClient +) -> None: + ouvertes: list[tuple[str, str, int]] = [] + + for methode, chemin in routes_protegees(app): + concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION) + response = await client.request(methode, concret, json={}) + if response.status_code not in STATUTS_DE_REFUS: + ouvertes.append((methode, chemin, response.status_code)) + + assert ouvertes == [] + + +async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None: + assert ("POST", "/api/v1/auth/login") in routes_declarees(app) + assert ("GET", "/api/v1/auth/me") in routes_declarees(app) + + +@pytest.mark.parametrize( + "chemin", + ["/api/v1/health/live", "/api/v1/health/ready"], + ids=["sonde_de_vie", "sonde_de_disponibilite"], +) +def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None: + assert ("GET", chemin) in ROUTES_PUBLIQUES diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py new file mode 100644 index 0000000..1c6fc64 --- /dev/null +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -0,0 +1,125 @@ +# Les trois refus ci-dessous sont la preuve que l'ajout seul est une propriété de la base et +# non une convention de code Python. Ce sont eux qu'il faut montrer, pas la classe du dépôt. + +import uuid + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.audit_log import AuditAction, AuditOutcome +from app.repositories.audit_log import ( + CLES_DE_DETAIL_AUTORISEES, + AuditLogRepository, + assemble_detail, +) + +pytestmark = pytest.mark.integration + +ACTEUR = Principal( + id=uuid.uuid4(), + email="admin@enervision.fr", + role=Role.ADMIN, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +async def une_ligne(session: AsyncSession) -> None: + await AuditLogRepository(session).record( + action=AuditAction.COMPTE_CREE, actor=ACTEUR, target_type="app_user", target_id="x" + ) + await session.flush() + + +@pytest.mark.parametrize( + "instruction", + [ + "update audit_log set action = 'falsifie'", + "delete from audit_log", + "truncate audit_log", + ], + ids=["modification", "suppression", "vidage"], +) +async def test_the_database_refuses_to_mutate_the_audit_log( + session: AsyncSession, instruction: str +) -> None: + await une_ligne(session) + + with pytest.raises(DBAPIError, match="ajout seul"): + await session.execute(text(instruction)) + await session.rollback() + + +async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None: + depot = AuditLogRepository(session) + + await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR) + await session.flush() + ligne = ( + await session.execute( + text("select actor_id, actor_email, actor_role, outcome from audit_log") + ) + ).one() + await session.rollback() + + assert ligne.actor_id == ACTEUR.id + assert ligne.actor_email == ACTEUR.email + assert ligne.actor_role == Role.ADMIN.value + assert ligne.outcome == AuditOutcome.SUCCES.value + + +async def test_record_accepts_a_label_when_there_is_no_authenticated_actor( + session: AsyncSession, +) -> None: + depot = AuditLogRepository(session) + + await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli") + await session.flush() + ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one() + await session.rollback() + + assert ligne.actor_id is None + assert ligne.actor_email == "cli" + + +async def test_record_drops_the_detail_keys_outside_the_allow_list( + session: AsyncSession, +) -> None: + depot = AuditLogRepository(session) + + await depot.record( + action=AuditAction.COMPTE_ROLE_CHANGE, + actor=ACTEUR, + detail={"role_avant": "lecteur", "mot_de_passe": "ne-doit-pas-passer"}, + ) + await session.flush() + detail = (await session.execute(text("select detail from audit_log"))).scalar_one() + await session.rollback() + + assert detail == {"role_avant": "lecteur"} + + +@pytest.mark.parametrize( + ("brut", "attendu"), + [ + (None, {}), + ({}, {}), + ({"motif": "reutilisation"}, {"motif": "reutilisation"}), + ({"password": "x"}, {}), + ], + ids=["absent", "vide", "cle_autorisee", "cle_refusee"], +) +def test_assemble_detail_only_keeps_the_allowed_keys( + brut: dict[str, str] | None, attendu: dict[str, str] +) -> None: + assert assemble_detail(brut) == attendu + + +def test_the_allow_list_never_mentions_a_secret() -> None: + suspects = {"password", "mot_de_passe", "token", "jeton", "secret", "hash"} + + assert CLES_DE_DETAIL_AUTORISEES & suspects == set() diff --git a/apps/backend/tests/repositories/test_login_attempt.py b/apps/backend/tests/repositories/test_login_attempt.py new file mode 100644 index 0000000..7ac620a --- /dev/null +++ b/apps/backend/tests/repositories/test_login_attempt.py @@ -0,0 +1,118 @@ +import uuid + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.login_attempt import LoginOutcome +from app.repositories.login_attempt import LoginAttemptRepository + +pytestmark = pytest.mark.integration + +IP = "203.0.113.10" +AUTRE_IP = "198.51.100.7" + + +def adresse() -> str: + return f"tentative-{uuid.uuid4().hex[:12]}@enervision.fr" + + +async def echoue( + depot: LoginAttemptRepository, email: str, ip: str | None, combien: int = 1 +) -> None: + for _ in range(combien): + await depot.record(email=email, client_ip=ip, outcome=LoginOutcome.IDENTIFIANTS_INVALIDES) + + +async def test_count_recent_failures_separates_the_three_counters( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible, voisin = adresse(), adresse() + await echoue(depot, cible, IP, combien=3) + await echoue(depot, cible, AUTRE_IP, combien=2) + await echoue(depot, voisin, IP, combien=4) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 3 + assert compteurs.per_identifier == 5 + assert compteurs.per_ip == 7 + + +async def test_count_recent_failures_ignores_successful_attempts( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible, IP, combien=2) + await depot.record(email=cible, client_ip=IP, outcome=LoginOutcome.SUCCES) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 2 + + +async def test_count_recent_failures_forgets_what_falls_outside_the_window( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible, IP, combien=2) + await session.flush() + await session.execute( + text( + "update login_attempt set occurred_at = now() - interval '2 hours' " + "where email_tried = :e" + ), + {"e": cible}, + ) + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 0 + + +async def test_count_recent_failures_still_counts_when_the_address_is_unknown( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + inconnu = adresse() + await echoue(depot, inconnu, IP, combien=5) + await session.flush() + + compteurs = await depot.count_recent_failures(email=inconnu, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 5 + + +async def test_record_normalises_the_address_before_counting(session: AsyncSession) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible.upper(), IP, combien=2) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 2 + + +async def test_count_recent_failures_tolerates_a_missing_client_address( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible, None, combien=2) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=None, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier == 2 diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py new file mode 100644 index 0000000..0701a2d --- /dev/null +++ b/apps/backend/tests/repositories/test_user.py @@ -0,0 +1,196 @@ +import uuid + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import AccountKind, Role +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + + +def adresse() -> str: + return f"compte-{uuid.uuid4().hex[:12]}@enervision.fr" + + +async def test_create_normalises_the_email_to_lower_case(session: AsyncSession) -> None: + depot = UserRepository(session) + saisie = adresse().upper() + + compte = await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR) + enregistre = compte.email + await session.rollback() + + assert enregistre == saisie.lower() + + +async def test_the_database_refuses_an_email_written_in_upper_case( + session: AsyncSession, +) -> None: + saisie = adresse().upper() + + 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.rollback() + + +async def test_the_database_refuses_two_accounts_sharing_an_email( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + saisie = adresse() + + await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR) + + with pytest.raises(IntegrityError): + await depot.create(email=saisie, password_hash="$argon2id$y", role=Role.ADMIN) + await session.rollback() + + +async def test_get_by_email_is_case_insensitive(session: AsyncSession) -> None: + depot = UserRepository(session) + saisie = adresse() + await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.OPERATEUR) + + trouve = await depot.get_by_email(saisie.upper()) + role = trouve.role if trouve else None + await session.rollback() + + assert role == Role.OPERATEUR.value + + +async def test_get_by_email_returns_nothing_for_an_unknown_address( + session: AsyncSession, +) -> None: + trouve = await UserRepository(session).get_by_email(adresse()) + + assert trouve is None + + +async def test_set_role_moves_the_credentials_marker_forward(session: AsyncSession) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.set_role(compte.id, Role.ADMIN) + await session.refresh(compte) + apres, role = compte.credentials_changed_at, compte.role + await session.rollback() + + assert role == Role.ADMIN.value + assert apres > avant + + +async def test_set_active_moves_the_credentials_marker_forward(session: AsyncSession) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.set_active(compte.id, is_active=False) + await session.refresh(compte) + apres, actif = compte.credentials_changed_at, compte.is_active + await session.rollback() + + assert actif is False + assert apres > avant + + +async def test_rehash_password_leaves_the_credentials_marker_untouched( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.rehash_password(compte.id, "$argon2id$plus-recent") + await session.refresh(compte) + apres, empreinte = compte.credentials_changed_at, compte.password_hash + await session.rollback() + + assert empreinte == "$argon2id$plus-recent" + assert apres == avant + + +async def test_update_password_moves_the_credentials_marker_forward( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.update_password(compte.id, "$argon2id$neuf", must_change_password=False) + await session.refresh(compte) + apres = compte.credentials_changed_at + await session.rollback() + + assert apres > avant + + +async def test_touch_last_login_records_the_connection_date(session: AsyncSession) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + + await depot.touch_last_login(compte.id) + await session.refresh(compte) + date = compte.last_login_at + await session.rollback() + + assert date is not None + + +async def test_count_active_admins_only_counts_enabled_administrators( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + depart = await depot.count_active_admins() + + await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN) + desactive = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN) + await depot.set_active(desactive.id, is_active=False) + total = await depot.count_active_admins() + await session.rollback() + + assert total == depart + 1 + + +async def test_create_accepts_a_service_account(session: AsyncSession) -> None: + depot = UserRepository(session) + + compte = await depot.create( + email=adresse(), + password_hash="$argon2id$x", + role=Role.OPERATEUR, + kind=AccountKind.SERVICE, + ) + nature = compte.kind + await session.rollback() + + assert nature == AccountKind.SERVICE.value + + +async def test_list_all_returns_the_accounts_sorted_by_email(session: AsyncSession) -> None: + depot = UserRepository(session) + await depot.create(email=f"zz-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR) + await depot.create(email=f"aa-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR) + + comptes = await depot.list_all() + emails = [compte.email for compte in comptes] + await session.rollback() + + assert emails == sorted(emails) + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await UserRepository(session).get_by_id(uuid.uuid4()) + + assert trouve is None diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py new file mode 100644 index 0000000..3b8902f --- /dev/null +++ b/apps/backend/tests/services/test_auth.py @@ -0,0 +1,234 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from app.core.security import TokenPolicy, decode_access_token +from app.models.login_attempt import LoginOutcome +from app.repositories.login_attempt import FailureCounts +from app.services.auth import ( + AuthService, + InvalidCredentialsError, + LoginPolicy, + RateLimitedError, +) + +POLITIQUE_JETON = TokenPolicy( + secret="un-secret-de-test-de-plus-de-trente-deux-caracteres", + issuer="enervision-api", + audience="enervision-web", + access_ttl=timedelta(minutes=15), +) +POLITIQUE_CONNEXION = LoginPolicy( + window_seconds=900, + max_failures_per_identifier_and_ip=5, + max_failures_per_ip=20, + max_failures_per_identifier=50, +) + + +@dataclass +class FauxCompte: + id: UUID = field(default_factory=uuid4) + email: str = "operateur@enervision.fr" + password_hash: str = "$argon2id$factice" + role: str = "operateur" + kind: str = "human" + is_active: bool = True + must_change_password: bool = False + credentials_changed_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class FauxDepotComptes: + def __init__(self, compte: FauxCompte | None) -> None: + self.compte = compte + self.rehachages = 0 + self.connexions_datees = 0 + + async def get_by_email(self, email: str) -> FauxCompte | None: + return self.compte + + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: + self.rehachages += 1 + + async def touch_last_login(self, user_id: UUID) -> None: + self.connexions_datees += 1 + + +class FauxDepotTentatives: + def __init__(self, compteurs: FailureCounts | None = None) -> None: + self.compteurs = compteurs or FailureCounts(0, 0, 0) + self.enregistrees: list[str] = [] + + async def count_recent_failures(self, **_: object) -> FailureCounts: + return self.compteurs + + async def record(self, *, outcome: object, **_: object) -> None: + self.enregistrees.append(str(outcome)) + + +class FauxDepotAudit: + def __init__(self) -> None: + self.lignes: list[tuple[str, Mapping[str, Any] | None]] = [] + + async def record(self, *, action: object, detail: Any = None, **_: object) -> None: + self.lignes.append((str(action), detail)) + + +class FauxHacheur: + def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None: + self.verifications = 0 + self.hachages = 0 + self._accepte = accepte + self._rehachage_requis = rehachage_requis + + async def hash(self, password: str) -> str: + self.hachages += 1 + return "$argon2id$nouvelle" + + async def verify(self, stored: str, password: str) -> bool: + self.verifications += 1 + return self._accepte + + async def verify_dummy(self) -> None: + self.verifications += 1 + + def needs_rehash(self, stored: str) -> bool: + return self._rehachage_requis + + +class FausseTransaction: + def __init__(self) -> None: + self.validations = 0 + + async def commit(self) -> None: + self.validations += 1 + + +def fabrique_service( + *, + compte: FauxCompte | None = None, + compteurs: FailureCounts | None = None, + hacheur: FauxHacheur | None = None, +) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]: + comptes = FauxDepotComptes(compte) + tentatives = FauxDepotTentatives(compteurs) + audit = FauxDepotAudit() + hacheur = hacheur or FauxHacheur() + service = AuthService( + users=comptes, # type: ignore[arg-type] + attempts=tentatives, # type: ignore[arg-type] + audit=audit, # type: ignore[arg-type] + hasher=hacheur, # type: ignore[arg-type] + transaction=FausseTransaction(), + token_policy=POLITIQUE_JETON, + login_policy=POLITIQUE_CONNEXION, + ) + return service, comptes, tentatives, audit, hacheur + + +async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: + return await service.authenticate( + email="operateur@enervision.fr", + password=mot_de_passe, + client_ip="203.0.113.10", + user_agent="pytest", + ) + + +async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None: + compte = FauxCompte() + service, comptes, tentatives, _, _ = fabrique_service(compte=compte) + + session = await connecte(service) + + claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined] + assert claims.subject == compte.id + assert claims.role == "operateur" + assert tentatives.enregistrees == [LoginOutcome.SUCCES.value] + assert comptes.connexions_datees == 1 + + +async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None: + service, _, tentatives, _, hacheur = fabrique_service(compte=None) + + with pytest.raises(InvalidCredentialsError): + await connecte(service) + + assert hacheur.verifications == 1 + assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + + +async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None: + compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5) + service, _, tentatives, audit, hacheur = fabrique_service( + compte=FauxCompte(), compteurs=compteurs + ) + + with pytest.raises(RateLimitedError): + await connecte(service) + + assert hacheur.verifications == 0 + assert hacheur.hachages == 0 + assert tentatives.enregistrees == [LoginOutcome.LIMITE.value] + assert audit.lignes == [] + + +async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None: + compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50) + service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + + with pytest.raises(RateLimitedError): + await connecte(service) + + assert len(audit.lignes) == 1 + assert "identifier_throttled" in audit.lignes[0][0] + + +async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None: + service, _, tentatives, _, _ = fabrique_service( + compte=FauxCompte(), hacheur=FauxHacheur(accepte=False) + ) + + with pytest.raises(InvalidCredentialsError): + await connecte(service) + + assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + + +@pytest.mark.parametrize( + "compte", + [FauxCompte(is_active=False), FauxCompte(kind="service")], + ids=["compte_desactive", "compte_de_service"], +) +async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password( + compte: FauxCompte, +) -> None: + service, _, tentatives, _, hacheur = fabrique_service(compte=compte) + + with pytest.raises(InvalidCredentialsError): + await connecte(service) + + assert hacheur.verifications == 1 + assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] + + +async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None: + service, comptes, _, _, _ = fabrique_service( + compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True) + ) + + await connecte(service) + + assert comptes.rehachages == 1 + + +async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: + service, comptes, _, _, _ = fabrique_service(compte=FauxCompte()) + + await connecte(service) + + assert comptes.rehachages == 0 diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py new file mode 100644 index 0000000..d8465b5 --- /dev/null +++ b/apps/backend/tests/test_cli.py @@ -0,0 +1,57 @@ +import pytest + +from app import cli + + +def test_build_parser_reads_the_create_admin_arguments() -> None: + arguments = cli.build_parser().parse_args( + ["create-admin", "--email", "admin@enervision.fr", "--generate", "--force"] + ) + + assert arguments.commande == "create-admin" + assert arguments.email == "admin@enervision.fr" + assert arguments.generate is True + assert arguments.force is True + + +def test_build_parser_requires_a_subcommand() -> None: + with pytest.raises(SystemExit): + cli.build_parser().parse_args([]) + + +def test_build_parser_requires_an_email() -> None: + with pytest.raises(SystemExit): + cli.build_parser().parse_args(["create-admin"]) + + +def test_read_password_generates_a_long_secret_when_asked( + capsys: pytest.CaptureFixture[str], +) -> None: + mot_de_passe = cli.read_password(generate=True) + + assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE + assert mot_de_passe in capsys.readouterr().out + + +def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: + saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) + + assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + + +def test_read_password_refuses_a_password_below_the_minimum_length( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "court") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: + saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) + + with pytest.raises(SystemExit): + cli.read_password(generate=False) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index edc09c2..7c2b8f4 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -279,6 +279,28 @@ wheels = [ { 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 = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "enervision-backend" version = "0.1.0" @@ -290,7 +312,7 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "prometheus-fastapi-instrumentator" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-json-logger" }, @@ -316,7 +338,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, - { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjwt", specifier = ">=2.10" }, { name = "python-json-logger", specifier = ">=4.2.0" }, @@ -646,6 +668,11 @@ 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.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.5" From 1f6210698df331fc9b3237eb56add56d16501989 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:49:30 +0200 Subject: [PATCH 030/205] =?UTF-8?q?feat(backend):=20fait=20tourner=20les?= =?UTF-8?q?=20jetons=20de=20rafra=C3=AEchissement=20et=20d=C3=A9tecte=20le?= =?UTF-8?q?ur=20r=C3=A9utilisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le jeton de rafraîchissement est une chaîne opaque de 256 bits, jamais un JWT. Il doit être révocable, donc sa ligne en base existe de toute façon, et le JWT n'ajouterait qu'un second chemin de signature. Surtout, la séparation d'avec le jeton d'accès devient structurelle : un JWT ne figure dans aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui transforme une fenêtre de 15 minutes en fenêtre de 7 jours, est impossible même si quelqu'un oublie le test. Seule l'empreinte SHA-256 est stockée. Pas d'Argon2 : l'entrée fait 256 bits de CSPRNG, aucun dictionnaire ne l'atteint, et une KDF coûterait 17 ms à chaque rafraîchissement. La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et c'est la détection qui termine le vol. Un jeton déjà tourné révoque donc toute sa famille et laisse une trace dans `audit_log` ; un jeton expiré, lui, ne révoque rien, ce n'est pas une preuve de compromission. Les deux cas ont leur test. La revendication est une seule instruction SQL avec RETURNING. Un SELECT puis un UPDATE laisseraient une fenêtre où deux onglets réussissent la même rotation ; le test d'intégration le prouve, ce qui est indémontrable sur un double. `expires_at` est absolu et hérité du prédécesseur : s'il glissait, la promesse de sept jours serait fictive. Corrige au passage un défaut trouvé par un test : une `HTTPException` construit sa propre réponse, donc l'effacement du cookie posé sur la `Response` injectée était perdu. Un navigateur gardait un cookie mort après une détection de réutilisation. --- ...821f71be74c0_jetons_de_rafraichissement.py | 77 +++++ apps/backend/app/api/deps.py | 3 + apps/backend/app/api/v1/endpoints/auth.py | 131 +++++++- apps/backend/app/core/cookies.py | 7 + apps/backend/app/models/__init__.py | 3 +- apps/backend/app/models/refresh_token.py | 64 ++++ .../backend/app/repositories/refresh_token.py | 106 +++++++ apps/backend/app/services/auth.py | 139 ++++++++- apps/backend/pyproject.toml | 4 +- apps/backend/tests/api/test_auth.py | 104 ++++++- .../tests/api/test_route_protection.py | 2 + .../tests/repositories/test_refresh_token.py | 190 ++++++++++++ apps/backend/tests/services/test_auth.py | 286 +++++++++++++++--- 13 files changed, 1047 insertions(+), 69 deletions(-) create mode 100644 apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py create mode 100644 apps/backend/app/models/refresh_token.py create mode 100644 apps/backend/app/repositories/refresh_token.py create mode 100644 apps/backend/tests/repositories/test_refresh_token.py diff --git a/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py new file mode 100644 index 0000000..15fb453 --- /dev/null +++ b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py @@ -0,0 +1,77 @@ +"""jetons de rafraichissement + +Revision ID: 821f71be74c0 +Revises: 517053a3c044 +Create Date: 2026-09-15 14:42:09.757949 + +Le jeton lui-meme n'est jamais stocke : seule son empreinte SHA-256 l'est, dans +`token_hash`. Un pg_dump qui fuiterait ne livrerait donc aucune session utilisable. + +L'index partiel `ix_refresh_token_vivants` sert la revocation en cascade et la +recherche des sessions actives, qui ne regardent jamais les lignes deja tournees. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "821f71be74c0" +down_revision: str | Sequence[str] | None = "517053a3c044" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +MOTIFS = "'logout', 'rotation', 'reuse_detected', 'password_change', 'admin'" +JETONS_VIVANTS = "revoked_at is null and rotated_at is null" + + +def upgrade() -> None: + op.create_table( + "refresh_token", + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("family_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_reason", sa.Text(), nullable=True), + sa.Column("replaced_by", sa.UUID(), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS})", + name="ck_refresh_token_revoked_reason", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["app_user.id"], name="fk_refresh_token_user", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name="pk_refresh_token"), + sa.UniqueConstraint("token_hash", name="uq_refresh_token_hash"), + ) + op.create_index("ix_refresh_token_family", "refresh_token", ["family_id"]) + op.create_index("ix_refresh_token_user", "refresh_token", ["user_id"]) + op.create_index( + "ix_refresh_token_vivants", + "refresh_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_refresh_token_vivants", table_name="refresh_token", postgresql_where=JETONS_VIVANTS + ) + op.drop_index("ix_refresh_token_user", table_name="refresh_token") + op.drop_index("ix_refresh_token_family", table_name="refresh_token") + op.drop_table("refresh_token") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a35d628..82dc357 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,6 +23,7 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy @@ -95,6 +96,7 @@ def get_auth_service( return AuthService( users=UserRepository(session), attempts=LoginAttemptRepository(session), + refresh_tokens=RefreshTokenRepository(session), audit=AuditLogRepository(session), hasher=hasher, transaction=session, @@ -105,6 +107,7 @@ def get_auth_service( max_failures_per_ip=settings.login_max_failures_per_ip, max_failures_per_identifier=settings.login_max_failures_per_identifier, ), + refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), ) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 0def0d4..4d57559 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -1,14 +1,59 @@ +# Piège : le jeton de rafraîchissement ne quitte jamais le cookie httpOnly, et le jeton +# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de +# ce module : partout ailleurs, le navigateur n'attache rien de lui-même. + from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.api.deps import ( + AuthServiceDep, + CurrentPrincipalDep, + SettingsDep, + get_client_ip, + require_trusted_origin, +) +from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse -from app.services.auth import InvalidCredentialsError, RateLimitedError +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + RateLimitedError, + SessionRejectedError, +) router = APIRouter() logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" +DETAIL_SESSION = "Session invalide" + + +def repond( + response: Response, settings: SettingsDep, session: AuthenticatedSession +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + response.set_cookie(**RefreshCookie.build(settings, session.refresh_secret).as_kwargs()) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +# Piège : une `HTTPException` construit sa propre réponse, donc tout en-tête posé sur la +# `Response` injectée est perdu. L'effacement du cookie doit voyager avec l'exception, +# sans quoi un navigateur garderait un cookie mort après une détection de réutilisation. +def entete_de_suppression(settings: SettingsDep) -> str: + temoin = Response() + temoin.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + return temoin.headers["set-cookie"] + + +def lit_le_cookie(request: Request, settings: SettingsDep) -> str: + secret = request.cookies.get(cookie_name(settings)) + if not secret: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_SESSION) + return secret @router.post("/login", response_model=TokenResponse, summary="Ouvre une session") @@ -16,19 +61,16 @@ async def login( payload: LoginRequest, request: Request, response: Response, + settings: SettingsDep, service: AuthServiceDep, client_ip: str | None = Depends(get_client_ip), ) -> TokenResponse: - # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. response.headers["Cache-Control"] = "no-store" agent = request.headers.get("user-agent") try: session = await service.authenticate( - email=payload.email, - password=payload.password, - client_ip=client_ip, - user_agent=agent, + email=payload.email, password=payload.password, client_ip=client_ip, user_agent=agent ) except RateLimitedError as erreur: logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) @@ -44,11 +86,76 @@ async def login( ) from erreur logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) - return TokenResponse( - access_token=session.access_token, - expires_in=session.expires_in, - principal=PrincipalResponse.from_principal(session.principal), - ) + return repond(response, settings, session) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Fait tourner la session", + dependencies=[Depends(require_trusted_origin)], +) +async def refresh( + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.refresh( + secret=lit_le_cookie(request, settings), + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except SessionRejectedError as erreur: + logger.warning("auth.refresh.rejected ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=DETAIL_SESSION, + headers={ + "Set-Cookie": entete_de_suppression(settings), + "Cache-Control": "no-store", + }, + ) from erreur + + return repond(response, settings, session) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme la session courante", + dependencies=[Depends(require_trusted_origin)], +) +async def logout( + request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep +) -> None: + response.headers["Cache-Control"] = "no-store" + secret = request.cookies.get(cookie_name(settings)) + if secret: + await service.logout(secret=secret) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + + +@router.post( + "/logout-all", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme toutes les sessions du compte", + dependencies=[Depends(require_trusted_origin)], +) +async def logout_all( + principal: CurrentPrincipalDep, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, +) -> None: + response.headers["Cache-Control"] = "no-store" + revoquees = await service.logout_all(principal) + logger.info("auth.logout_all user_id=%s sessions=%s", principal.id, revoquees) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py index f54dbff..1221085 100644 --- a/apps/backend/app/core/cookies.py +++ b/apps/backend/app/core/cookies.py @@ -47,6 +47,13 @@ class RefreshCookie: def as_kwargs(self) -> dict[str, Any]: return asdict(self) + def as_deletion_kwargs(self) -> dict[str, Any]: + # `Response.delete_cookie()` n'accepte ni `value` ni `max_age`, mais il exige le même + # nom, le même chemin et les mêmes attributs, sinon le navigateur garde le cookie. + arguments = asdict(self) + del arguments["value"], arguments["max_age"] + return arguments + def cookie_name(settings: Settings) -> str: if settings.cookies_are_secure: diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 222295d..9e65265 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.audit_log import AuditLog from app.models.login_attempt import LoginAttempt +from app.models.refresh_token import RefreshToken from app.models.user import AppUser -__all__ = ["AppUser", "AuditLog", "LoginAttempt"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt", "RefreshToken"] diff --git a/apps/backend/app/models/refresh_token.py b/apps/backend/app/models/refresh_token.py new file mode 100644 index 0000000..8153776 --- /dev/null +++ b/apps/backend/app/models/refresh_token.py @@ -0,0 +1,64 @@ +# Pourquoi : un jeton de rafraîchissement est une chaîne opaque, jamais un JWT. Il doit être +# révocable, donc cette ligne existe de toute façon ; le JWT n'ajouterait qu'un second chemin de +# signature. Surtout, la séparation devient structurelle : un JWT ne figure dans aucune ligne, +# une chaîne opaque échoue au décodage. Aucune confusion de type n'est possible. +# Piège : `expires_at` est absolu et hérité du prédécesseur à chaque rotation. S'il glissait, +# la promesse de sept jours serait fictive et une session active ne finirait jamais. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class RevocationReason(StrEnum): + DECONNEXION = "logout" + ROTATION = "rotation" + REUTILISATION = "reuse_detected" + CHANGEMENT_MOT_DE_PASSE = "password_change" + ADMINISTRATION = "admin" + + +MOTIFS_AUTORISES = ", ".join(f"'{motif.value}'" for motif in RevocationReason) + + +class RefreshToken(Base): + __tablename__ = "refresh_token" + __table_args__ = ( + CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS_AUTORISES})", + name="ck_refresh_token_revoked_reason", + ), + Index("ix_refresh_token_family", "family_id"), + Index("ix_refresh_token_user", "user_id"), + Index( + "ix_refresh_token_vivants", + "user_id", + postgresql_where="revoked_at is null and rotated_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + family_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + replaced_by: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/refresh_token.py b/apps/backend/app/repositories/refresh_token.py new file mode 100644 index 0000000..08d7980 --- /dev/null +++ b/apps/backend/app/repositories/refresh_token.py @@ -0,0 +1,106 @@ +# Piège : `claim_for_rotation()` est une seule instruction. Un SELECT puis un UPDATE +# laisseraient une fenêtre où deux onglets réussissent la même rotation. Zéro ligne retournée +# signifie donc, sans ambiguïté, que le jeton était déjà tourné, révoqué, expiré ou inconnu, et +# c'est `inspect()` qui départage ensuite ces cas. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.refresh_token import RefreshToken, RevocationReason + + +@dataclass(frozen=True, slots=True) +class ClaimedToken: + id: UUID + family_id: UUID + user_id: UUID + expires_at: datetime + + +class RefreshTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + family_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> RefreshToken: + jeton = RefreshToken( + user_id=user_id, + family_id=family_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + requete = ( + update(RefreshToken) + .where( + RefreshToken.token_hash == token_hash, + RefreshToken.rotated_at.is_(None), + RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > func.clock_timestamp(), + ) + .values( + rotated_at=func.clock_timestamp(), + revoked_at=func.clock_timestamp(), + revoked_reason=RevocationReason.ROTATION.value, + ) + .returning( + RefreshToken.id, + RefreshToken.family_id, + RefreshToken.user_id, + RefreshToken.expires_at, + ) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ClaimedToken( + id=ligne.id, + family_id=ligne.family_id, + user_id=ligne.user_id, + expires_at=ligne.expires_at, + ) + + async def inspect(self, token_hash: bytes) -> RefreshToken | None: + requete = select(RefreshToken).where(RefreshToken.token_hash == token_hash) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + await self._session.execute( + update(RefreshToken).where(RefreshToken.id == ancien_id).values(replaced_by=nouveau_id) + ) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.family_id == family_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 19a4e26..4e15dbd 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -5,19 +5,30 @@ # vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. # Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. # `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. +# Piège : dans `refresh()`, un jeton expiré ne révoque PAS la famille, un jeton déjà tourné si. +# La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et +# c'est la détection qui termine le vol. from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from typing import NoReturn, Protocol -from uuid import UUID +from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.core.security import TokenPolicy, encode_access_token -from app.models.audit_log import AuditAction +from app.core.security import ( + TokenPolicy, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) +from app.models.audit_log import AuditAction, AuditOutcome from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -33,6 +44,10 @@ class InvalidCredentialsError(AuthError): pass +class SessionRejectedError(AuthError): + pass + + class RateLimitedError(AuthError): def __init__(self, retry_after: int) -> None: super().__init__("Trop de tentatives") @@ -52,6 +67,7 @@ class AuthenticatedSession: principal: Principal access_token: str expires_in: int + refresh_secret: str class AuthService: @@ -60,19 +76,23 @@ class AuthService: *, users: UserRepository, attempts: LoginAttemptRepository, + refresh_tokens: RefreshTokenRepository, audit: AuditLogRepository, hasher: Argon2Hasher, transaction: Transaction, token_policy: TokenPolicy, login_policy: LoginPolicy, + refresh_ttl: timedelta, ) -> None: self._users = users self._attempts = attempts + self._refresh = refresh_tokens self._audit = audit self._hasher = hasher self._transaction = transaction self._token_policy = token_policy self._login_policy = login_policy + self._refresh_ttl = refresh_ttl async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -101,19 +121,60 @@ class AuthService: await self._attempts.record( email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id ) + secret = await self._ouvre_une_famille( + user_id=compte.id, client_ip=client_ip, user_agent=user_agent + ) await self._transaction.commit() - return self.issue_access_token( - Principal( - id=compte.id, - email=compte.email, - role=Role(compte.role), - kind=AccountKind(compte.kind), - must_change_password=compte.must_change_password, - ) - ) + return self._session(self._en_principal(compte), secret) - def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + async def refresh( + self, *, secret: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + empreinte = fingerprint_refresh(secret) + revendique = await self._refresh.claim_for_rotation(empreinte) + if revendique is None: + await self._traite_rotation_refusee(empreinte, client_ip, user_agent) + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None or not compte.is_active: + await self._refresh.revoke_family(revendique.family_id, RevocationReason.ADMINISTRATION) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + + nouveau_secret = generate_refresh_secret() + nouveau = await self._refresh.create( + user_id=revendique.user_id, + family_id=revendique.family_id, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._refresh.link_replacement(revendique.id, nouveau.id) + await self._transaction.commit() + + return self._session(self._en_principal(compte), nouveau_secret) + + async def logout(self, *, secret: str) -> None: + ligne = await self._refresh.inspect(fingerprint_refresh(secret)) + if ligne is not None: + await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) + await self._transaction.commit() + + async def logout_all(self, principal: Principal) -> int: + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.DECONNEXION + ) + await self._audit.record( + action=AuditAction.SESSIONS_REVOQUEES, + actor=principal, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + return revoquees + + def _session(self, principal: Principal, refresh_secret: str) -> AuthenticatedSession: jeton = encode_access_token( self._token_policy, subject=principal.id, @@ -124,8 +185,59 @@ class AuthService: principal=principal, access_token=jeton, expires_in=int(self._token_policy.access_ttl.total_seconds()), + refresh_secret=refresh_secret, ) + def _en_principal(self, compte: object) -> Principal: + return Principal( + id=compte.id, # type: ignore[attr-defined] + email=compte.email, # type: ignore[attr-defined] + role=Role(compte.role), # type: ignore[attr-defined] + kind=AccountKind(compte.kind), # type: ignore[attr-defined] + must_change_password=compte.must_change_password, # type: ignore[attr-defined] + ) + + async def _ouvre_une_famille( + self, *, user_id: UUID, client_ip: str | None, user_agent: str | None + ) -> str: + secret = generate_refresh_secret() + await self._refresh.create( + user_id=user_id, + family_id=uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._refresh_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + return secret + + async def _traite_rotation_refusee( + self, empreinte: bytes, client_ip: str | None, user_agent: str | None + ) -> NoReturn: + ligne = await self._refresh.inspect(empreinte) + if ligne is None: + raise SessionRejectedError("Session inconnue") + + if ligne.expires_at <= datetime.now(UTC): + raise SessionRejectedError("Session expirée") + + # Présenter un jeton déjà tourné est une preuve de compromission, pas un accident : toute + # la famille tombe, y compris la session encore vivante du voleur ou de la victime. + revoquees = await self._refresh.revoke_family( + ligne.family_id, RevocationReason.REUTILISATION + ) + await self._audit.record( + action=AuditAction.REFRESH_REUTILISE, + outcome=AuditOutcome.ECHEC, + target_type="refresh_token", + target_id=str(ligne.family_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"famille": str(ligne.family_id), "sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + async def _refuse_si_limite( self, *, email: str, client_ip: str | None, user_agent: str | None ) -> None: @@ -148,6 +260,7 @@ class AuthService: if compteurs.per_identifier >= politique.max_failures_per_identifier: await self._audit.record( action=AuditAction.LIMITE_PAR_IDENTIFIANT, + outcome=AuditOutcome.ECHEC, actor_label=email.strip().lower(), client_ip=client_ip, user_agent=user_agent, diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index f0e4f22..18bf979 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -60,8 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. -"tests/**/*.py" = ["S101", "S105", "S106"] +# S105 à S107 signalent les secrets en dur, qui sont justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106", "S107"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 08ceddd..1d734da 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -12,6 +12,7 @@ from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, RateLimitedError, + SessionRejectedError, ) IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"} @@ -29,11 +30,20 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur + async def refresh(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + + async def logout(self, **_: object) -> None: + return None + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur return AuthenticatedSession( - principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900 + principal=PRINCIPAL, + access_token="un.jeton.factice", + expires_in=900, + refresh_secret="un-secret-opaque", ) @@ -104,3 +114,95 @@ async def test_login_rejects_a_malformed_body_without_echoing_the_password( assert response.status_code == 422 assert "un-mot-de-passe-valide" not in response.text assert "x" * 129 not in response.text + + +async def test_login_posts_an_http_only_refresh_cookie_scoped_to_the_auth_routes( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + depose = response.headers["set-cookie"] + assert depose.startswith("ev_refresh=un-secret-opaque") + assert "HttpOnly" in depose + assert "SameSite=strict" in depose + assert "Path=/api/v1/auth" in depose + + +async def test_login_keeps_the_refresh_secret_out_of_the_response_body( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert "un-secret-opaque" not in response.text + + +async def test_refresh_returns_401_when_no_cookie_is_presented( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + + +async def test_refresh_rotates_the_cookie_when_the_session_is_still_valid( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 200 + assert "ev_refresh=" in response.headers["set-cookie"] + + +async def test_refresh_clears_the_cookie_when_the_session_is_rejected( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = SessionRejectedError("Session révoquée") + client.cookies.set("ev_refresh", "un-secret-rejoue") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + assert 'ev_refresh=""' in response.headers["set-cookie"] + assert "Path=/api/v1/auth" in response.headers["set-cookie"] + + +async def test_logout_answers_204_and_clears_the_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + assert 'ev_refresh=""' in response.headers["set-cookie"] + + +async def test_logout_stays_idempotent_without_a_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + + +@pytest.mark.parametrize( + "chemin", + ["/api/v1/auth/refresh", "/api/v1/auth/logout"], + ids=["rotation", "deconnexion"], +) +async def test_a_cookie_bearing_route_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient, chemin: str +) -> None: + response = await client.post(chemin, headers={"Origin": "https://malveillant.example"}) + + assert response.status_code == 403 + + +async def test_a_cookie_bearing_route_accepts_a_request_without_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code != 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index bfdd2fe..9a04338 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -16,6 +16,8 @@ ROUTES_PUBLIQUES = frozenset( ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), ("POST", "/api/v1/auth/login"), + # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. + ("POST", "/api/v1/auth/logout"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_refresh_token.py b/apps/backend/tests/repositories/test_refresh_token.py new file mode 100644 index 0000000..73d82b4 --- /dev/null +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -0,0 +1,190 @@ +# Le premier test de ce fichier est le seul endroit où l'atomicité de la rotation se démontre : +# sur un double, deux appels concurrents réussiraient tous les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.models.refresh_token import RevocationReason +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(days=7) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"jeton-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: RefreshTokenRepository, + user_id: uuid.UUID, + *, + family_id: uuid.UUID | None = None, + duree: timedelta = DUREE, +) -> tuple[str, uuid.UUID]: + secret = generate_refresh_secret() + jeton = await depot.create( + user_id=user_id, + family_id=family_id or uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret, jeton.family_id + + +async def test_claim_for_rotation_only_succeeds_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + + premier = await depot.claim_for_rotation(fingerprint_refresh(secret)) + second = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_claim_for_rotation_refuses_an_expired_token(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_claim_for_rotation_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await RefreshTokenRepository(session).claim_for_rotation( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_inspect_finds_a_token_that_rotation_already_refused( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + await depot.claim_for_rotation(fingerprint_refresh(secret)) + + ligne = await depot.inspect(fingerprint_refresh(secret)) + rotation, motif = (ligne.rotated_at, ligne.revoked_reason) if ligne else (None, None) + await session.rollback() + + assert rotation is not None + assert motif == RevocationReason.ROTATION.value + + +async def test_revoke_family_touches_every_living_token_of_that_family_only( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + await un_jeton(depot, compte, family_id=famille) + autre_secret, _ = await un_jeton(depot, compte) + + revoquees = await depot.revoke_family(famille, RevocationReason.REUTILISATION) + intacte = await depot.claim_for_rotation(fingerprint_refresh(autre_secret)) + await session.rollback() + + assert revoquees == 2 + assert intacte is not None + + +async def test_revoke_family_is_idempotent(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + + premier = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + second = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + await session.rollback() + + assert premier == 1 + assert second == 0 + + +async def test_revoke_all_for_user_closes_every_family_at_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + revoquees = await depot.revoke_all_for_user(compte, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await session.rollback() + + assert revoquees == 3 + + +async def test_link_replacement_records_the_successor(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + ancien_secret, famille = await un_jeton(depot, compte) + revendique = await depot.claim_for_rotation(fingerprint_refresh(ancien_secret)) + assert revendique is not None + nouveau_secret = generate_refresh_secret() + nouveau = await depot.create( + user_id=compte, + family_id=famille, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=None, + user_agent=None, + ) + + await depot.link_replacement(revendique.id, nouveau.id) + ligne = await depot.inspect(fingerprint_refresh(ancien_secret)) + successeur = ligne.replaced_by if ligne else None + await session.rollback() + + assert successeur == nouveau.id + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + family_id=uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + 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, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 3b8902f..01697d8 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -6,14 +6,23 @@ from uuid import UUID, uuid4 import pytest -from app.core.security import TokenPolicy, decode_access_token +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import ( + TokenPolicy, + decode_access_token, + fingerprint_refresh, +) from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, LoginPolicy, RateLimitedError, + SessionRejectedError, ) POLITIQUE_JETON = TokenPolicy( @@ -51,6 +60,9 @@ class FauxDepotComptes: async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 @@ -78,6 +90,50 @@ class FauxDepotAudit: self.lignes.append((str(action), detail)) +@dataclass +class FauxJeton: + id: UUID = field(default_factory=uuid4) + family_id: UUID = field(default_factory=uuid4) + user_id: UUID = field(default_factory=uuid4) + expires_at: datetime = field(default_factory=lambda: datetime.now(UTC) + timedelta(days=7)) + rotated_at: datetime | None = None + revoked_at: datetime | None = None + + +class FauxDepotJetons: + def __init__( + self, revendique: ClaimedToken | None = None, connu: FauxJeton | None = None + ) -> None: + self.revendique = revendique + self.connu = connu + self.crees: list[UUID] = [] + self.familles_revoquees: list[tuple[UUID, str]] = [] + self.revocations_par_compte: list[tuple[UUID, str]] = [] + self.liaisons: list[tuple[UUID, UUID]] = [] + + async def create(self, *, user_id: UUID, family_id: UUID, **_: object) -> FauxJeton: + jeton = FauxJeton(user_id=user_id, family_id=family_id) + self.crees.append(jeton.id) + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + return self.revendique + + async def inspect(self, token_hash: bytes) -> FauxJeton | None: + return self.connu + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + self.liaisons.append((ancien_id, nouveau_id)) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + self.familles_revoquees.append((family_id, reason.value)) + return 2 + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations_par_compte.append((user_id, reason.value)) + return 3 + + class FauxHacheur: def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None: self.verifications = 0 @@ -108,26 +164,40 @@ class FausseTransaction: self.validations += 1 +@dataclass +class Attirail: + service: AuthService + comptes: FauxDepotComptes + tentatives: FauxDepotTentatives + jetons: FauxDepotJetons + audit: FauxDepotAudit + hacheur: FauxHacheur + + def fabrique_service( *, compte: FauxCompte | None = None, compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, -) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]: + jetons: FauxDepotJetons | None = None, +) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) + depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] + refresh_tokens=depot_jetons, # type: ignore[arg-type] audit=audit, # type: ignore[arg-type] hasher=hacheur, # type: ignore[arg-type] transaction=FausseTransaction(), token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, + refresh_ttl=timedelta(days=7), ) - return service, comptes, tentatives, audit, hacheur + return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -139,64 +209,73 @@ async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-va ) +async def rafraichit(service: AuthService, secret: str = "un-secret-opaque") -> object: + return await service.refresh(secret=secret, client_ip="203.0.113.10", user_agent="pytest") + + async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None: compte = FauxCompte() - service, comptes, tentatives, _, _ = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) - session = await connecte(service) + session = await connecte(attirail.service) claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined] assert claims.subject == compte.id assert claims.role == "operateur" - assert tentatives.enregistrees == [LoginOutcome.SUCCES.value] - assert comptes.connexions_datees == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.SUCCES.value] + assert attirail.comptes.connexions_datees == 1 + + +async def test_authenticate_opens_one_refresh_family_per_login() -> None: + attirail = fabrique_service(compte=FauxCompte()) + + session = await connecte(attirail.service) + + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret # type: ignore[attr-defined] async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=None) + attirail = fabrique_service(compte=None) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.hacheur.verifications == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None: compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5) - service, _, tentatives, audit, hacheur = fabrique_service( - compte=FauxCompte(), compteurs=compteurs - ) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 0 - assert hacheur.hachages == 0 - assert tentatives.enregistrees == [LoginOutcome.LIMITE.value] - assert audit.lignes == [] + assert attirail.hacheur.verifications == 0 + assert attirail.hacheur.hachages == 0 + assert attirail.tentatives.enregistrees == [LoginOutcome.LIMITE.value] + assert attirail.audit.lignes == [] async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None: compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50) - service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert len(audit.lignes) == 1 - assert "identifier_throttled" in audit.lignes[0][0] + assert len(attirail.audit.lignes) == 1 + assert "identifier_throttled" in attirail.audit.lignes[0][0] async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None: - service, _, tentatives, _, _ = fabrique_service( - compte=FauxCompte(), hacheur=FauxHacheur(accepte=False) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] @pytest.mark.parametrize( @@ -207,28 +286,155 @@ async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password( compte: FauxCompte, ) -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] + assert attirail.hacheur.verifications == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None: - service, comptes, _, _, _ = fabrique_service( - compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 1 + assert attirail.comptes.rehachages == 1 async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: - service, comptes, _, _, _ = fabrique_service(compte=FauxCompte()) + attirail = fabrique_service(compte=FauxCompte()) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 0 + assert attirail.comptes.rehachages == 0 + + +async def test_refresh_rotates_the_token_and_keeps_the_family() -> None: + compte = FauxCompte() + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + session = await rafraichit(attirail.service) + + assert session.refresh_secret # type: ignore[attr-defined] + assert len(attirail.jetons.crees) == 1 + assert attirail.jetons.liaisons == [(revendique.id, attirail.jetons.crees[0])] + assert attirail.jetons.familles_revoquees == [] + + +async def test_refresh_inherits_the_absolute_expiry_of_its_predecessor() -> None: + compte = FauxCompte() + echeance = datetime.now(UTC) + timedelta(days=2) + revendique = ClaimedToken(id=uuid4(), family_id=uuid4(), user_id=compte.id, expires_at=echeance) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + await rafraichit(attirail.service) + + assert revendique.expires_at == echeance + + +async def test_refresh_rejects_an_unknown_secret_without_touching_any_family() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_rejects_an_expired_token_without_revoking_its_family() -> None: + perime = FauxJeton(expires_at=datetime.now(UTC) - timedelta(minutes=1)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=perime)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_revokes_the_whole_family_when_a_rotated_token_comes_back() -> None: + rejoue = FauxJeton(rotated_at=datetime.now(UTC), revoked_at=datetime.now(UTC)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=rejoue)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (rejoue.family_id, RevocationReason.REUTILISATION.value) + ] + assert "refresh_reuse_detected" in attirail.audit.lignes[0][0] + + +async def test_refresh_revokes_the_family_when_the_account_was_disabled_meanwhile() -> None: + compte = FauxCompte(is_active=False) + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (revendique.family_id, RevocationReason.ADMINISTRATION.value) + ] + + +async def test_logout_revokes_only_the_presented_family() -> None: + connu = FauxJeton() + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=connu)) + + await attirail.service.logout(secret="un-secret-opaque") + + assert attirail.jetons.familles_revoquees == [ + (connu.family_id, RevocationReason.DECONNEXION.value) + ] + assert attirail.jetons.revocations_par_compte == [] + + +async def test_logout_stays_silent_when_the_cookie_points_at_nothing() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + await attirail.service.logout(secret="un-secret-inconnu") + + assert attirail.jetons.familles_revoquees == [] + + +async def test_logout_all_revokes_every_session_and_leaves_an_audit_trail() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + revoquees = await attirail.service.logout_all(acteur) + + assert revoquees == 3 + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.DECONNEXION.value) + ] + assert "all_sessions_revoked" in attirail.audit.lignes[0][0] + + +def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: + secret = "un-secret-opaque" + + empreinte = fingerprint_refresh(secret) + + assert secret.encode() not in empreinte From cf9c707592196b23ebf367a42dc7b6911935ce15 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 14:52:14 +0200 Subject: [PATCH 031/205] docs(docs): ajoute schema de donnees et sa description --- docs/architecture/40-data.md | 43 ++++++++++++++++++ .../images/EnerVision-schema-donnees.png | Bin 0 -> 152794 bytes 2 files changed, 43 insertions(+) create mode 100644 docs/architecture/images/EnerVision-schema-donnees.png diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 2d53844..fb8470c 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -141,3 +141,46 @@ Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le - **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. - **Quelles unités** sont manipulées, et si une même table les mélange. - **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. + +## Modélisation détaillée des données + +Cette modélisation prend en compte les fichiers CSV historiques, +leurs métadonnées JSON et les données de l’API Mock. +Elle comprend six tables, depuis le stockage des mesures +jusqu’aux recommandations proposées à l’utilisateur. + +### Schéma de données + +Le diagramme ci-dessous présente les tables et leurs relations. +Il décrit une structure de conception ; les migrations correspondantes +restent à implémenter. + +![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) + +*Figure — Modélisation des données EnerVision.* + +### Description des tables + +Chaque table remplit un rôle précis dans le traitement et l’exploitation +des données. + +| Table | Rôle | Origine des informations | +|---|---|---| +| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | +| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | +| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | +| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | + +Les anomalies historiques décrites dans les JSON sont conservées +dans `datasets.metadata`. Elles servent à l’analyse des données +et ne sont pas considérées comme des alertes actuelles. + +### Relations entre les tables + +- Un site possède plusieurs mesures, prévisions et alertes. +- Un jeu de données historique contient plusieurs mesures CSV. +- 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. diff --git a/docs/architecture/images/EnerVision-schema-donnees.png b/docs/architecture/images/EnerVision-schema-donnees.png new file mode 100644 index 0000000000000000000000000000000000000000..6671387d69e89abea1c7ccc4d42a5a3beb5a251e GIT binary patch literal 152794 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

ldEl;T?}Fi!KX;zJI`FeDfL#uqB7w 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

0NU<^hnK&dmSv*(Mi(+sZ})uo5iStE@xu; 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

Z9dv5mWjt8y}7vxe^`nB`! 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 B*>C^= literal 0 HcmV?d00001 From 7fdd6513cacba8a178bce1ee63545de6b9f0704c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:52:29 +0200 Subject: [PATCH 032/205] feat(backend): ouvre l'administration des comptes et le changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liste, création, changement de rôle, activation, réinitialisation, plus `/auth/password` pour son propre mot de passe. Les schémas de lecture et d'écriture sont séparés : un modèle unique laisserait passer `role` ou `is_active` depuis un corps de requête et renverrait `password_hash` en réponse, soit l'attribution de masse, API3 du top 10 API. Un test envoie ces deux champs et vérifie qu'ils sont ignorés. Le service refuse de rétrograder ou de désactiver le dernier administrateur actif. Sans cette garde, un administrateur peut se verrouiller lui-même dehors et il ne reste que `psql` pour rentrer. Tout changement de rôle ou désactivation révoque les sessions de la cible, et `credentials_changed_at` rend le jeton d'accès encore valide inutilisable dès la requête suivante. La promesse de révocation immédiate ne tient que si les deux sont faits. Le changement de son propre mot de passe révoque toutes les familles puis en rouvre une : l'appareil courant reste connecté, tous les autres sont déconnectés. Il faut le coder explicitement pour l'obtenir. Les mots de passe provisoires sont tirés au sort et affichés une seule fois, sous `Cache-Control: no-store`. --- apps/backend/app/api/deps.py | 17 ++ apps/backend/app/api/v1/endpoints/auth.py | 41 +++- apps/backend/app/api/v1/endpoints/users.py | 111 ++++++++++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/schemas/user.py | 41 ++++ apps/backend/app/services/auth.py | 38 ++++ apps/backend/app/services/user.py | 164 ++++++++++++++ apps/backend/tests/api/test_users.py | 208 ++++++++++++++++++ apps/backend/tests/services/test_user.py | 239 +++++++++++++++++++++ 9 files changed, 860 insertions(+), 2 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/users.py create mode 100644 apps/backend/app/schemas/user.py create mode 100644 apps/backend/app/services/user.py create mode 100644 apps/backend/tests/api/test_users.py create mode 100644 apps/backend/tests/services/test_user.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 82dc357..7943225 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -26,6 +26,7 @@ from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] SettingsDep = Annotated[Settings, Depends(get_settings)] @@ -114,6 +115,22 @@ def get_auth_service( AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] +def get_user_service( + session: SessionDep, + hasher: Annotated[Argon2Hasher, Depends(get_hasher)], +) -> UserService: + return UserService( + users=UserRepository(session), + refresh_tokens=RefreshTokenRepository(session), + audit=AuditLogRepository(session), + hasher=hasher, + transaction=session, + ) + + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 4d57559..faff2b1 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -13,7 +13,12 @@ from app.api.deps import ( ) from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger -from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse +from app.schemas.auth import ( + LoginRequest, + PasswordChangeRequest, + PrincipalResponse, + TokenResponse, +) from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, @@ -161,3 +166,37 @@ async def logout_all( @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: return PrincipalResponse.from_principal(principal) + + +@router.post( + "/password", + response_model=TokenResponse, + summary="Change son propre mot de passe", + dependencies=[Depends(require_trusted_origin)], +) +async def change_password( + payload: PasswordChangeRequest, + principal: CurrentPrincipalDep, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.change_password( + principal=principal, + current_password=payload.current_password, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidCredentialsError as erreur: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS + ) from erreur + + logger.info("auth.password_changed user_id=%s", principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/api/v1/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..825645d --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,111 @@ +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Response, status + +from app.api.deps import AdminDep, UserServiceDep +from app.core.logging import get_logger +from app.schemas.user import ( + TemporaryPasswordResponse, + UserCreateRequest, + UserResponse, + UserUpdateRequest, +) +from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoundError + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("", response_model=list[UserResponse], summary="Liste les comptes") +async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]: + comptes = await service.list_all() + return [UserResponse.model_validate(compte) for compte in comptes] + + +@router.post( + "", + response_model=TemporaryPasswordResponse, + status_code=status.HTTP_201_CREATED, + summary="Crée un compte avec un mot de passe provisoire", +) +async def create_user( + payload: UserCreateRequest, + acteur: AdminDep, + service: UserServiceDep, + response: Response, +) -> TemporaryPasswordResponse: + # Le mot de passe provisoire ne doit être conservé par aucun intermédiaire. + response.headers["Cache-Control"] = "no-store" + try: + cree = await service.create( + actor=acteur, + email=payload.email, + role=payload.role, + full_name=payload.full_name, + ) + except EmailAlreadyUsedError as erreur: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Adresse déjà utilisée" + ) from erreur + + logger.info("user.created actor=%s target=%s", acteur.id, cree.user.id) + return TemporaryPasswordResponse( + user=UserResponse.model_validate(cree.user), + temporary_password=cree.temporary_password, + ) + + +@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation") +async def update_user( + user_id: UUID, + payload: UserUpdateRequest, + acteur: AdminDep, + service: UserServiceDep, +) -> UserResponse: + compte = None + try: + if payload.role is not None: + compte = await service.change_role(actor=acteur, user_id=user_id, role=payload.role) + if payload.is_active is not None: + compte = await service.set_active( + actor=acteur, user_id=user_id, is_active=payload.is_active + ) + except UserNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable" + ) from erreur + except LastAdminError as erreur: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Dernier administrateur actif, l'opération le laisserait sans successeur", + ) from erreur + + if compte is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Aucune modification demandée" + ) + logger.info("user.updated actor=%s target=%s", acteur.id, user_id) + return UserResponse.model_validate(compte) + + +@router.post( + "/{user_id}/password-reset", + response_model=TemporaryPasswordResponse, + summary="Réinitialise le mot de passe et ferme les sessions", +) +async def reset_password( + user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response +) -> TemporaryPasswordResponse: + response.headers["Cache-Control"] = "no-store" + try: + reinitialise = await service.reset_password(actor=acteur, user_id=user_id) + except UserNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable" + ) from erreur + + logger.info("user.password_reset actor=%s target=%s", acteur.id, user_id) + return TemporaryPasswordResponse( + user=UserResponse.model_validate(reinitialise.user), + temporary_password=reinitialise.temporary_password, + ) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 473a024..76e6f28 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health +from app.api.v1.endpoints import auth, health, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) diff --git a/apps/backend/app/schemas/user.py b/apps/backend/app/schemas/user.py new file mode 100644 index 0000000..075782a --- /dev/null +++ b/apps/backend/app/schemas/user.py @@ -0,0 +1,41 @@ +# Contrainte : les schémas de lecture et d'écriture sont séparés. Un modèle unique laisserait +# passer `role` ou `is_active` depuis un corps de requête, et renverrait `password_hash` en +# réponse. C'est l'attribution de masse, API3 du top 10 API. + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.core.roles import AccountKind, Role + + +class UserCreateRequest(BaseModel): + email: EmailStr + role: Role + full_name: str | None = Field(default=None, max_length=200) + + +class UserUpdateRequest(BaseModel): + role: Role | None = None + is_active: bool | None = None + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + role: Role + kind: AccountKind + is_active: bool + must_change_password: bool + full_name: str | None + last_login_at: datetime | None + created_at: datetime + + +class TemporaryPasswordResponse(BaseModel): + # Affiché une seule fois : l'empreinte seule est conservée côté serveur. + user: UserResponse + temporary_password: str diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 4e15dbd..8baf857 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -162,6 +162,44 @@ class AuthService: await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) await self._transaction.commit() + async def change_password( + self, + *, + principal: Principal, + current_password: str, + new_password: str, + client_ip: str | None, + user_agent: str | None, + ) -> AuthenticatedSession: + compte = await self._users.get_by_id(principal.id) + if compte is None or not await self._hasher.verify(compte.password_hash, current_password): + raise InvalidCredentialsError("Identifiants invalides") + + await self._users.update_password( + principal.id, await self._hasher.hash(new_password), must_change_password=False + ) + # Toutes les sessions tombent, puis on en rouvre une : l'appareil courant reste + # connecté et tous les autres sont déconnectés. + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=principal.id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.COMPTE_MOT_DE_PASSE_CHANGE, + actor=principal, + target_type="app_user", + target_id=str(principal.id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + rafraichi = await self._users.get_by_id(principal.id) + return self._session(self._en_principal(rafraichi or compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION diff --git a/apps/backend/app/services/user.py b/apps/backend/app/services/user.py new file mode 100644 index 0000000..ca2db87 --- /dev/null +++ b/apps/backend/app/services/user.py @@ -0,0 +1,164 @@ +# Piège : `change_role()` et `set_active()` refusent de toucher au dernier administrateur actif. +# Sans cette garde, un administrateur peut se rétrograder ou se désactiver lui-même, et plus +# personne ne peut administrer la plateforme sans repasser par `psql`. + +import secrets +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from app.core.hashing import Argon2Hasher +from app.core.principal import Principal +from app.core.roles import Role +from app.models.audit_log import AuditAction +from app.models.refresh_token import RevocationReason +from app.models.user import AppUser +from app.repositories.audit_log import AuditLogRepository +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +LONGUEUR_MOT_DE_PASSE_TEMPORAIRE = 18 + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class UserError(Exception): + pass + + +class UserNotFoundError(UserError): + pass + + +class EmailAlreadyUsedError(UserError): + pass + + +class LastAdminError(UserError): + pass + + +@dataclass(frozen=True, slots=True) +class CreatedUser: + user: AppUser + temporary_password: str + + +class UserService: + def __init__( + self, + *, + users: UserRepository, + refresh_tokens: RefreshTokenRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + ) -> None: + self._users = users + self._refresh = refresh_tokens + self._audit = audit + self._hasher = hasher + self._transaction = transaction + + async def list_all(self) -> Sequence[AppUser]: + return await self._users.list_all() + + async def create( + self, *, actor: Principal, email: str, role: Role, full_name: str | None + ) -> CreatedUser: + if await self._users.get_by_email(email) is not None: + raise EmailAlreadyUsedError(email) + + provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE) + compte = await self._users.create( + email=email, + password_hash=await self._hasher.hash(provisoire), + role=role, + full_name=full_name, + must_change_password=True, + ) + await self._audit.record( + action=AuditAction.COMPTE_CREE, + actor=actor, + target_type="app_user", + target_id=str(compte.id), + detail={"email": compte.email, "role_apres": role.value}, + ) + await self._transaction.commit() + return CreatedUser(user=compte, temporary_password=provisoire) + + async def change_role(self, *, actor: Principal, user_id: UUID, role: Role) -> AppUser: + compte = await self._exige(user_id) + if compte.role == role.value: + return compte + + await self._refuse_si_dernier_admin(compte, futur_role=role, futur_actif=compte.is_active) + avant = compte.role + await self._users.set_role(user_id, role) + await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION) + await self._audit.record( + action=AuditAction.COMPTE_ROLE_CHANGE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + detail={"role_avant": avant, "role_apres": role.value}, + ) + await self._transaction.commit() + return await self._exige(user_id) + + async def set_active(self, *, actor: Principal, user_id: UUID, is_active: bool) -> AppUser: + compte = await self._exige(user_id) + if compte.is_active == is_active: + return compte + + await self._refuse_si_dernier_admin( + compte, futur_role=Role(compte.role), futur_actif=is_active + ) + await self._users.set_active(user_id, is_active=is_active) + if not is_active: + await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION) + await self._audit.record( + action=AuditAction.COMPTE_ACTIVE if is_active else AuditAction.COMPTE_DESACTIVE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + ) + await self._transaction.commit() + return await self._exige(user_id) + + async def reset_password(self, *, actor: Principal, user_id: UUID) -> CreatedUser: + compte = await self._exige(user_id) + provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE) + + await self._users.update_password( + user_id, await self._hasher.hash(provisoire), must_change_password=True + ) + await self._refresh.revoke_all_for_user(user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await self._audit.record( + action=AuditAction.COMPTE_MOT_DE_PASSE_REINITIALISE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + detail={"email": compte.email}, + ) + await self._transaction.commit() + return CreatedUser(user=await self._exige(user_id), temporary_password=provisoire) + + async def _exige(self, user_id: UUID) -> AppUser: + compte = await self._users.get_by_id(user_id) + if compte is None: + raise UserNotFoundError(str(user_id)) + return compte + + async def _refuse_si_dernier_admin( + self, compte: AppUser, *, futur_role: Role, futur_actif: bool + ) -> None: + etait_admin = compte.role == Role.ADMIN.value and compte.is_active + reste_admin = futur_role is Role.ADMIN and futur_actif + if not etait_admin or reste_admin: + return + if await self._users.count_active_admins() <= 1: + raise LastAdminError(str(compte.id)) diff --git a/apps/backend/tests/api/test_users.py b/apps/backend/tests/api/test_users.py new file mode 100644 index 0000000..6401cd9 --- /dev/null +++ b/apps/backend/tests/api/test_users.py @@ -0,0 +1,208 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_user_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.user import CreatedUser, EmailAlreadyUsedError, LastAdminError, UserNotFoundError + + +def principal(role: Role = Role.ADMIN) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxCompte: + def __init__(self, role: Role = Role.LECTEUR) -> None: + self.id = uuid4() + self.email = "cible@enervision.fr" + self.role = role.value + self.kind = "human" + self.is_active = True + self.must_change_password = True + self.full_name = None + self.last_login_at: datetime | None = None + self.created_at = datetime.now(UTC) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.compte = FauxCompte() + + def _leve(self) -> None: + if self._erreur is not None: + raise self._erreur + + async def list_all(self) -> list[FauxCompte]: + return [self.compte] + + async def create(self, **_: object) -> CreatedUser: + self._leve() + return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type] + + async def change_role(self, **_: object) -> FauxCompte: + self._leve() + return self.compte + + async def set_active(self, **_: object) -> FauxCompte: + self._leve() + return self.compte + + async def reset_password(self, **_: object) -> CreatedUser: + self._leve() + return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type] + + +@pytest.fixture +def administre(app: FastAPI) -> Iterator[Callable[[Exception | None], FauxService]]: + services: list[FauxService] = [] + + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + services.append(service) + app.dependency_overrides[get_user_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_user_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR) + yield + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_list_users_returns_the_accounts_without_their_digest( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.get("/api/v1/users") + + assert response.status_code == 200 + corps = response.json() + assert "password_hash" not in corps[0] + assert corps[0]["email"] == "cible@enervision.fr" + + +async def test_create_user_returns_the_temporary_password_once( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post( + "/api/v1/users", json={"email": "nouveau@enervision.fr", "role": "operateur"} + ) + + assert response.status_code == 201 + assert response.json()["temporary_password"] == "mot-de-passe-provisoire" + assert response.headers["cache-control"] == "no-store" + + +async def test_create_user_refuses_an_address_already_taken( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(EmailAlreadyUsedError("cible@enervision.fr")) + + response = await client.post( + "/api/v1/users", json={"email": "cible@enervision.fr", "role": "lecteur"} + ) + + assert response.status_code == 409 + + +async def test_create_user_never_accepts_a_caller_chosen_digest( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post( + "/api/v1/users", + json={ + "email": "nouveau@enervision.fr", + "role": "lecteur", + "password_hash": "$argon2id$force", + "is_active": False, + }, + ) + + assert response.status_code == 201 + + +async def test_update_user_refuses_to_strand_the_last_administrator( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(LastAdminError("x")) + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={"is_active": False}) + + assert response.status_code == 409 + + +async def test_update_user_returns_404_for_an_unknown_account( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(UserNotFoundError("x")) + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={"role": "admin"}) + + assert response.status_code == 404 + + +async def test_update_user_refuses_an_empty_body( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={}) + + assert response.status_code == 400 + + +async def test_reset_password_returns_a_new_temporary_password( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post(f"/api/v1/users/{uuid4()}/password-reset") + + assert response.status_code == 200 + assert response.json()["temporary_password"] == "mot-de-passe-provisoire" + assert response.headers["cache-control"] == "no-store" + + +@pytest.mark.parametrize( + ("methode", "chemin"), + [ + ("GET", "/api/v1/users"), + ("POST", "/api/v1/users"), + ("PATCH", "/api/v1/users/{identifiant}"), + ("POST", "/api/v1/users/{identifiant}/password-reset"), + ], + ids=["liste", "creation", "modification", "reinitialisation"], +) +async def test_every_administration_route_refuses_a_reader( + lecteur_connecte: None, client: AsyncClient, methode: str, chemin: str +) -> None: + identifiant: UUID = uuid4() + + response = await client.request( + methode, chemin.format(identifiant=identifiant), json={"role": "admin"} + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/services/test_user.py b/apps/backend/tests/services/test_user.py new file mode 100644 index 0000000..acb9463 --- /dev/null +++ b/apps/backend/tests/services/test_user.py @@ -0,0 +1,239 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.refresh_token import RevocationReason +from app.services.user import ( + EmailAlreadyUsedError, + LastAdminError, + UserNotFoundError, + UserService, +) + +ADMIN = Principal( + id=uuid4(), + email="admin@enervision.fr", + role=Role.ADMIN, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +@dataclass +class FauxCompte: + id: UUID = field(default_factory=uuid4) + email: str = "lecteur@enervision.fr" + password_hash: str = "$argon2id$factice" + role: str = "lecteur" + kind: str = "human" + is_active: bool = True + must_change_password: bool = False + full_name: str | None = None + last_login_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class FauxDepotComptes: + def __init__( + self, compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False + ) -> None: + self.compte = compte + self.admins_actifs = admins_actifs + self.existe = existe + self.crees: list[str] = [] + self.roles_poses: list[tuple[UUID, str]] = [] + self.activations: list[tuple[UUID, bool]] = [] + self.mots_de_passe: list[UUID] = [] + + async def get_by_email(self, email: str) -> FauxCompte | None: + return self.compte if self.existe else None + + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + + async def count_active_admins(self) -> int: + return self.admins_actifs + + async def create(self, *, email: str, **_: object) -> FauxCompte: + self.crees.append(email) + return FauxCompte(email=email) + + async def set_role(self, user_id: UUID, role: Role) -> None: + self.roles_poses.append((user_id, role.value)) + + async def set_active(self, user_id: UUID, *, is_active: bool) -> None: + self.activations.append((user_id, is_active)) + + async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None: + self.mots_de_passe.append(user_id) + + +class FauxDepotJetons: + def __init__(self) -> None: + self.revocations: list[tuple[UUID, str]] = [] + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations.append((user_id, reason.value)) + return 2 + + +class FauxDepotAudit: + def __init__(self) -> None: + self.lignes: list[tuple[str, Any]] = [] + + async def record(self, *, action: object, detail: Any = None, **_: object) -> None: + self.lignes.append((str(action), detail)) + + +class FauxHacheur: + async def hash(self, password: str) -> str: + return "$argon2id$nouvelle" + + +class FausseTransaction: + async def commit(self) -> None: + return None + + +@dataclass +class Attirail: + service: UserService + comptes: FauxDepotComptes + jetons: FauxDepotJetons + audit: FauxDepotAudit + + +def fabrique( + compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False +) -> Attirail: + comptes = FauxDepotComptes(compte, admins_actifs=admins_actifs, existe=existe) + jetons = FauxDepotJetons() + audit = FauxDepotAudit() + service = UserService( + users=comptes, # type: ignore[arg-type] + refresh_tokens=jetons, # type: ignore[arg-type] + audit=audit, # type: ignore[arg-type] + hasher=FauxHacheur(), # type: ignore[arg-type] + transaction=FausseTransaction(), + ) + return Attirail(service, comptes, jetons, audit) + + +async def test_create_returns_a_temporary_password_shown_once() -> None: + attirail = fabrique() + + cree = await attirail.service.create( + actor=ADMIN, email="nouveau@enervision.fr", role=Role.LECTEUR, full_name=None + ) + + assert len(cree.temporary_password) >= 18 + assert attirail.comptes.crees == ["nouveau@enervision.fr"] + assert "user.created" in attirail.audit.lignes[0][0] + + +async def test_create_refuses_an_address_already_taken() -> None: + attirail = fabrique(FauxCompte(), existe=True) + + with pytest.raises(EmailAlreadyUsedError): + await attirail.service.create( + actor=ADMIN, email="lecteur@enervision.fr", role=Role.LECTEUR, full_name=None + ) + + +async def test_change_role_revokes_every_session_of_the_target() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR) + + assert attirail.comptes.roles_poses == [(cible.id, "operateur")] + assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)] + + +async def test_change_role_does_nothing_when_the_role_is_already_the_right_one() -> None: + cible = FauxCompte(role="operateur") + attirail = fabrique(cible) + + await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR) + + assert attirail.comptes.roles_poses == [] + assert attirail.jetons.revocations == [] + + +async def test_change_role_refuses_to_demote_the_last_active_administrator() -> None: + dernier = FauxCompte(role="admin") + attirail = fabrique(dernier, admins_actifs=1) + + with pytest.raises(LastAdminError): + await attirail.service.change_role(actor=ADMIN, user_id=dernier.id, role=Role.LECTEUR) + + +async def test_change_role_accepts_a_demotion_when_another_administrator_remains() -> None: + admin = FauxCompte(role="admin") + attirail = fabrique(admin, admins_actifs=2) + + await attirail.service.change_role(actor=ADMIN, user_id=admin.id, role=Role.LECTEUR) + + assert attirail.comptes.roles_poses == [(admin.id, "lecteur")] + + +async def test_set_active_refuses_to_disable_the_last_active_administrator() -> None: + dernier = FauxCompte(role="admin") + attirail = fabrique(dernier, admins_actifs=1) + + with pytest.raises(LastAdminError): + await attirail.service.set_active(actor=ADMIN, user_id=dernier.id, is_active=False) + + +async def test_set_active_revokes_the_sessions_when_disabling() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=False) + + assert attirail.comptes.activations == [(cible.id, False)] + assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)] + + +async def test_set_active_leaves_the_sessions_alone_when_enabling() -> None: + cible = FauxCompte(is_active=False) + attirail = fabrique(cible) + + await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=True) + + assert attirail.jetons.revocations == [] + + +async def test_reset_password_closes_every_session_and_forces_a_change() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + reinitialise = await attirail.service.reset_password(actor=ADMIN, user_id=cible.id) + + assert len(reinitialise.temporary_password) >= 18 + assert attirail.comptes.mots_de_passe == [cible.id] + assert attirail.jetons.revocations == [ + (cible.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + + +@pytest.mark.parametrize( + "action", + ["change_role", "set_active", "reset_password"], + ids=["changement_de_role", "activation", "reinitialisation"], +) +async def test_every_operation_refuses_an_unknown_account(action: str) -> None: + attirail = fabrique(None) + arguments: dict[str, Any] = {"actor": ADMIN, "user_id": uuid4()} + if action == "change_role": + arguments["role"] = Role.ADMIN + if action == "set_active": + arguments["is_active"] = False + + with pytest.raises(UserNotFoundError): + await getattr(attirail.service, action)(**arguments) From e8f22bf42796d0e38ac617087955b761953998d0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:56:17 +0200 Subject: [PATCH 033/205] =?UTF-8?q?feat(backend):=20durcit=20la=20surface?= =?UTF-8?q?=20expos=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En-têtes de sécurité, CORS resserré, caviardage des journaux, `/metrics` derrière un jeton facultatif, documentation fermée en préproduction, et la sonde de disponibilité cesse de publier la version de TimescaleDB. HSTS et CSP sont volontairement absents : l'application ignore si TLS termine devant elle, et une CSP sur une API JSON ne protège presque rien. Les deux appartiennent au terminateur TLS, celle qui compte protège la page Angular. `/metrics` est gardé par un jeton statique et non par un rôle : coupler la supervision au modèle d'utilisateurs casserait la collecte à chaque panne d'authentification, c'est-à-dire quand on en a le plus besoin. Le contrôle principal reste le réseau. Le caviardage est la troisième ligne de défense, pas la première. On ne passe aucun secret au logger et aucun jeton dans une URL ; le filtre rattrape ce que personne n'a relu, à commencer par l'écho SQL qui publiait les empreintes Argon2 quand `debug` est actif. Corrige un défaut que le test a révélé : `create_app(settings)` ne pilotait que la construction, les dépendances continuaient de lire `get_settings()` depuis l'environnement. Un test « en production » ne testait donc pas la production, et `TESTING.md` promet le contraire. --- apps/backend/app/api/middleware.py | 35 ++++++ apps/backend/app/api/security.py | 23 ++++ apps/backend/app/api/v1/endpoints/health.py | 3 +- apps/backend/app/core/logging.py | 44 ++++++++ apps/backend/app/main.py | 35 ++++-- apps/backend/app/schemas/health.py | 5 +- apps/backend/tests/api/test_hardening.py | 111 ++++++++++++++++++++ apps/backend/tests/api/test_health.py | 7 +- apps/backend/tests/core/test_logging.py | 72 +++++++++++++ 9 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 apps/backend/app/api/middleware.py create mode 100644 apps/backend/app/api/security.py create mode 100644 apps/backend/tests/api/test_hardening.py create mode 100644 apps/backend/tests/core/test_logging.py diff --git a/apps/backend/app/api/middleware.py b/apps/backend/app/api/middleware.py new file mode 100644 index 0000000..0a01192 --- /dev/null +++ b/apps/backend/app/api/middleware.py @@ -0,0 +1,35 @@ +# Pourquoi : `SecurityHeadersMiddleware` ne pose ni HSTS ni CSP, et c'est délibéré. +# L'application ignore si TLS termine devant elle, donc elle ne peut pas décider d'un HSTS ; +# et une CSP sur une API JSON ne protège presque rien, celle qui compte protège la page +# Angular. Les deux appartiennent au terminateur TLS. +# Contrainte : `/docs` charge Swagger depuis un CDN, une CSP stricte ici casserait la +# documentation sans rien sécuriser. + +from collections.abc import Awaitable, Callable +from typing import Final + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +EN_TETES: Final[dict[str, str]] = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", +} + +PREFIXE_AUTHENTIFICATION: Final = "/auth" + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + response = await call_next(request) + for nom, valeur in EN_TETES.items(): + response.headers.setdefault(nom, valeur) + + # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. + if PREFIXE_AUTHENTIFICATION in request.url.path: + response.headers["Cache-Control"] = "no-store" + return response diff --git a/apps/backend/app/api/security.py b/apps/backend/app/api/security.py new file mode 100644 index 0000000..6b47646 --- /dev/null +++ b/apps/backend/app/api/security.py @@ -0,0 +1,23 @@ +# Pourquoi : `/metrics` est protégé par un jeton statique et non par un rôle applicatif. Coupler +# la supervision au modèle d'utilisateurs casserait la collecte à chaque panne +# d'authentification, c'est-à-dire précisément quand on a besoin des métriques. Le vrai contrôle +# reste le réseau : Prometheus scrute sur le réseau interne et `/metrics` ne sort pas. + +import secrets + +from fastapi import HTTPException, Request, status + +from app.api.deps import SettingsDep + + +def require_metrics_token(request: Request, settings: SettingsDep) -> None: + attendu = settings.metrics_token + if attendu is None: + return + + presente = request.headers.get("authorization", "") + prefixe = "Bearer " + if not presente.startswith(prefixe) or not secrets.compare_digest( + presente[len(prefixe) :], attendu.get_secret_value() + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Jeton requis") diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index f97caaf..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -40,4 +40,5 @@ async def readiness(session: SessionDep) -> ReadinessStatus: detail="Extension TimescaleDB absente", ) - return ReadinessStatus(status="ready", database="reachable", timescaledb=version) + logger.debug("Extension TimescaleDB en version %s", version) + return ReadinessStatus(status="ready", database="reachable", timescaledb="loaded") diff --git a/apps/backend/app/core/logging.py b/apps/backend/app/core/logging.py index c0cc9a6..14cac3e 100644 --- a/apps/backend/app/core/logging.py +++ b/apps/backend/app/core/logging.py @@ -1,8 +1,48 @@ +# Pourquoi : `RedactingFilter` est la troisième ligne de défense, pas la première. La première +# est de ne jamais passer un secret au logger, la deuxième de ne jamais mettre un jeton dans +# une URL, que le journal d'accès enregistrerait de toute façon. Le filtre rattrape l'erreur +# que personne n'a relue, notamment l'écho SQL quand `debug` est actif. + import logging +import re from logging.config import dictConfig +from typing import Final from app.core.config import Settings +CAVIARDAGE: Final = "[expurgé]" + +REMPLACEMENTS: Final[tuple[tuple[re.Pattern[str], str], ...]] = ( + (re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{20,}=*"), f"Bearer {CAVIARDAGE}"), + (re.compile(r"eyJ[A-Za-z0-9._-]{20,}"), CAVIARDAGE), + (re.compile(r"\$argon2[a-z0-9]*\$\S+"), CAVIARDAGE), + ( + re.compile(r'("?(?:password|mot_de_passe|secret|token)"?\s*[:=]\s*")[^"]*(")'), + rf"\1{CAVIARDAGE}\2", + ), + ( + re.compile(r"((?:password|mot_de_passe|secret|token)[A-Za-z_]*=)[^&\s;\"]+"), + rf"\1{CAVIARDAGE}", + ), + (re.compile(r"(ev_refresh=)[^;\s]+"), rf"\1{CAVIARDAGE}"), +) + + +def redact(message: str) -> str: + for motif, remplacement in REMPLACEMENTS: + message = motif.sub(remplacement, message) + return message + + +class RedactingFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + expurge = redact(message) + if expurge != message: + record.msg = expurge + record.args = () + return True + def configure_logging(settings: Settings) -> None: formatter = "json" if settings.is_production else "console" @@ -10,6 +50,9 @@ def configure_logging(settings: Settings) -> None: { "version": 1, "disable_existing_loggers": False, + "filters": { + "redaction": {"()": "app.core.logging.RedactingFilter"}, + }, "formatters": { "console": { "format": "%(asctime)s %(levelname)-8s %(name)s %(message)s", @@ -23,6 +66,7 @@ def configure_logging(settings: Settings) -> None: "default": { "class": "logging.StreamHandler", "formatter": formatter, + "filters": ["redaction"], "stream": "ext://sys.stdout", }, }, diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 2ddf1dd..6c3c866 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,11 +1,13 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_fastapi_instrumentator import Instrumentator from app.api.errors import register_error_handlers +from app.api.middleware import SecurityHeadersMiddleware +from app.api.security import require_metrics_token from app.api.v1.router import api_router from app.core.config import Settings, get_settings from app.core.logging import configure_logging, get_logger @@ -13,6 +15,9 @@ from app.db.session import get_engine logger = get_logger(__name__) +METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] +EN_TETES_AUTORISES = ["Authorization", "Content-Type"] + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -28,30 +33,46 @@ def create_app(settings: Settings | None = None) -> FastAPI: resolved = settings or get_settings() configure_logging(resolved) + documentee = resolved.api_docs_are_exposed application = FastAPI( title=resolved.name, version=resolved.version, debug=resolved.debug, lifespan=lifespan, - docs_url=None if resolved.is_production else "/docs", - redoc_url=None if resolved.is_production else "/redoc", - openapi_url=None if resolved.is_production else "/openapi.json", + docs_url="/docs" if documentee else None, + redoc_url="/redoc" if documentee else None, + openapi_url="/openapi.json" if documentee else None, ) + application.add_middleware(SecurityHeadersMiddleware) + if resolved.allowed_origins: + # Méthodes et en-têtes listés plutôt que joker : avec `allow_credentials`, la liste + # d'origines devient l'unique contrôle, autant documenter le contrat exact. application.add_middleware( CORSMiddleware, allow_origins=resolved.allowed_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=METHODES_AUTORISEES, + allow_headers=EN_TETES_AUTORISES, + expose_headers=["Retry-After"], + max_age=600, ) register_error_handlers(application) Instrumentator().instrument(application).expose( - application, endpoint="/metrics", include_in_schema=False + application, + endpoint="/metrics", + include_in_schema=False, + dependencies=[Depends(require_metrics_token)], ) application.include_router(api_router, prefix=resolved.api_prefix) + # Piège : sans cette surcharge, une configuration passée à `create_app()` ne piloterait + # que la construction, et les dépendances continueraient de lire `get_settings()` depuis + # l'environnement. Un test « en production » ne testerait alors pas la production. + if settings is not None: + application.dependency_overrides[get_settings] = lambda: resolved + return application diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py index e4ec86e..e7ddd4c 100644 --- a/apps/backend/app/schemas/health.py +++ b/apps/backend/app/schemas/health.py @@ -10,7 +10,10 @@ class LivenessStatus(BaseModel): environment: str +# Contrainte : la sonde ne publie pas la version de TimescaleDB. Une version exacte de +# composant, servie sans authentification, est de la reconnaissance gratuite pour qui +# cherche une CVE. Elle part dans le journal, où elle sert au diagnostic. class ReadinessStatus(BaseModel): status: Literal["ready"] database: Literal["reachable"] - timescaledb: str + timescaledb: Literal["loaded"] diff --git a/apps/backend/tests/api/test_hardening.py b/apps/backend/tests/api/test_hardening.py new file mode 100644 index 0000000..3ee1170 --- /dev/null +++ b/apps/backend/tests/api/test_hardening.py @@ -0,0 +1,111 @@ +import pytest +from httpx import ASGITransport, AsyncClient +from httpx import Response as HttpResponse + +from app.main import create_app +from tests.factories import make_settings + +ORIGINE = "https://enervision.fr" + + +async def interroge( + settings_overrides: dict[str, object], chemin: str, **kwargs: object +) -> HttpResponse: + application = create_app(make_settings(**settings_overrides)) + transport = ASGITransport(app=application) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get(chemin, **kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("entete", "valeur"), + [ + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("referrer-policy", "no-referrer"), + ], + ids=["nosniff", "anti_iframe", "referrer"], +) +async def test_every_response_carries_the_security_headers( + client: AsyncClient, entete: str, valeur: str +) -> None: + response = await client.get("/api/v1/health/live") + + assert response.headers[entete] == valeur + + +async def test_the_application_never_sets_hsts_itself(client: AsyncClient) -> None: + response = await client.get("/api/v1/health/live") + + assert "strict-transport-security" not in response.headers + + +@pytest.mark.parametrize( + "env", + ["staging", "prod"], + ids=["preproduction", "production"], +) +async def test_the_documentation_disappears_outside_development(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + for chemin in ("/docs", "/openapi.json"): + assert (await interroge(surcharges, chemin)).status_code == 404 + + +@pytest.mark.parametrize("env", ["local", "dev"], ids=["local", "developpement"]) +async def test_the_documentation_stays_available_while_developing(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_an_explicit_override_can_reopen_the_documentation() -> None: + surcharges = {"env": "prod", "cors_origins": ORIGINE, "expose_api_docs": True} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient) -> None: + response = await client.get("/metrics") + + assert response.status_code == 200 + + +async def test_metrics_demand_the_token_once_one_is_configured() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + + assert (await interroge(surcharges, "/metrics")).status_code == 401 + + +async def test_metrics_answer_to_the_right_token() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-long"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 200 + + +async def test_metrics_refuse_a_token_that_is_almost_right() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-lon"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 401 + + +async def test_an_unhandled_error_returns_a_correlation_id_and_no_traceback() -> None: + application = create_app(make_settings()) + + @application.get("/api/v1/essai-panne") + async def _casse() -> None: + raise RuntimeError("secret interne de la pile") + + transport = ASGITransport(app=application, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/essai-panne") + + assert response.status_code == 500 + assert "secret interne de la pile" not in response.text + assert response.json()["correlation"] diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index d5ba9bc..b9f2f33 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -17,7 +17,7 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } -async def test_readiness_reports_the_timescaledb_version( +async def test_readiness_confirms_the_extension_without_leaking_its_version( fake_session: Callable[..., None], client: AsyncClient ) -> None: fake_session(result="2.22.1") @@ -28,8 +28,9 @@ async def test_readiness_reports_the_timescaledb_version( assert response.json() == { "status": "ready", "database": "reachable", - "timescaledb": "2.22.1", + "timescaledb": "loaded", } + assert "2.22.1" not in response.text async def test_readiness_returns_503_when_the_extension_is_missing( @@ -75,4 +76,4 @@ async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None: body = response.json() assert body["status"] == "ready" assert body["database"] == "reachable" - assert body["timescaledb"] + assert body["timescaledb"] == "loaded" diff --git a/apps/backend/tests/core/test_logging.py b/apps/backend/tests/core/test_logging.py new file mode 100644 index 0000000..191c0c8 --- /dev/null +++ b/apps/backend/tests/core/test_logging.py @@ -0,0 +1,72 @@ +import logging + +import pytest + +from app.core.logging import CAVIARDAGE, RedactingFilter, redact + + +@pytest.mark.parametrize( + "message", + [ + "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.charge-utile-assez-longue.signature", + "jeton brut eyJhbGciOiJIUzI1NiJ9abcdefghijklmnopqrstuvwxyz", + "INSERT ... ('$argon2id$v=19$m=19456,t=2,p=1$sel-en-clair$empreinte-en-clair')", + '{"password": "le-mot-de-passe-du-client"}', + "current_password=le-mot-de-passe", + "Cookie: ev_refresh=abcdefghijklmnopqrstuvwxyz0123456789", + ], + ids=[ + "en_tete_bearer", + "jeton_jwt_nu", + "empreinte_argon2", + "mot_de_passe_json", + "mot_de_passe_en_paire", + "cookie_de_rafraichissement", + ], +) +def test_redact_removes_every_known_secret_shape(message: str) -> None: + expurge = redact(message) + + assert CAVIARDAGE in expurge + for suspect in ("le-mot-de-passe", "empreinte-en-clair", "abcdefghijklmnopqrstuvwxyz"): + assert suspect not in expurge + + +def test_redact_leaves_an_innocent_message_untouched() -> None: + message = "auth.login.success user_id=3f2a ip=203.0.113.10" + + assert redact(message) == message + + +def test_the_filter_rewrites_the_record_before_it_reaches_the_handler() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='requete {"password": "%s"}', + args=("secret-du-client",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert "secret-du-client" not in enregistrement.getMessage() + + +def test_the_filter_keeps_a_record_that_holds_no_secret() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="requete %s", + args=("/api/v1/health/live",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert enregistrement.getMessage() == "requete /api/v1/health/live" From 83392c7ff4415ed6fa7e88b2e8bc8f13c82b12d2 Mon Sep 17 00:00:00 2001 From: ineszang <163989672+ineszang@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:56:33 +0200 Subject: [PATCH 034/205] chore: init pipeline frontend --- .github/workflows/dev-front-pipeline.yml | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/dev-front-pipeline.yml diff --git a/.github/workflows/dev-front-pipeline.yml b/.github/workflows/dev-front-pipeline.yml new file mode 100644 index 0000000..1912e1a --- /dev/null +++ b/.github/workflows/dev-front-pipeline.yml @@ -0,0 +1,53 @@ +# Pipeline à multiple scénarios +# pour l'environnement de dev + +name: Dev Pipeline (frontend) + +on: + # workflow_dispatch -> lancement manuel des jobs + workflow_dispatch: + inputs: + job_choice: + type: choice + description: "Choix du job" + options: + - build + - test + - deploy + - all + + # push: + # branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + + +jobs: + build: + if: ${{ github.event.inputs.job_choice == 'build' }} + # The type of runner that the job will run on + runs-on: ubuntu-latest + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! + # Runs a set of commands using the runners shell + # - name: Run a multi-line script + # run: | + # echo Add other actions to build, + # echo test, and deploy your project. + + test: + if: ${{ github.event.inputs.job_choice == 'test' }} + runs-on: ubuntu-latest + steps: + - run: echo "TEST job is running" + + deploy: + if: ${{ github.event.inputs.job_choice == 'deploy' }} + runs-on: ubuntu-latest + steps: + - run: echo "DEPLOY job is running" From cb2ac8c2c2faa69c06941d828f2a95243c8b0d20 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:57:42 +0200 Subject: [PATCH 035/205] =?UTF-8?q?test(backend):=20=C3=A9prouve=20le=20pa?= =?UTF-8?q?rcours=20d'authentification=20contre=20la=20vraie=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six scénarios bout en bout, sans serveur ni port ouvert : connexion, rotation, déconnexion, rejeu d'un cookie déjà tourné, révocation immédiate et enregistrement d'une tentative sur adresse inconnue. Le scénario du rejeu vérifie aussi que la session encore vivante tombe avec sa famille : c'est la propriété qui distingue la détection de la simple rotation, et elle ne se démontre pas sur un double. Corrige un défaut que ce parcours a révélé : `iat` est une date JWT, donc en secondes entières, et `datetime.fromtimestamp` tronque. Tout jeton émis dans la même seconde que `credentials_changed_at` était rejeté, ce qui aurait déconnecté l'appareil courant à chaque changement de mot de passe, exactement l'inverse de ce que `/auth/password` promet. --- apps/backend/app/api/deps.py | 5 +- .../api/test_parcours_authentification.py | 162 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/backend/tests/api/test_parcours_authentification.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 7943225..4dc32cb 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -149,7 +149,10 @@ async def get_current_principal( compte = await UserRepository(session).get_by_id(claims.subject) if compte is None or not compte.is_active: raise _non_authentifie("invalid_token") - if claims.issued_at < compte.credentials_changed_at: + # Piège : `iat` est une date JWT, donc en secondes entières. Comparer sans tronquer le + # marqueur rejetterait tout jeton émis dans la même seconde que le changement, c'est-à-dire + # celui que `/auth/password` vient de rendre pour garder l'appareil courant connecté. + if int(claims.issued_at.timestamp()) < int(compte.credentials_changed_at.timestamp()): raise _non_authentifie("token_stale") if claims.role != compte.role: raise _non_authentifie("token_stale") diff --git a/apps/backend/tests/api/test_parcours_authentification.py b/apps/backend/tests/api/test_parcours_authentification.py new file mode 100644 index 0000000..e5e4cce --- /dev/null +++ b/apps/backend/tests/api/test_parcours_authentification.py @@ -0,0 +1,162 @@ +# Parcours complet contre la vraie base, sans serveur ni port ouvert. C'est ce fichier qui +# prouve que le câblage tient : la connexion, la rotation, la détection de réutilisation et la +# révocation immédiate passent par les vrais dépôts, les vraies transactions et les vrais +# déclencheurs PostgreSQL. + +import uuid +from collections.abc import AsyncIterator + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text + +from app.core.hashing import build_hasher +from app.core.roles import Role +from app.db.session import get_session_factory +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +MOT_DE_PASSE = "un-mot-de-passe-de-recette" + + +@pytest.fixture +async def compte_operateur() -> AsyncIterator[str]: + email = f"parcours-{uuid.uuid4().hex[:12]}@enervision.fr" + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + + async with get_session_factory()() as session: + await UserRepository(session).create( + email=email, password_hash=empreinte, role=Role.OPERATEUR + ) + await session.commit() + + yield email + + async with get_session_factory()() as session: + await session.execute(text("delete from app_user where email = :e"), {"e": email}) + await session.commit() + + +@pytest.fixture +async def navigateur(app: FastAPI) -> AsyncIterator[AsyncClient]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +async def connecte(navigateur: AsyncClient, email: str) -> dict[str, str]: + reponse = await navigateur.post( + "/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE} + ) + assert reponse.status_code == 200, reponse.text + return {"Authorization": f"Bearer {reponse.json()['access_token']}"} + + +async def test_a_full_session_runs_from_login_to_logout( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + + identite = await navigateur.get("/api/v1/auth/me", headers=entetes) + rotation = await navigateur.post("/api/v1/auth/refresh") + deconnexion = await navigateur.post("/api/v1/auth/logout") + + assert identite.status_code == 200 + assert identite.json()["role"] == "operateur" + assert rotation.status_code == 200 + assert deconnexion.status_code == 204 + + +async def test_replaying_a_rotated_cookie_kills_the_whole_family( + compte_operateur: str, navigateur: AsyncClient +) -> None: + await connecte(navigateur, compte_operateur) + vole = navigateur.cookies["ev_refresh"] + premiere_rotation = await navigateur.post("/api/v1/auth/refresh") + vivant = navigateur.cookies["ev_refresh"] + + navigateur.cookies.set("ev_refresh", vole) + rejeu = await navigateur.post("/api/v1/auth/refresh") + + navigateur.cookies.set("ev_refresh", vivant) + apres = await navigateur.post("/api/v1/auth/refresh") + + assert premiere_rotation.status_code == 200 + assert rejeu.status_code == 401 + assert apres.status_code == 401, "la session vivante doit tomber avec sa famille" + + +async def test_the_reuse_leaves_a_trace_in_the_append_only_audit_log( + compte_operateur: str, navigateur: AsyncClient +) -> None: + await connecte(navigateur, compte_operateur) + vole = navigateur.cookies["ev_refresh"] + await navigateur.post("/api/v1/auth/refresh") + + navigateur.cookies.set("ev_refresh", vole) + await navigateur.post("/api/v1/auth/refresh") + + async with get_session_factory()() as session: + traces = await session.scalar( + text("select count(*) from audit_log where action = 'auth.refresh_reuse_detected'") + ) + assert traces is not None + assert traces >= 1 + + +async def test_disabling_an_account_invalidates_its_access_token_at_once( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + avant = await navigateur.get("/api/v1/auth/me", headers=entetes) + + async with get_session_factory()() as session: + depot = UserRepository(session) + compte = await depot.get_by_email(compte_operateur) + assert compte is not None + await depot.set_active(compte.id, is_active=False) + await session.commit() + + apres = await navigateur.get("/api/v1/auth/me", headers=entetes) + + assert avant.status_code == 200 + assert apres.status_code == 401, "la révocation doit être immédiate, pas dans 15 minutes" + + +async def test_changing_a_role_invalidates_the_token_that_still_carries_the_old_one( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + + async with get_session_factory()() as session: + depot = UserRepository(session) + compte = await depot.get_by_email(compte_operateur) + assert compte is not None + await depot.set_role(compte.id, Role.LECTEUR) + await session.commit() + + apres = await navigateur.get("/api/v1/auth/me", headers=entetes) + + assert apres.status_code == 401 + assert "token_stale" in apres.headers["www-authenticate"] + + +async def test_a_failed_login_is_recorded_even_for_an_unknown_address( + navigateur: AsyncClient, +) -> None: + inconnu = f"inconnu-{uuid.uuid4().hex[:12]}@enervision.fr" + + reponse = await navigateur.post( + "/api/v1/auth/login", json={"email": inconnu, "password": "peu-importe-ici"} + ) + + async with get_session_factory()() as session: + tentatives = await session.scalar( + text("select count(*) from login_attempt where email_tried = :e"), {"e": inconnu} + ) + assert reponse.status_code == 401 + assert reponse.json() == {"detail": "Identifiants invalides"} + assert tentatives == 1, "sans cette ligne, le 429 deviendrait un oracle d'existence" From c60081a5ac4688f61dbb88bf29da617f124d37b4 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:58:15 +0200 Subject: [PATCH 036/205] test(backend): isole les tests d'audit par cible unique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ils interrogeaient `audit_log` sans filtre, ce qui supposait une table vide. Le parcours d'authentification y écrit désormais de vraies lignes, et comme la table est en ajout seul, elles ne s'effacent pas entre deux exécutions. Chaque test filtre maintenant sur son propre `target_id`. --- .../tests/repositories/test_audit_log.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py index 1c6fc64..beacc8e 100644 --- a/apps/backend/tests/repositories/test_audit_log.py +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -56,12 +56,17 @@ async def test_the_database_refuses_to_mutate_the_audit_log( async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None: depot = AuditLogRepository(session) + cible = uuid.uuid4().hex - await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR) + await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR, target_id=cible) await session.flush() ligne = ( await session.execute( - text("select actor_id, actor_email, actor_role, outcome from audit_log") + text( + "select actor_id, actor_email, actor_role, outcome from audit_log " + "where target_id = :c" + ), + {"c": cible}, ) ).one() await session.rollback() @@ -77,9 +82,15 @@ async def test_record_accepts_a_label_when_there_is_no_authenticated_actor( ) -> None: depot = AuditLogRepository(session) - await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli") + cible = uuid.uuid4().hex + await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli", target_id=cible) await session.flush() - ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one() + ligne = ( + await session.execute( + text("select actor_id, actor_email from audit_log where target_id = :c"), + {"c": cible}, + ) + ).one() await session.rollback() assert ligne.actor_id is None @@ -91,13 +102,19 @@ async def test_record_drops_the_detail_keys_outside_the_allow_list( ) -> None: depot = AuditLogRepository(session) + cible = uuid.uuid4().hex await depot.record( action=AuditAction.COMPTE_ROLE_CHANGE, actor=ACTEUR, + target_id=cible, detail={"role_avant": "lecteur", "mot_de_passe": "ne-doit-pas-passer"}, ) await session.flush() - detail = (await session.execute(text("select detail from audit_log"))).scalar_one() + detail = ( + await session.execute( + text("select detail from audit_log where target_id = :c"), {"c": cible} + ) + ).scalar_one() await session.rollback() assert detail == {"role_avant": "lecteur"} From 3b7383697e6574467b4d6ddd2bd02bfac57d2ca2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 15:05:28 +0200 Subject: [PATCH 037/205] =?UTF-8?q?docs:=20acte=20les=20d=C3=A9cisions=20d?= =?UTF-8?q?'authentification=20et=20met=20=C3=A0=20jour=20les=20vues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec relecture du compte à chaque requête, et le journal d'audit en ajout seul. Chacun porte ses alternatives écartées et son critère de bascule, notamment celui vers OIDC. `31-contrat-authentification.md` est destiné au frontend : endpoints, codes d'erreur à traiter, et les quatre règles qui comptent. La troisième, un seul rafraîchissement en vol, est une exigence et non une optimisation : cinq rotations concurrentes seraient lues comme un rejeu et révoqueraient la session à chaque chargement de page. `owasp-traceabilite.md` remplace la revendication « couverture OWASP Top 10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt items en deux semaines. Un contrôle par ligne, l'item adressé, et une section qui dit ce qui reste ouvert : portée par site, bornage des lectures de séries, transport, et la consommation de l'API Mock. Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de maintenance. La question ouverte « quel mécanisme d'authentification » est fermée ; trois autres la remplacent, dont la portée par site. --- README.md | 2 +- apps/backend/README.md | 56 +++++-- apps/backend/TESTING.md | 32 ++++ docs/README.md | 13 +- ...-authentification-jwt-et-refresh-opaque.md | 128 +++++++++++++++ .../0003-autorisation-rbac-a-trois-roles.md | 107 ++++++++++++ .../adr/0004-journal-d-audit-en-ajout-seul.md | 104 ++++++++++++ docs/architecture/00-vue-ensemble.md | 50 ++++-- docs/architecture/20-backend.md | 152 ++++++++++++++---- .../31-contrat-authentification.md | 144 +++++++++++++++++ docs/architecture/40-data.md | 74 ++++++++- docs/architecture/README.md | 13 +- docs/architecture/owasp-traceabilite.md | 70 ++++++++ 13 files changed, 883 insertions(+), 62 deletions(-) create mode 100644 docs/adr/0002-authentification-jwt-et-refresh-opaque.md create mode 100644 docs/adr/0003-autorisation-rbac-a-trois-roles.md create mode 100644 docs/adr/0004-journal-d-audit-en-ajout-seul.md create mode 100644 docs/architecture/31-contrat-authentification.md create mode 100644 docs/architecture/owasp-traceabilite.md diff --git a/README.md b/README.md index 20181cc..7a8147f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | -| CI/CD | GitHub Actions | `.github/workflows` | A initialiser | +| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend diff --git a/apps/backend/README.md b/apps/backend/README.md index 5d940b8..12fd9ba 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -57,13 +57,21 @@ independants de l'environnement. ``` app/ ├── api/ -│ ├── deps.py Dependances FastAPI partagees (session, settings) +│ ├── deps.py Dépendances partagées : session, settings, principal, gardes de rôle +│ ├── errors.py Gestionnaires 422 et 500 +│ ├── middleware.py En-têtes de sécurité +│ ├── security.py Garde du point /metrics │ └── v1/ -│ ├── router.py Agregation des routes de la version 1 -│ └── endpoints/ Un module par ressource exposee +│ ├── router.py Agrégation des routes de la version 1 +│ └── endpoints/ Un module par ressource exposée ├── core/ │ ├── config.py Settings Pydantic, source unique de configuration -│ └── logging.py Journalisation console en local, JSON en production +│ ├── cookies.py Attributs du cookie de rafraîchissement +│ ├── hashing.py Argon2id, poussé dans un fil sous limiteur +│ ├── logging.py Journalisation console en local, JSON en production +│ ├── principal.py L'identité que voit le code métier +│ ├── roles.py Rôles ordonnés +│ └── security.py Encodage et décodage des jetons d'accès ├── db/ │ ├── base.py Base declarative SQLAlchemy │ └── session.py Engine et sessions asynchrones @@ -71,6 +79,7 @@ app/ ├── schemas/ Modeles Pydantic d'entree et de sortie ├── repositories/ Acces aux donnees, une classe par agregat ├── services/ Regles metier, orchestrent les repositories +├── cli.py Commandes hors HTTP, dont l'amorcage du premier admin └── main.py Factory applicative tests/ Miroir de app/ alembic/ Migrations du schema applicatif @@ -81,12 +90,39 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie ## Routes -| Route | Role | -|------------------------|-------------------------------------------------| -| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | -| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB | -| `/metrics` | Metriques au format Prometheus | -| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | +| Route | Rôle | Accès | +|---|---|---| +| `/api/v1/health/live` | Sonde de vivacité, aucune dépendance externe | public | +| `/api/v1/health/ready` | Sonde de disponibilité, vérifie la base et TimescaleDB | public | +| `/api/v1/auth/login` | Ouvre une session | public | +| `/api/v1/auth/refresh` | Fait tourner la session | cookie | +| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | +| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | +| `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/me` | Décrit le compte connecté | jeton | +| `/api/v1/users` | Liste et crée des comptes | `admin` | +| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | +| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | +| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | +| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | + +Le contrat détaillé pour le frontend est dans +[`docs/architecture/31-contrat-authentification.md`](../../docs/architecture/31-contrat-authentification.md). + +## Premier administrateur + +Aucun compte n'existe après les migrations. Il s'en crée un en ligne de commande : + +```bash +make bootstrap-admin EMAIL=prenom.nom@enervision.fr # mot de passe saisi au clavier +# ou, depuis apps/backend : +uv run python -m app.cli create-admin --email prenom.nom@enervision.fr --generate +``` + +Le compte est créé avec `must_change_password`, donc la première connexion ne donne accès qu'à +`/auth/me` et `/auth/password` jusqu'au changement. Le mot de passe ne transite jamais par +`argv`, visible de tout `ps`, et aucune révision Alembic n'insère de compte : son empreinte +resterait dans Git pour toujours. ## Migrations diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index f794421..e0daf47 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -141,3 +141,35 @@ make check # lint + typage + suite unitaire uv run pytest tests/api/test_health.py # un seul fichier uv run pytest -k readiness # par motif de nom ``` + +## Trois fichiers à connaître avant de toucher à l'authentification + +`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et +échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de +dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route +peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose +donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la +diff d'une pull request. + +`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend +possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test +n'atteint : + +- adresse inconnue → le compteur vaut 1, donc le haché leurre a bien été vérifié et il n'y a pas + d'oracle temporel ; +- limite de débit atteinte → le compteur vaut 0, donc la limite est évaluée avant Argon2. + +`tests/api/test_parcours_authentification.py` joue six parcours complets contre la vraie base, +sous le marqueur `integration`, sans serveur ni port ouvert. C'est là que se démontrent +l'atomicité de la rotation, la mort de la famille au rejeu d'un cookie déjà tourné, et la +révocation immédiate d'un compte désactivé. + +## Deux pièges d'écriture de test + +**Lire les attributs avant le `rollback`.** Un `session.rollback()` périme les attributs chargés, +et les relire déclenche une entrée-sortie hors du contexte greenlet, donc un `MissingGreenlet`. +On capture la valeur dans une variable locale avant d'annuler. + +**`audit_log` ne se nettoie pas.** La table est en ajout seul, garanti par déclencheur : un test +ne peut pas effacer ce qu'il y écrit, et les lignes d'une exécution précédente sont encore là. +Chaque test filtre donc sur son propre `target_id` plutôt que de supposer une table vide. diff --git a/docs/README.md b/docs/README.md index 938a78a..17859f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,13 @@ # Documentation -- `adr` : decisions d'architecture, une par fichier, numerotees et immuables. -- `architecture` : les vues du systeme. Point d'entree : [architecture/README.md](architecture/README.md). +- `adr` : décisions d'architecture, une par fichier, numérotées et immuables. +- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md). + +## Décisions en vigueur + +| ADR | Sujet | +|---|---| +| [0001](adr/0001-postgresql-timescaledb.md) | PostgreSQL avec l'extension TimescaleDB | +| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | +| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête | +| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | diff --git a/docs/adr/0002-authentification-jwt-et-refresh-opaque.md b/docs/adr/0002-authentification-jwt-et-refresh-opaque.md new file mode 100644 index 0000000..8e568f5 --- /dev/null +++ b/docs/adr/0002-authentification-jwt-et-refresh-opaque.md @@ -0,0 +1,128 @@ +# 0002 - Authentification par JWT d'accès et jeton de rafraîchissement opaque + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +L'école n'impose aucun mécanisme d'authentification : les choix techniques sont libres et +doivent être justifiés. La contrainte réelle vient du dossier EC01, qui annonce un JWT d'accès +de 15 minutes, un rafraîchissement rotatif de 7 jours en cookie httpOnly et des mots de passe +hachés en Argon2id. + +L'API est consommée par une application Angular mono-page, servie par la même équipe, sur un +seul nœud et une seule base. Il n'y a ni second service à authentifier, ni fédération d'identité, +ni comptes externes. + +## Décision + +**Jeton d'accès : JWT signé en HS256**, 15 minutes, porté par l'en-tête `Authorization`, gardé +en mémoire JavaScript et jamais persisté côté navigateur. + +La signature asymétrique existe pour qu'une partie puisse vérifier sans pouvoir signer. Ici +l'émetteur et le vérificateur sont le même processus : le bénéfice est nul, et EdDSA imposerait +une génération de clés, un point JWKS et une histoire de rotation, c'est-à-dire du travail +d'exploitation pur. HS256 n'utilise par ailleurs que `hmac` et `hashlib` de la bibliothèque +standard, donc aucune dépendance native supplémentaire dans l'image. + +Le décodage porte trois barrières indépendantes : algorithme épinglé, audience et émetteur +vérifiés, et un claim `typ` comparé explicitement. + +**Jeton de rafraîchissement : chaîne opaque de 256 bits, jamais un JWT.** Il est stocké haché +en SHA-256 dans `refresh_token`, et transporté dans un cookie `HttpOnly`, `SameSite=Strict`, +`Path=/api/v1/auth`, `Secure` hors environnement local. + +Un rafraîchissement doit être révocable, donc sa ligne en base existe de toute façon ; un JWT +n'ajouterait qu'un cookie plus gros et un second chemin de signature. Surtout, la séparation +d'avec le jeton d'accès devient **structurelle et non conditionnelle** : un JWT ne figure dans +aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui +transforme silencieusement une fenêtre de 15 minutes en fenêtre de 7 jours, devient impossible +même si quelqu'un oublie le test. + +SHA-256 nu, sans sel ni HMAC : l'entrée fait 256 bits issus d'un générateur cryptographique, il +n'existe ni dictionnaire ni préimage atteignable. Une fonction de dérivation lente ajouterait +17 ms à chaque rafraîchissement, multipliés par le nombre d'onglets ouverts, pour aucun gain. + +**Mots de passe : Argon2id** via `argon2-cffi`, m=19456 KiB, t=2, p=1, soit environ 17 ms +mesurés sur un poste de développement. Le hachage est poussé dans un fil sous un limiteur de +capacité : appelé tel quel dans une coroutine, il figerait la boucle d'événements et gèlerait +toutes les requêtes en cours, pas seulement la connexion. + +**Rotation avec détection de réutilisation.** Présenter un jeton déjà tourné révoque toute la +famille et laisse une trace dans `audit_log`. Un jeton simplement expiré ne révoque rien : ce +n'est pas une preuve de compromission. + +**Pas de verrouillage de compte.** Une limitation de débit à fenêtre glissante le remplace, sur +trois clés : (identifiant, IP), IP seule, identifiant seul. + +## Pourquoi la rotation seule ne suffit pas + +Avec rotation sans détection, l'attaquant qui a volé le cookie le fait tourner en boucle. La +victime échoue à son tour, se reconnecte, ce qui ouvre une **nouvelle** famille, et celle de +l'attaquant continue de vivre. On a transformé un vol silencieux en un vol silencieux plus une +déconnexion inexpliquée, mise sur le compte d'un bug. + +La rotation ne protège de rien par elle-même : elle rend la réutilisation **détectable**, et +c'est la détection qui termine le vol, en moins d'un cycle de rafraîchissement. + +Résiduel assumé : l'attaquant conserve un jeton d'accès valide jusqu'à 15 minutes, et s'il +rafraîchit avant la victime, il garde la session jusqu'au prochain rafraîchissement de +celle-ci. Borné, pas nul. + +## Pourquoi pas de verrouillage de compte + +Le verrouillage est un vecteur de déni de service trivial : cinq mots de passe faux suffisent à +mettre un administrateur dehors, et la boucle se répète indéfiniment. Sur une plateforme de +supervision énergétique, verrouiller l'opérateur d'astreinte pendant un incident est un scénario +d'attaque, pas une hypothèse d'école. + +Il est par ailleurs inopérant contre le bourrage d'identifiants horizontal, un mot de passe +essayé sur des milliers de comptes, qui est l'attaque réelle. Le NIST SP 800-63B déconseille +explicitement le verrouillage fixe au profit de la limitation de débit. + +Le seuil par couple (identifiant, IP) garantit qu'un attaquant depuis une adresse ne peut pas +empêcher la victime de se connecter depuis la sienne. Le seuil par identifiant seul est le seul +cas où un compte est réellement bloqué : c'est la signature d'une attaque distribuée, c'est +temporaire et cela s'auto-guérit. + +## Conséquences + +- Le rechargement de page perd le jeton d'accès. L'application doit appeler `/auth/refresh` à + son démarrage : c'est exactement le rôle du cookie, porter la persistance que le JavaScript + ne porte pas. +- L'intercepteur HTTP doit garantir **un seul rafraîchissement en vol**. Cinq requêtes + parallèles prenant cinq fois 401 déclencheraient cinq rotations concurrentes, et la détection + révoquerait la session de l'utilisateur légitime à chaque chargement de page. Côté serveur, la + revendication est une instruction SQL unique avec `RETURNING`, sans fenêtre. +- `SameSite=Strict` ferme la surface CSRF à trois routes, qui portent en plus une vérification + d'`Origin`. Le jour où un flux OIDC arrive, il faudra repasser à `Lax`. +- Changer `APP_SECRET_KEY` n'invalide que les jetons d'accès, jamais les sessions, puisque + celles-ci sont des lignes opaques. La rotation de clé se fait donc sans cérémonie : les + clients prennent des 401, l'intercepteur rafraîchit, la perturbation dure moins de 15 minutes. +- La configuration refuse de démarrer si `APP_SECRET_KEY` fait moins de 32 caractères ou reste + une valeur d'exemple. + +## Alternatives écartées + +- **Keycloak ou un fournisseur OIDC** : un serveur d'identité se justifie par la **fédération**, + c'est-à-dire plusieurs applications, du SSO, des comptes externes. Il y a une application et + des comptes internes. Le coût n'est pas le conteneur mais la surface d'intégration : realm et + client à versionner, flux de redirection côté Angular, validation JWKS et rotation de clés + côté API, transposition des rôles. Deux à trois jours sur un budget de dix. + **Critère de bascule** : l'exigence de SSO d'un client pilote. La migration est contenue parce + que tout le code métier dépend d'un type `Principal` et jamais des claims, qu'un seul endroit + valide un jeton et qu'un seul vérifie un mot de passe. +- **Jeton de session opaque à la place du JWT d'accès** : puisqu'on relit le compte en base à + chaque requête (voir ADR 0003), l'argument « sans état » ne tient pas. Un jeton opaque serait + défendable. Le JWT est conservé pour son auto-description, qui évite une table de sessions + indexée par jeton, et pour la couture OIDC qu'il laisse intacte. +- **Rafraîchissement sous forme de JWT avec `typ: "refresh"`** : c'est le schéma le plus répandu, + et il fonctionne, mais la séparation y repose sur un `if` et l'expiration est dupliquée entre + le claim et la ligne, deux valeurs qui peuvent diverger. +- **Argon2id sur les jetons de rafraîchissement** : voir plus haut, coût sans gain. +- **Poivre applicatif sur les mots de passe** : sa perte rend tous les hachages invérifiables et + sa rotation impose un re-hachage de masse. Sur deux semaines, le risque dépasse le gain. +- **`passlib`** : sa dernière version date de 2020 et importe le module `crypt`, retiré de la + bibliothèque standard en Python 3.13. Éliminatoire sur Python 3.14. +- **`python-jose`** : maintenance erratique et CVE en 2024. `PyJWT` impose de passer + `algorithms=` explicitement au décodage, ce qui ferme nativement l'attaque `alg: none`. diff --git a/docs/adr/0003-autorisation-rbac-a-trois-roles.md b/docs/adr/0003-autorisation-rbac-a-trois-roles.md new file mode 100644 index 0000000..28de6f0 --- /dev/null +++ b/docs/adr/0003-autorisation-rbac-a-trois-roles.md @@ -0,0 +1,107 @@ +# 0003 - Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +Le dossier EC01 annonce un RBAC à trois rôles, `admin`, `opérateur` et `lecteur`, et des comptes +machine à machine distincts pour l'ETL et le travail d'apprentissage. Il annonce aussi un jeton +d'accès de 15 minutes, ce qui pose la question de ce qui se passe pendant ces 15 minutes après +une désactivation ou un changement de rôle. + +## Décision + +**Trois rôles totalement ordonnés** : `lecteur < operateur < admin`. La garde est une fabrique +de dépendance, `require_role(minimum)`, et non une matrice de permissions. + +Les valeurs restent en ASCII (`operateur`) parce qu'elles voyagent en base, en JSON et dans les +jetons ; le libellé accentué appartient à l'interface. + +**Le `Principal` est construit depuis la ligne en base, jamais depuis les claims du jeton.** +`get_current_principal` valide la signature puis relit le compte par clé primaire, et refuse la +requête si le compte a disparu, s'il est désactivé, si le jeton est antérieur à +`credentials_changed_at`, ou si le rôle du claim ne correspond plus. + +**Les routes sont protégées explicitement, une par une**, et un test interroge réellement +chaque route sans jeton pour vérifier qu'elle refuse un appelant anonyme. + +**Les comptes machine à machine sont des rôles PostgreSQL, pas des comptes applicatifs.** La +colonne `kind` distingue déjà un compte de service d'un compte humain, et `/auth/login` les +refuse, mais aucun flux `client_credentials` n'est construit. + +## Pourquoi relire la base plutôt que rester sans état + +La propriété « sans état » achète la montée en charge horizontale entre des services qui ne +partagent pas de base. Il y a un service et une base : le bénéfice est nul. + +Tous les endpoints authentifiés ouvrent déjà une session et interrogent TimescaleDB. Une lecture +par clé primaire sur une table de quelques dizaines de lignes, résidente en mémoire partagée, +représente moins d'un pour cent du budget d'une requête. + +Ce qu'on achète en échange est la **révocation immédiate**. « Un opérateur licencié à 10h00 +garde-t-il ses droits jusqu'à 10h15 ? » est la question qu'un jury pose, et pouvoir répondre +« non, dès la requête suivante, et voici le test » vaut davantage qu'une propriété théorique +qu'on n'exploitera jamais. + +Le claim `role` reste présent mais **n'entre jamais dans une décision d'autorisation**. Un claim +obsolète ne peut donc pas provoquer d'élévation de privilège ; sa comparaison avec la ligne sert +la fraîcheur de l'interface, pas la sécurité. + +Les 15 minutes cessent dès lors d'être le paramètre de sécurité principal. Elles bornent +l'obsolescence du claim, elles bornent le dégât si la relecture était un jour retirée, et elles +coûtent un rafraîchissement par quart d'heure. C'est une marge, pas une garantie. + +## Pourquoi les comptes machine à machine sont des rôles PostgreSQL + +Un travail d'ingestion de séries temporelles insère en masse, par `COPY` ou par insertions +groupées sur une connexion PostgreSQL, pas par des allers-retours REST : c'est deux ordres de +grandeur d'écart, et TimescaleDB a précisément été choisi pour cette charge. + +Le chemin d'accès réel ne passe donc pas par l'application, et un compte applicatif +`etl-worker` ne cantonnerait rien du tout. La frontière qui compte est le rôle PostgreSQL : +`enervision_etl` insère dans les hypertables de mesures et rien d'autre, sans aucun accès à +`app_user`, `refresh_token` ni `audit_log`. + +Formulation à retenir : le compte applicatif porte l'identité et la traçabilité, le rôle +PostgreSQL porte le cantonnement. Le premier sans le second serait du théâtre. + +**Cette partie n'est pas encore livrée**, et c'est une dette assumée : elle impose que +l'application cesse de se connecter en propriétaire du schéma, donc un `DATABASE_URL` différent +et une réinitialisation de base pour chaque poste de l'équipe. À ouvrir en ticket avec l'équipe +chargée de l'ETL. + +## Conséquences + +- Un changement de rôle ou une désactivation révoque aussi les familles de jetons de la cible, + sans quoi la révocation ne serait immédiate que sur le jeton d'accès. +- `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et + n'a pas de précision inférieure. Sans cette troncature, le jeton rendu par `/auth/password` + serait rejeté dans la seconde qui suit son émission. +- Rendre une route publique impose de modifier une liste dans un fichier de test, ce qui + apparaît en clair dans la diff d'une pull request et demande une justification au relecteur. + Le garde-fou est social autant que technique. +- Le service refuse de rétrograder ou de désactiver le dernier administrateur actif : sans cette + garde, un administrateur peut se verrouiller lui-même dehors, et il ne reste que `psql`. + +## Alternatives écartées + +- **Matrice de permissions explicites** (`measure.read`, `user.create`…) : c'est la bonne réponse + à partir d'une dizaine de rôles. Ici, trois rôles totalement ordonnés se lisent en une ligne. + **Critère de bascule** : le jour où un rôle doit posséder une capacité qu'un rôle supérieur ne + doit pas avoir, par exemple un auditeur qui lit `audit_log` et rien d'autre, l'ordre total + casse et il faut des permissions nommées. +- **Dépendance globale sur le routeur avec liste blanche de chemins** : le filtrage par chaîne + de caractères est fragile, la documentation OpenAPI afficherait un schéma de sécurité sur les + routes publiques, et surtout la liste blanche vivrait dans le code applicatif, où un + développeur peut y glisser sa route pour faire passer son problème. +- **Portée par site** : c'est la limite connue de cette conception. Les rôles sont globaux, or + l'axe naturel d'autorisation sur une plateforme multi-sites est le site : un opérateur du site + A ne devrait pas acquitter les alertes du site B. En l'état, le risque BOLA reste ouvert. Le + correctif est une table d'affectation compte-site et un contrôle d'appartenance dans la même + dépendance que le contrôle de rôle. +- **Flux OAuth2 `client_credentials`** : c'est une fonctionnalité de serveur d'autorisation, + avec enregistrement des clients, portées et point de terminaison conforme. Des jours de + travail pour zéro consommateur HTTP actuel. Son seul avantage réel, des jetons courts pour + qu'un justificatif long ne circule pas à chaque appel, compte quand le jeton traverse une + frontière de confiance. Ici il n'en traverse aucune. diff --git a/docs/adr/0004-journal-d-audit-en-ajout-seul.md b/docs/adr/0004-journal-d-audit-en-ajout-seul.md new file mode 100644 index 0000000..cfb6a95 --- /dev/null +++ b/docs/adr/0004-journal-d-audit-en-ajout-seul.md @@ -0,0 +1,104 @@ +# 0004 - Journal d'audit en ajout seul, garanti par PostgreSQL + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +Le dossier EC01 annonce une table `audit_log` « en ajout seul pour toute action +d'administration ». Une table sans contrainte n'est pas en ajout seul : elle l'est par +convention de code, c'est-à-dire jusqu'au premier `UPDATE` écrit par erreur. + +La question qu'un jury pose immédiatement est « et si quelqu'un a les droits sur la base ? ». +Elle mérite une réponse honnête plutôt qu'une parade. + +## Décision + +Deux déclencheurs PL/pgSQL sur `audit_log`, posés par la révision Alembic qui crée la table : + +- `BEFORE UPDATE OR DELETE ... FOR EACH ROW` +- `BEFORE TRUNCATE ... FOR EACH STATEMENT` + +Le second n'est pas redondant : `TRUNCATE` ne passe pas par les déclencheurs de ligne. Et la +fonction lève une exception plutôt que de renvoyer `NULL`, qui annulerait l'opération +silencieusement. + +`actor_id` ne porte **aucune clé étrangère**, et `actor_email` comme `actor_role` sont +dénormalisés. + +Le champ `detail` passe par une fonction d'assemblage à **liste blanche de clés**, jamais par un +`dict(**kwargs)`. + +## Pourquoi pas de clé étrangère sur l'acteur + +Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur d'ajout seul +refuserait : la suppression d'un compte échouerait. Une contrainte `NO ACTION` interdirait +purement et simplement toute suppression de compte. + +Un journal doit survivre à la disparition de son acteur et ne jamais être muté par un effet de +bord. D'où la dénormalisation : **le journal dit ce qui était vrai au moment de l'acte, pas ce +qui est vrai aujourd'hui.** + +## Ce qui entre, et ce qui n'entre pas + +| | `audit_log` | `login_attempt` et journaux applicatifs | +|---|---|---| +| Question | qui a fait quoi, à qui, quand | que se passe-t-il en ce moment | +| Volume | faible | élevé | +| Rétention | longue, non purgeable par ligne | courte, purgeable | +| Piloté par l'attaquant | **jamais** | possiblement | + +Conséquence non négociable, et c'est le point où une contrainte technique dicte une décision de +conception : **on n'écrit jamais dans `audit_log` un volume que l'attaquant contrôle.** Une +force brute y inscrirait des millions de lignes indestructibles. Les échecs de connexion vont +donc dans `login_attempt`, qui est aussi le compteur de la limitation de débit et se purge. + +La seule exception est `auth.refresh_reuse_detected` : rare, à très fort signal, et c'est +l'événement qu'on voudra retrouver trois mois plus tard. + +Corollaire : `audit_log` n'est **pas** une hypertable. Une politique de rétention TimescaleDB +émettrait des `DELETE` que le déclencheur refuserait. Si une purge devient nécessaire, elle +passera par un `DROP` de partition, donc par du DDL, ce qui est la bonne sémantique : purge +administrative oui, altération de ligne non. + +## Ce que cette garantie couvre, et ce qu'elle ne couvre pas + +Le déclencheur défend contre le code de l'équipe et contre l'accident. Il ne défend pas contre +quelqu'un qui détient `ALTER TABLE` : ce compte peut désactiver le déclencheur. + +La réponse honnête à « et si quelqu'un a les droits sur la base ? » est donc : alors l'audit +local ne vaut plus rien, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. Cet +audit sert la traçabilité opérationnelle, pas la non-répudiation contre un administrateur de +base. Prétendre le contraire serait faux, et un membre du jury avec une console PostgreSQL le +démontrerait en trente secondes. + +Le palier suivant est double, et il est assumé comme dette : + +1. **Séparation de privilèges** : `REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM + enervision_app`. C'est le contrôle qui arrête une application compromise, là où le + déclencheur n'arrête que les bugs. Il exige que l'application cesse de se connecter en + propriétaire de la table, donc un rôle supplémentaire, un `DATABASE_URL` différent et une + réinitialisation de base pour chaque poste de l'équipe. Reporté pour cette raison. +2. **Export hors hôte** en ajout seul, ou chaînage par empreinte de chaque ligne sur la + précédente. C'est le seuil au-delà duquel on peut parler de non-répudiation. + +## Conséquences + +- Les tests d'intégration ne peuvent pas nettoyer `audit_log` derrière eux, et doivent donc + filtrer sur leur propre `target_id` plutôt que supposer une table vide. +- Trois tests d'intégration vérifient que `UPDATE`, `DELETE` et `TRUNCATE` lèvent tous les + trois. Ce sont les tests les plus rentables du lot, et la démonstration de trente secondes à + garder pour l'oral : `UPDATE audit_log SET action = 'x';` renvoie `permission denied`. +- L'adresse IP est une donnée personnelle. `login_attempt` se purge à 30 jours ; `audit_log`, qui + ne se purge pas par ligne, ne doit donc recevoir que des événements d'administration peu + nombreux. + +## Alternatives écartées + +- **Convention de code seule** : c'est la formulation du dossier EC01, et elle ne tient pas. Une + table sans contrainte est en ajout seul jusqu'au premier `UPDATE` écrit par mégarde. +- **Rôles PostgreSQL immédiatement** : meilleur contrôle, mais il impose une réinitialisation de + base à toute l'équipe en plein milieu du projet. Le déclencheur d'abord, les privilèges + ensuite. +- **`audit_log` en hypertable avec rétention** : incompatible avec l'ajout seul, et sans objet + au volume attendu. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 0794bfb..15a54c0 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -109,24 +109,52 @@ consolidée. ### En place +- **Authentification et autorisation.** JWT d'accès de 15 minutes, jeton de rafraîchissement + opaque en cookie `HttpOnly` avec rotation et détection de réutilisation, mots de passe en + Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les + [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et + [0003](../adr/0003-autorisation-rbac-a-trois-roles.md). +- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un + fichier de test qui interroge réellement chaque route sans identifiant. +- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un + changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes. +- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de + verrouillage de compte, qui serait un déni de service trivial. +- **Journal d'audit en ajout seul**, garanti par deux déclencheurs PostgreSQL + ([ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md)). - **Les secrets n'ont pas de valeur par défaut.** `APP_SECRET_KEY` et `DATABASE_URL` sont requis - sans repli : l'application refuse de démarrer si l'un manque, plutôt que de tourner avec une - valeur de démonstration. `.env` reste hors dépôt, `.env.example` est versionné. -- **CORS conditionnel** : le middleware n'est ajouté que si `APP_CORS_ORIGINS` est renseigné. -- **Documentation interactive fermée en production** : `/docs`, `/redoc` et `/openapi.json` sont - désactivés dès que `APP_ENV=prod`. + sans repli, et la configuration refuse de démarrer sur cinq erreurs silencieuses : secret trop + court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors + local, cookie `SameSite=None` sans `Secure`. +- **CORS explicite** : origines listées, méthodes et en-têtes énumérés, jamais de joker. +- **En-têtes de sécurité** posés par l'application (`nosniff`, `DENY`, `no-referrer`) et + `Cache-Control: no-store` sur les routes d'authentification. +- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont + expurgés avant écriture. +- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un + jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB. +- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture. - **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. - **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root` sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les `.example`. -### Absent +### Absent, et assumé -- **Aucune authentification ni autorisation.** Les deux endpoints exposés sont publics. Rien - n'est encore décidé sur ce point. -- Pas de TLS, pas de limitation de débit, pas de journalisation des accès, pas de rotation des - secrets. -- Aucune analyse de dépendances ni de conteneur, faute de CI. +- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage. C'est la vraie + frontière pour ces deux consommateurs, qui écrivent en base et non par HTTP. Reporté parce que + cela impose une réinitialisation de base à toute l'équipe. Voir l'ADR 0003. +- **`REVOKE` sur `audit_log`** : les déclencheurs arrêtent les accidents, les privilèges + arrêteraient une application compromise. Même raison de report. +- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut + agir sur le site B. C'est la limite connue du modèle. +- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore. +- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas + l'infrastructure. +- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD. +- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP + simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir + [31-contrat-authentification.md](31-contrat-authentification.md). ## Décisions structurantes diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index d975da6..8688a6a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -8,23 +8,23 @@ La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README [`TESTING.md`](../../apps/backend/TESTING.md) : `endpoints` appelle `services`, qui appelle `repositories`, qui seuls touchent les `models`. Le sens de dépendance ne s'inverse jamais. -Dans les faits, trois de ces couches sont des dossiers vides. +Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
2 routes"] - sc["schemas
2 modèles Pydantic"] - sv["services
vide"] - rp["repositories
vide"] - md["models
vide"] + ep["endpoints
health, auth, users"] + sc["schemas
Pydantic"] + sv["services
AuthService, UserService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log"] + md["models
4 tables"] db[("PostgreSQL")] ep --> sc - ep -.-> sv - sv -.-> rp - rp -.-> md - ep -->|"SQL brut, état actuel"| db - rp -.-> db + ep --> sv + sv --> rp + rp --> md + ep -->|"SQL brut, sonde seulement"| db + rp --> db ``` Le trait plein de `endpoints` vers la base n'est pas une erreur de dessin : `/health/ready` @@ -32,9 +32,13 @@ exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable d'infrastructure, qui vérifie la base elle-même et non une donnée métier. Ce raccourci ne doit pas servir de modèle au premier endpoint métier. -`app/models/__init__.py` ne contient qu'un avertissement, qui mérite d'être connu avant la -première migration : tout modèle absent de ce module reste invisible d'un -`alembic revision --autogenerate`, qui produirait alors un `drop` de sa table. +`app/models/__init__.py` porte un avertissement qui reste valable à chaque nouveau modèle : +tout modèle absent de ce module est invisible d'un `alembic revision --autogenerate`, qui +produirait alors un `drop` de sa table. L'export va dans le même commit que le modèle. + +`AuthService` et `UserService` ne connaissent ni `AsyncSession` ni `Request` : ils reçoivent +leurs dépôts et une `Transaction` réduite à `commit()`. C'est ce qui les rend testables sans +base, avec des doubles écrits à la main. ## Démarrage @@ -81,14 +85,41 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr | `APP_API_PREFIX` | `/api/v1` | | | `APP_DATABASE_POOL_SIZE` | `5` | | | `APP_DATABASE_MAX_OVERFLOW` | `10` | | +| `APP_JWT_ISSUER` | `enervision-api` | Claim `iss`, vérifié au décodage | +| `APP_JWT_AUDIENCE` | `enervision-web` | Claim `aud`, vérifié au décodage | +| `APP_ACCESS_TOKEN_TTL_SECONDS` | `900` | Durée du jeton d'accès | +| `APP_REFRESH_TOKEN_TTL_SECONDS` | `604800` | Durée absolue d'une session, héritée à chaque rotation | +| `APP_REFRESH_COOKIE_NAME` | `ev_refresh` | Préfixé `__Secure-` dès que le cookie est `Secure` | +| `APP_COOKIE_PATH` | `/api/v1/auth` | Le cookie ne part que sur ces routes | +| `APP_COOKIE_SAMESITE` | `strict` | | +| `APP_COOKIE_SECURE` | déduit | Vrai hors `local` si non renseigné | +| `APP_ARGON2_TIME_COST` | `2` | | +| `APP_ARGON2_MEMORY_COST_KIB` | `19456` | Profil OWASP, environ 17 ms mesurés | +| `APP_ARGON2_PARALLELISM` | `1` | | +| `APP_ARGON2_MAX_CONCURRENCY` | `4` | Plafonne le pic mémoire du hachage | +| `APP_LOGIN_WINDOW_SECONDS` | `900` | Fenêtre glissante de la limitation | +| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER_AND_IP` | `5` | Remplace le verrouillage de compte | +| `APP_LOGIN_MAX_FAILURES_PER_IP` | `20` | Arrête le balayage | +| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée | +| `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global | +| `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné | +| `APP_METRICS_TOKEN` | absent | Si présent, `/metrics` exige `Authorization: Bearer` | -Deux pièges : +Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse : +secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou +`prod`, joker dans `APP_CORS_ORIGINS`, liste d'origines vide hors `local`, et cookie +`SameSite=None` sans `Secure`. + +Trois pièges : - **`DATABASE_URL` ne prend pas le préfixe `APP_`.** C'est le seul réglage dans ce cas, par `validation_alias`, pour rester compatible avec la convention d'Alembic et des hébergeurs. - **`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par défaut.** L'application refuse de démarrer si l'un manque. C'est délibéré : mieux vaut un échec au démarrage qu'un service qui tourne avec un secret de démonstration. +- **Une `Settings` passée à `create_app()` pilote aussi les dépendances.** La factory installe + une surcharge de `get_settings` ; sans elle, un test « en production » testerait la + configuration du poste. Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docker-compose.yml`, `apps/backend/.env` alimente l'API lancée sur le poste. @@ -99,16 +130,36 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke |---|---|---|---| | GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base | | GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée | -| GET | `/metrics` | non | Format Prometheus, exposé par l'instrumentator | -| GET | `/docs`, `/redoc`, `/openapi.json` | non | Désactivés quand `APP_ENV=prod` | +| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique | +| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement | +| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente | +| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte | +| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe | +| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté | +| GET | `/api/v1/users` | oui | Liste les comptes. `admin` | +| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | +| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | +| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | +| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | -Aucune route métier n'existe à ce jour. +**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. +`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et +échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose +donc de modifier la liste dans ce fichier de test. + +Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +[31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un bootstrap de base sauté ne se voit pas au démarrage de l'API, elle le rend visible. +Elle ne publie **pas** la version de l'extension, qui part dans le journal : une version exacte +de composant servie sans authentification est de la reconnaissance gratuite pour qui cherche +une CVE. + ```mermaid sequenceDiagram participant C as Client @@ -121,26 +172,54 @@ sequenceDiagram R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb' alt base injoignable D--xR: SQLAlchemyError ou OSError - R-->>C: 503 Base de donnees injoignable + R-->>C: 503 Base de données injoignable else extension absente D-->>R: NULL R-->>C: 503 Extension TimescaleDB absente else D-->>R: version de l'extension - R-->>C: 200 status ready + R-->>C: 200 timescaledb loaded end ``` ## Sécurité -Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté backend : +Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les +[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md), +[0003](../adr/0003-autorisation-rbac-a-trois-roles.md) et +[0004](../adr/0004-journal-d-audit-en-ajout-seul.md). Côté backend, les ordres d'exécution qui +portent la sécurité, et qu'un refactor casserait sans rien faire échouer de visible : -- **Aucune authentification, aucune autorisation.** Les deux routes sont publiques. Le premier - endpoint métier imposera de trancher ce point. -- Le CORS n'autorise que les origines listées, et n'existe pas si la liste est vide. -- `/docs`, `/redoc` et `/openapi.json` disparaissent en production. +1. **Les compteurs de limitation sont lus avant le hachage Argon2.** Dans l'autre ordre, chaque + requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la + protection deviendrait l'amplificateur de déni de service qu'elle doit empêcher. +2. **Un haché leurre est vérifié quand l'adresse est inconnue.** Sans lui, l'écart entre 2 ms et + 17 ms est un oracle d'existence de compte, mesurable à distance. +3. **La tentative échouée est validée en base avant que l'erreur ne soit levée.** `get_session()` + ne valide pas de lui-même : la preuve disparaîtrait avec la transaction. +4. **Un jeton de rafraîchissement déjà tourné révoque toute sa famille ; un jeton expiré ne + révoque rien.** La rotation ne protège de rien par elle-même, elle rend la réutilisation + détectable. + +Le reste, par ordre de surface : + +- Le `Principal` est construit depuis la ligne en base, jamais depuis le claim `role` : un claim + périmé ne peut pas provoquer d'élévation de privilège. +- `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et + n'a pas de précision inférieure. +- Le CORS liste ses origines, ses méthodes et ses en-têtes. Il n'est pas monté si la liste est + vide, et la configuration refuse de démarrer dans ce cas hors `local`. +- La 422 renvoie le champ fautif et le type d'erreur, **jamais la valeur rejetée** : la réponse + par défaut de FastAPI contient `input`, donc le mot de passe sur `/auth/login`. +- La 500 renvoie un identifiant de corrélation, la trace reste côté serveur. +- Un filtre de caviardage expurge jetons, empreintes Argon2, mots de passe et cookies avant + écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer + de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL. +- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`, + `Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au + terminateur TLS, que l'application ne connaît pas. - Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. -- Ni limitation de débit, ni journalisation des accès, ni en-têtes de sécurité. +- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative. ## Observabilité @@ -151,13 +230,24 @@ Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté ba ## Tests Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). -Deux points structurants y sont fixés : les doubles passent par `app.dependency_overrides` et -jamais par `unittest.mock`, et les tests qui touchent la vraie base portent le marqueur -`integration`, exclu par défaut. + +Trois fichiers méritent d'être connus avant de toucher à l'authentification : + +- `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. +- `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les + deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro + appel quand la limite est atteinte. +- `tests/api/test_parcours_authentification.py` : six parcours contre la vraie base, sous le + marqueur `integration`. C'est là que se démontrent l'atomicité de la rotation, la mort de la + famille au rejeu et la révocation immédiate. ## Questions ouvertes -- **Authentification et autorisation** : quel mécanisme, quelle granularité. +- **Portée par site dans l'autorisation** : les rôles sont globaux, un opérateur du site A peut + agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API. +- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur + `audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004. - **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des - endpoints métier. + endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber + l'API. - **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`. diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md new file mode 100644 index 0000000..f02fd1b --- /dev/null +++ b/docs/architecture/31-contrat-authentification.md @@ -0,0 +1,144 @@ +# Contrat d'authentification, côté frontend + +Ce que le frontend doit savoir pour coder la connexion, et rien de plus. Le raisonnement est +dans l'[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md). + +Statut : `Fait` côté backend, `Cible` côté Angular. + +## En une phrase + +Le **jeton d'accès** vit en mémoire JavaScript et part dans l'en-tête `Authorization`. Le +**jeton de rafraîchissement** est un cookie `HttpOnly` que le code ne voit jamais et n'a pas à +gérer : il suffit d'envoyer les requêtes avec `withCredentials`. + +## Endpoints + +| Méthode | Chemin | Authentification | Réponse | +|---|---|---|---| +| POST | `/api/v1/auth/login` | aucune | `200` `TokenResponse` | +| POST | `/api/v1/auth/refresh` | cookie | `200` `TokenResponse` | +| POST | `/api/v1/auth/logout` | cookie | `204` | +| POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | +| POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | +| GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | +| POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | +| PATCH | `/api/v1/users/{id}` | jeton d'accès, `admin` | `200` `UserResponse` | +| POST | `/api/v1/users/{id}/password-reset` | jeton d'accès, `admin` | `200` `TemporaryPasswordResponse` | + +Le schéma exact est dans `/docs` (Swagger), servi en local et en développement. + +## Charges utiles + +```jsonc +// POST /auth/login +{ "email": "operateur@enervision.fr", "password": "..." } + +// TokenResponse, rendu par login, refresh et password +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 900, + "principal": { + "id": "3f2a...", + "email": "operateur@enervision.fr", + "role": "lecteur | operateur | admin", + "kind": "human", + "must_change_password": false + } +} + +// POST /auth/password +{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +``` + +Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. + +## Codes d'erreur à traiter + +| Code | Quand | Ce que fait le frontend | +|---|---|---| +| `401` sur `/auth/login` | identifiants faux, compte désactivé, compte inconnu | afficher le message générique tel quel, ne rien déduire de plus | +| `429` sur `/auth/login` | trop de tentatives | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `401` avec `WWW-Authenticate: ... error="expired"` | jeton d'accès périmé | **rafraîchir**, puis rejouer la requête | +| `401` avec `error="token_stale"` | rôle changé ou compte désactivé pendant la session | **rafraîchir** ; si le rafraîchissement échoue, déconnecter | +| `401` avec `error="invalid_token"` | jeton illisible ou compte disparu | déconnecter | +| `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion | +| `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe | +| `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | +| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | + +## Les quatre règles qui comptent + +**1. Le jeton d'accès ne se persiste jamais.** Ni `localStorage`, ni `sessionStorage`, ni +cookie : un signal dans un service racine. Un rechargement de page le perd, c'est voulu. + +**2. Au démarrage de l'application, appeler `/auth/refresh`.** C'est ce qui restaure la session +après un rechargement, via `provideAppInitializer`. Un `401` y est normal : il signifie +simplement qu'il n'y a pas de session, on affiche la page de connexion. + +**3. Un seul rafraîchissement en vol à la fois.** C'est une exigence, pas une optimisation. +Cinq requêtes parallèles qui prennent cinq fois `401` déclencheraient cinq rotations +concurrentes ; le serveur n'en accepte qu'une et considère les autres comme un rejeu, ce qui +**révoque toute la session**. L'utilisateur serait déconnecté à chaque chargement de page. + +```ts +// Dans l'intercepteur : une seule rotation partagée par tous les appelants. +private rotation$?: Observable; + +private rafraichir(): Observable { + this.rotation$ ??= this.http.post('/api/v1/auth/refresh', {}, { withCredentials: true }) + .pipe(finalize(() => (this.rotation$ = undefined)), shareReplay(1)); + return this.rotation$; +} +``` + +**4. Toutes les requêtes vers `/auth/*` portent `withCredentials: true`.** Sans quoi le cookie +n'est pas envoyé et le rafraîchissement échoue toujours. + +## Ce qu'il faut savoir sur le cookie + +- Nom `ev_refresh` en local, `__Secure-ev_refresh` ailleurs. Le code ne le lit jamais. +- `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`. Il n'est donc envoyé que sur ces routes. +- `Secure` dès que l'environnement n'est pas `local`, donc **HTTPS obligatoire hors poste de + développement**. +- `HttpOnly` empêche de voler le cookie, pas de s'en servir : une XSS peut appeler + `/auth/refresh` depuis l'origine de la victime. La vraie défense contre ce cas reste de ne pas + avoir de XSS. + +## Dev et production, le point à ne pas rater + +En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est +**même origine** et le cookie marche sans rien configurer. + +En production, `src/environments/environment.ts` contient encore le gabarit +`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun +cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.** + +Deux corrections, à faire avant la démonstration : + +1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un + `location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ; +2. servir en HTTPS. + +Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin +réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque. + +## Origines autorisées + +Le backend ne monte le middleware CORS que si `APP_CORS_ORIGINS` est renseigné, et refuse de +démarrer hors `local` si la liste est vide. Les routes portant le cookie vérifient en plus +l'en-tête `Origin` : une origine absente de la liste reçoit un `403`. + +Méthodes autorisées : `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `OPTIONS`. +En-têtes autorisés : `Authorization`, `Content-Type`. En-tête exposé : `Retry-After`. + +## Premier compte + +Créé en ligne de commande côté serveur (`make bootstrap-admin EMAIL=...`), avec +`must_change_password` à vrai. La première connexion renvoie donc `403 +password_change_required` sur toute route métier, et seuls `/auth/me` et `/auth/password` +répondent. L'écran de changement de mot de passe doit exister avant la démonstration. + +Idem pour tout compte créé par un administrateur : le mot de passe provisoire est affiché **une +seule fois** dans la réponse, il n'est plus jamais récupérable. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 2d53844..86d65d1 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -35,8 +35,8 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Une révision Alembic, `5353c0e4f094`, qui **ne crée aucune table**. Elle établit - `alembic_version` et refuse de s'appliquer si l'extension manque : +- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle + établit `alembic_version` et refuse de s'appliquer si l'extension manque : ```sql IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN @@ -47,6 +47,9 @@ END IF; Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. +Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, +puis `login_attempt` et `audit_log`, puis `refresh_token`. + ## Cycle de vie d'une mesure Statut : `Cible`. Aucun de ces maillons n'existe. @@ -65,7 +68,72 @@ flowchart LR Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. -## Modèle +## Tables d'authentification + +Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les +hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. + +```mermaid +erDiagram + APP_USER ||--o{ REFRESH_TOKEN : ouvre + APP_USER { + uuid id PK + string email UK + text password_hash + text role + text kind + bool is_active + bool must_change_password + timestamptz credentials_changed_at + } + REFRESH_TOKEN { + uuid id PK + uuid family_id + uuid user_id FK + bytea token_hash UK + timestamptz expires_at + timestamptz rotated_at + timestamptz revoked_at + text revoked_reason + uuid replaced_by + } + LOGIN_ATTEMPT { + bigint id PK + timestamptz occurred_at + string email_tried + inet client_ip + text outcome + } + AUDIT_LOG { + bigint id PK + timestamptz occurred_at + uuid actor_id + text actor_email + text action + jsonb detail + } +``` + +Quatre choix de modélisation portent une intention et se défendent seuls : + +- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de + `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition + au rôle PostgreSQL qui portera le cantonnement de l'ETL. +- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le + changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui + lit un audit. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il + glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. +- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont + dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur + d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). + +`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est +donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils +refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est +piloté par l'attaquant. + +## Modèle métier Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a23be40..c6b91f0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,12 +12,17 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. | [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms | | [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration | | [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | +| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | -L'observabilité, la sécurité et la CI/CD n'ont pas de document propre : ce sont des sections des -cinq ci-dessus, tant que `monitoring/`, `.github/workflows/` et `etl/airflow/` ne contiennent que -des `.gitkeep`. Elles en sortiront le jour où elles auront de la matière. Un fichier vide de plus -n'aide personne. +L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents +ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en +sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne. + +La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans +[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la +traçabilité OWASP dans [owasp-traceabilite.md](owasp-traceabilite.md), et les décisions dans les +ADR 0002 à 0004. ## Conventions diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md new file mode 100644 index 0000000..ada1a45 --- /dev/null +++ b/docs/architecture/owasp-traceabilite.md @@ -0,0 +1,70 @@ +# Traçabilité OWASP + +Ce document remplace la revendication « couverture OWASP Top 10 et OWASP API Security Top 10 » +de la NFR4 du dossier EC01. Cette formulation est indéfendable telle quelle : vingt items, non +vérifiables en deux semaines, et « montrez-moi votre couverture de A04 Insecure Design » n'a pas +de réponse honnête. + +Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse, +et une section qui dit ce qui n'est pas couvert et pourquoi. + +Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier +n'existent pas encore, donc plusieurs lignes resteront à compléter. + +## Contrôles en place + +| Contrôle | Où | Item adressé | +|---|---|---| +| Interdire par défaut, liste blanche de routes publiques vérifiée par un test qui appelle réellement chaque route | `tests/api/test_route_protection.py` | API5 Broken Function Level Authorization, A01 Broken Access Control | +| RBAC à trois rôles ordonnés, décision prise sur la ligne en base et jamais sur le claim | `app/api/deps.py` | A01, API5 | +| Révocation immédiate : compte relu à chaque requête, `credentials_changed_at` invalide les jetons antérieurs | `app/api/deps.py`, `app/repositories/user.py` | A01, API2 Broken Authentication | +| Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures | +| Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 | +| Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption | +| Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 | +| Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 | +| Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 | +| Algorithme épinglé, `aud`, `iss` et `typ` vérifiés, `alg: none` rejeté | `app/core/security.py` | A02, API2 | +| Cookie `HttpOnly`, `Secure`, `SameSite=Strict`, `Path` restreint, suppression symétrique | `app/core/cookies.py` | A05 Security Misconfiguration | +| Vérification d'`Origin` sur les trois routes portant le cookie | `app/api/deps.py` | A01 | +| Schémas de lecture et d'écriture séparés, aucun modèle ORM en réponse | `app/schemas/user.py` | API3 Broken Object Property Level Authorization | +| Validation stricte Pydantic en entrée, mot de passe borné à 128 caractères | `app/schemas/auth.py` | A03 Injection, API4 | +| Requêtes paramétrées par SQLAlchemy, aucune concaténation SQL | `app/repositories/` | A03 | +| Réponse 422 qui ne renvoie jamais la valeur rejetée | `app/api/errors.py` | A09 Security Logging and Monitoring Failures | +| Réponse 500 générique avec identifiant de corrélation, trace côté serveur seulement | `app/api/errors.py` | A05 | +| Journal d'audit en ajout seul garanti par déclencheurs, liste blanche des clés de détail | ADR 0004, `app/repositories/audit_log.py` | A09 | +| 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 | +| 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 | +| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | + +Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des +règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01. + +## Non couvert, et pourquoi + +| Item | État | Raison | +|---|---|---| +| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | +| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | +| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | +| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | +| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | +| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | +| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. | +| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. | + +## Ce qu'il faut répondre, et ne pas répondre + +Sur A04 Insecure Design, la réponse n'est pas une case cochée mais deux décisions concrètes : le +refus du verrouillage de compte, qui aurait été un déni de service, et le refus de laisser un +administrateur se verrouiller lui-même dehors. + +Sur l'audit, ne jamais prétendre que la table est inviolable : elle ne l'est pas contre un compte +qui a les droits sur la base, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. + +Sur l'API Mock, ne jamais répondre « c'est un mock, ce n'est pas notre périmètre ». C'est +précisément le périmètre : c'est la frontière de confiance. From bfbd9ee2cc798f556dfb5d73f84693097c581a56 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 15:06:20 +0200 Subject: [PATCH 038/205] test(backend): couvre le changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthService.change_password` n'avait aucun test unitaire, alors qu'il porte la promesse que l'appareil courant reste connecté pendant que tous les autres tombent. Deux cas : le nominal, où une seule session est rouverte après la révocation, et le refus quand le mot de passe actuel est faux, qui ne doit rien révoquer. --- apps/backend/tests/services/test_auth.py | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 01697d8..9b8c42c 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -56,6 +56,7 @@ class FauxDepotComptes: self.compte = compte self.rehachages = 0 self.connexions_datees = 0 + self.mots_de_passe_changes = 0 async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte @@ -66,6 +67,9 @@ class FauxDepotComptes: async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 + async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None: + self.mots_de_passe_changes += 1 + async def touch_last_login(self, user_id: UUID) -> None: self.connexions_datees += 1 @@ -438,3 +442,54 @@ def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: empreinte = fingerprint_refresh(secret) assert secret.encode() not in empreinte + + +async def test_change_password_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=True, + ) + + session = await attirail.service.change_password( + principal=acteur, + current_password="l-ancien-mot-de-passe", + new_password="le-nouveau-mot-de-passe", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1, "l'appareil courant doit repartir avec une session" + assert session.refresh_secret + assert "password_changed" in attirail.audit.lignes[0][0] + + +async def test_change_password_refuses_a_wrong_current_password() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte, hacheur=FauxHacheur(accepte=False)) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + with pytest.raises(InvalidCredentialsError): + await attirail.service.change_password( + principal=acteur, + current_password="mauvais", + new_password="le-nouveau-mot-de-passe", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] + assert attirail.jetons.crees == [] From b8f806518fa7f854677d449f4f32fda047883cfd Mon Sep 17 00:00:00 2001 From: ineszang Date: Tue, 15 Sep 2026 16:08:31 +0200 Subject: [PATCH 039/205] feat(frontend): ajout sonarqube dans le pipeline --- .github/workflows/dev-front-pipeline.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/dev-front-pipeline.yml b/.github/workflows/dev-front-pipeline.yml index 1912e1a..28abac2 100644 --- a/.github/workflows/dev-front-pipeline.yml +++ b/.github/workflows/dev-front-pipeline.yml @@ -12,6 +12,7 @@ on: description: "Choix du job" options: - build + - sonarqube - test - deploy - all @@ -40,6 +41,18 @@ jobs: # echo Add other actions to build, # echo test, and deploy your project. + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + test: if: ${{ github.event.inputs.job_choice == 'test' }} runs-on: ubuntu-latest From b300be5186b7164e66651d3831c8121e93b349b7 Mon Sep 17 00:00:00 2001 From: ineszang Date: Tue, 15 Sep 2026 16:10:18 +0200 Subject: [PATCH 040/205] chore: init de la config du frontend sur docker compose --- docker-compose.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index d8569c9..1a3983c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,5 +43,18 @@ services: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped + frontend: + build: ./apps/frontend + # si backend fonctionnel + depends_on: + backend: + condition: service_healthy + environment: + + ports: + - "${FRONTEND_PORT:-3000}:80" + restart: unless-stopped + + volumes: pgdata: From 2390e58f78fae88e028f5b623bb0183046cf9fa1 Mon Sep 17 00:00:00 2001 From: ineszang Date: Tue, 15 Sep 2026 16:10:39 +0200 Subject: [PATCH 041/205] chore: sonarqube --- sonar-project.properties | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..3c1af86 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.projectKey=ProjetPiscine_EnerVision +sonar.organization=groupe3-ener-vision + + +# This is the name and version displayed in the SonarCloud UI. +#sonar.projectName=ProjetPiscine_EnerVision +#sonar.projectVersion=1.0 + + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +#sonar.sources=. + +# Encoding of the source code. Default is default system encoding +#sonar.sourceEncoding=UTF-8 \ No newline at end of file From b032f084fcc03078c13eb0f5a8d1629af00712c7 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:15:21 +0200 Subject: [PATCH 042/205] fix(apps): gere les caracteres encodes dans l'URL Alembic --- apps/backend/alembic/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index 7b09cae..a1a4adc 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -19,7 +19,7 @@ config = context.config if config.config_file_name is not None: fileConfig(config.config_file_name) -config.set_main_option("sqlalchemy.url", get_settings().database_url) +config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%")) target_metadata = Base.metadata From 128133761f7259dc19feb29c2823ceb641023d17 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:16:18 +0200 Subject: [PATCH 043/205] feat(apps): cree les six tables data et l'hypertable readings --- .../e6d2026091501_create_data_schema.py | 216 ++++++++++++++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/energy.py | 209 +++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 apps/backend/alembic/versions/e6d2026091501_create_data_schema.py create mode 100644 apps/backend/app/models/energy.py diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py new file mode 100644 index 0000000..87146d6 --- /dev/null +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -0,0 +1,216 @@ +"""Création des six tables Data et de l'hypertable readings. + +Revision ID: e6d2026091501 +Revises: 5353c0e4f094 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "e6d2026091501" +down_revision = "5353c0e4f094" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "datasets", + sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("dataset_name", sa.Text(), nullable=False), + sa.Column("archive_sha256", sa.String(length=64), nullable=False), + sa.Column("storage_uri", sa.Text(), nullable=False), + sa.Column("source_timezone", sa.Text(), nullable=True), + sa.Column( + "metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + sa.PrimaryKeyConstraint("dataset_id"), + sa.UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + ) + op.create_table( + "sites", + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("site_name", sa.Text(), nullable=False), + sa.Column("site_type", sa.Text(), nullable=False), + sa.Column("location", sa.Text(), nullable=True), + sa.Column("capacity_kw", sa.Double(), nullable=True), + sa.Column("status", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("site_id"), + ) + op.create_table( + "predictions", + sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("target_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("target_metric", sa.Text(), nullable=False), + sa.Column("period_minutes", sa.Integer(), nullable=True), + sa.Column("predicted_value", sa.Double(), nullable=True), + sa.Column("model_reference", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("failure_reason", sa.Text(), nullable=True), + sa.CheckConstraint( + "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)", + name="ck_predictions_status", + ), + sa.CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_predictions_energy_period", + ), + sa.CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_predictions_metric" + ), + sa.CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_predictions_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("prediction_id"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + ) + op.create_index( + "ix_predictions_site_target", "predictions", ["site_id", "target_at"], unique=False + ) + op.create_table( + "readings", + sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("dataset_id", sa.BigInteger(), nullable=True), + sa.Column("consumption_kw", sa.Double(), nullable=True), + sa.Column("consumption_kwh", sa.Double(), nullable=True), + sa.Column("consumption_euros", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("voltage_v", sa.Double(), nullable=True), + sa.Column("current_a", sa.Double(), nullable=True), + sa.Column("power_factor", sa.Double(), nullable=True), + sa.Column("temperature_celsius", sa.Double(), nullable=True), + sa.Column("humidity_percent", sa.Double(), nullable=True), + sa.Column("solar_irradiance_wm2", sa.Double(), nullable=True), + sa.Column("is_working_hours", sa.Boolean(), nullable=True), + sa.Column("data_quality", sa.Text(), nullable=True), + sa.Column("null_reasons", postgresql.ARRAY(sa.Text()), nullable=True), + sa.Column( + "imputed_values", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True + ), + sa.Column("imputation_method", sa.Text(), nullable=True), + sa.Column( + "ingested_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint( + "(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)", + name="ck_readings_dataset_source", + ), + sa.CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_readings_quality", + ), + sa.CheckConstraint( + "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + ), + sa.CheckConstraint( + "(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", + name="ck_readings_imputation", + ), + sa.ForeignKeyConstraint( + ["dataset_id"], ["datasets.dataset_id"], name="fk_readings_dataset", ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_readings_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("reading_id", "timestamp"), + ) + op.create_index("ix_readings_dataset_id", "readings", ["dataset_id"], unique=False) + op.create_index( + "ix_readings_site_timestamp", "readings", ["site_id", "timestamp"], unique=False + ) + op.create_index( + "uq_readings_source", + "readings", + ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], + unique=True, + ) + op.execute( + "SELECT create_hypertable('readings', by_range('timestamp'), create_default_indexes => FALSE)" + ) + op.create_table( + "alerts", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("alert_id", sa.Text(), nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("type", sa.Text(), nullable=False), + sa.Column("severity", sa.Text(), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("value", sa.Double(), nullable=True), + sa.Column("threshold", sa.Double(), nullable=True), + sa.Column("metric", sa.Text(), nullable=True), + sa.Column("prediction_id", sa.BigInteger(), nullable=True), + sa.Column( + "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + ), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + sa.CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + ), + sa.ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["predictions.prediction_id", "predictions.site_id"], + name="fk_alerts_prediction_site", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_alerts_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), + ) + op.create_index("ix_alerts_site_timestamp", "alerts", ["site_id", "timestamp"], unique=False) + op.create_table( + "recommendations", + sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("alert_id", sa.BigInteger(), nullable=False), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("explanation", sa.Text(), nullable=False), + sa.Column("rule_reference", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["alert_id"], ["alerts.id"], name="fk_recommendations_alert", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("recommendation_id"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + op.drop_table("recommendations") + op.drop_table("alerts") + op.drop_table("readings") + op.drop_table("predictions") + op.drop_table("sites") + op.drop_table("datasets") diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 6d71227..0f48e79 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,6 @@ # Piege : tout modele absent de ce module reste invisible de `alembic revision # --autogenerate`, qui genererait alors un drop de sa table. + +from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site + +__all__ = ["Alert", "Dataset", "Prediction", "Reading", "Recommendation", "Site"] diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py new file mode 100644 index 0000000..de27c7c --- /dev/null +++ b/apps/backend/app/models/energy.py @@ -0,0 +1,209 @@ +"""Tables du modèle de données EnerVision (CSV, API Mock et résultats ML).""" + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + Double, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Dataset(Base): + __tablename__ = "datasets" + __table_args__ = ( + CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + ) + + dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dataset_name: Mapped[str] = mapped_column(Text) + archive_sha256: Mapped[str] = mapped_column(String(64)) + storage_uri: Mapped[str] = mapped_column(Text) + source_timezone: Mapped[str | None] = mapped_column(Text) + # "metadata" est réservé par SQLAlchemy ; le nom SQL reste inchangé. + dataset_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB(none_as_null=True)) + + +class Site(Base): + __tablename__ = "sites" + + site_id: Mapped[str] = mapped_column(Text, primary_key=True) + site_name: Mapped[str] = mapped_column(Text) + site_type: Mapped[str] = mapped_column(Text) + location: Mapped[str | None] = mapped_column(Text) + capacity_kw: Mapped[float | None] = mapped_column(Double) + status: Mapped[str | None] = mapped_column(Text) + + +class Reading(Base): + __tablename__ = "readings" + __table_args__ = ( + CheckConstraint( + "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + ), + CheckConstraint( + "(source = 'csv' AND dataset_id IS NOT NULL) OR " + "(source IN ('api_current', 'api_history') AND dataset_id IS NULL)", + name="ck_readings_dataset_source", + ), + CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_readings_quality", + ), + CheckConstraint( + "(imputed_values IS NULL AND imputation_method IS NULL) OR " + "(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", + name="ck_readings_imputation", + ), + Index("ix_readings_site_timestamp", "site_id", "timestamp"), + Index("ix_readings_dataset_id", "dataset_id"), + ) + + reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_readings_site", ondelete="RESTRICT") + ) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True) + source: Mapped[str] = mapped_column(Text) + dataset_id: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("datasets.dataset_id", name="fk_readings_dataset", ondelete="RESTRICT"), + ) + consumption_kw: Mapped[float | None] = mapped_column(Double) + consumption_kwh: Mapped[float | None] = mapped_column(Double) + consumption_euros: Mapped[Decimal | None] = mapped_column(Numeric(14, 2)) + voltage_v: Mapped[float | None] = mapped_column(Double) + current_a: Mapped[float | None] = mapped_column(Double) + power_factor: Mapped[float | None] = mapped_column(Double) + temperature_celsius: Mapped[float | None] = mapped_column(Double) + humidity_percent: Mapped[float | None] = mapped_column(Double) + solar_irradiance_wm2: Mapped[float | None] = mapped_column(Double) + is_working_hours: Mapped[bool | None] = mapped_column(Boolean) + data_quality: Mapped[str | None] = mapped_column(Text) + null_reasons: Mapped[list[str] | None] = mapped_column(ARRAY(Text)) + imputed_values: Mapped[dict[str, Any] | None] = mapped_column(JSONB(none_as_null=True)) + imputation_method: Mapped[str | None] = mapped_column(Text) + ingested_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True)) + + +Index( + "uq_readings_source", + Reading.site_id, + Reading.timestamp, + Reading.source, + func.coalesce(Reading.dataset_id, text("0")), + unique=True, +) + + +class Prediction(Base): + __tablename__ = "predictions" + __table_args__ = ( + UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + Index("ix_predictions_site_target", "site_id", "target_at"), + CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", + name="ck_predictions_metric", + ), + CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + ), + CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_predictions_energy_period", + ), + CheckConstraint( + "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR " + "(status IN ('insufficient_data', 'error') AND predicted_value IS NULL " + "AND failure_reason IS NOT NULL)", + name="ck_predictions_status", + ), + ) + + prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_predictions_site", ondelete="RESTRICT") + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + target_metric: Mapped[str] = mapped_column(Text) + period_minutes: Mapped[int | None] = mapped_column(Integer) + predicted_value: Mapped[float | None] = mapped_column(Double) + model_reference: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + failure_reason: Mapped[str | None] = mapped_column(Text) + + +class Alert(Base): + __tablename__ = "alerts" + __table_args__ = ( + UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), + Index("ix_alerts_site_timestamp", "site_id", "timestamp"), + ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["predictions.prediction_id", "predictions.site_id"], + name="fk_alerts_prediction_site", + ondelete="RESTRICT", + ), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + ), + CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + alert_id: Mapped[str] = mapped_column(Text) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_alerts_site", ondelete="RESTRICT") + ) + source: Mapped[str] = mapped_column(Text) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + type: Mapped[str] = mapped_column(Text) + severity: Mapped[str] = mapped_column(Text) + message: Mapped[str] = mapped_column(Text) + value: Mapped[float | None] = mapped_column(Double) + threshold: Mapped[float | None] = mapped_column(Double) + metric: Mapped[str | None] = mapped_column(Text) + prediction_id: Mapped[int | None] = mapped_column(BigInteger) + raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True)) + + +class Recommendation(Base): + __tablename__ = "recommendations" + __table_args__ = ( + UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + ) + + recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + alert_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("alerts.id", name="fk_recommendations_alert", ondelete="RESTRICT") + ) + action: Mapped[str] = mapped_column(Text) + 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()) From c04ce9a9aecb19fbb67cace3bcd285cf939a22bc Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:16:40 +0200 Subject: [PATCH 044/205] test(apps): verifie les contraintes du schema data --- apps/backend/tests/db/test_data_schema.py | 261 ++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 apps/backend/tests/db/test_data_schema.py diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py new file mode 100644 index 0000000..aefc9fa --- /dev/null +++ b/apps/backend/tests/db/test_data_schema.py @@ -0,0 +1,261 @@ +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import insert, select, text +from sqlalchemy.engine import make_url +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 + +pytestmark = pytest.mark.integration +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +@pytest.fixture +async def data_connection() -> AsyncIterator[AsyncConnection]: + url = make_url(get_settings().database_url) + if url.database != "enervision_test": + pytest.fail("Ces tests exigent DATABASE_URL vers enervision_test.") + engine = create_async_engine(url) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + try: + yield connection + finally: + await transaction.rollback() + finally: + await engine.dispose() + + +@pytest.fixture +async def data_site(data_connection: AsyncConnection) -> str: + site_id = f"TEST-{uuid4()}" + await data_connection.execute( + insert(Site).values(site_id=site_id, site_name="Site de test", site_type="office") + ) + return site_id + + +async def test_readings_is_a_time_hypertable_when_migrated( + data_connection: AsyncConnection, +) -> None: + query = text( + "SELECT column_name FROM timescaledb_information.dimensions " + "WHERE hypertable_schema = 'public' AND hypertable_name = 'readings'" + ) + + result = await data_connection.execute(query) + + assert result.scalars().all() == ["timestamp"] + + +async def test_reading_preserves_null_and_zero_when_inserted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = insert(Reading).values( + site_id=data_site, + timestamp=MOMENT, + source="api_current", + consumption_kw=None, + consumption_kwh=0, + data_quality="partial", + null_reasons=["sensor_failure"], + raw_data={"consumption_kw": None}, + imputed_values=None, + imputation_method=None, + ) + + await data_connection.execute(statement) + result = ( + await data_connection.execute( + select( + Reading.consumption_kw, + Reading.consumption_kwh, + Reading.raw_data, + Reading.imputed_values, + ).where(Reading.site_id == data_site) + ) + ).one() + + assert tuple(result) == (None, 0, {"consumption_kw": None}, None) + + +@pytest.mark.parametrize("source", ["csv", "api_current", "api_history"]) +async def test_duplicate_reading_is_rejected_when_key_matches( + data_connection: AsyncConnection, data_site: str, source: str +) -> None: + dataset_id = None + if source == "csv": + dataset_id = ( + await data_connection.execute( + insert(Dataset.__table__) + .values( + dataset_name="Archive de test", + archive_sha256=uuid4().hex + uuid4().hex, + storage_uri="test://archive", + metadata={}, + ) + .returning(Dataset.dataset_id) + ) + ).scalar_one() + statement = insert(Reading).values( + site_id=data_site, + timestamp=MOMENT, + source=source, + dataset_id=dataset_id, + raw_data={}, + ) + await data_connection.execute(statement) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) + + +@pytest.mark.parametrize( + "changes", + [ + {"source": "csv"}, + {"source": "unknown"}, + {"site_id": "UNKNOWN-SITE"}, + {"data_quality": "unknown"}, + {"imputed_values": {"consumption_kw": 12}}, + {"imputation_method": "mean-v1"}, + ], + ids=[ + "csv_sans_dataset", + "source_inconnue", + "site_absent", + "qualite_inconnue", + "imputation_sans_methode", + "methode_sans_imputation", + ], +) +async def test_invalid_reading_is_rejected_when_constraints_fail( + data_connection: AsyncConnection, data_site: str, changes: dict[str, object] +) -> None: + values: dict[str, object] = { + "site_id": data_site, + "timestamp": MOMENT, + "source": "api_current", + "raw_data": {}, + } + values.update(changes) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(insert(Reading).values(**values)) + + +async def test_prediction_requires_period_when_energy_is_predicted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = insert(Prediction).values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kwh", + predicted_value=12, + status="available", + model_reference="test-model/1", + ) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) + + +async def test_unavailable_prediction_preserves_null_when_inserted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = ( + insert(Prediction) + .values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kw", + status="insufficient_data", + failure_reason="Historique trop court", + model_reference="test-model/1", + ) + .returning(Prediction.predicted_value) + ) + + value = (await data_connection.execute(statement)).scalar_one() + + assert value is None + + +async def test_alert_rejects_prediction_when_site_differs( + data_connection: AsyncConnection, data_site: str +) -> None: + other_site = f"TEST-{uuid4()}" + await data_connection.execute( + insert(Site).values(site_id=other_site, site_name="Autre site", site_type="office") + ) + prediction_id = ( + await data_connection.execute( + insert(Prediction) + .values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kw", + predicted_value=12, + status="available", + model_reference="test-model/1", + ) + .returning(Prediction.prediction_id) + ) + ).scalar_one() + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute( + insert(Alert).values( + alert_id=str(uuid4()), + site_id=other_site, + source="enervision", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + prediction_id=prediction_id, + raw_data={}, + ) + ) + + +async def test_recommendation_is_unique_when_alert_and_rule_match( + data_connection: AsyncConnection, data_site: str +) -> None: + alert_id = ( + await data_connection.execute( + insert(Alert) + .values( + alert_id=str(uuid4()), + site_id=data_site, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + .returning(Alert.id) + ) + ).scalar_one() + statement = insert(Recommendation).values( + alert_id=alert_id, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + ) + await data_connection.execute(statement) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) From ff6e3c288ca467792767a47acad897332276b4f4 Mon Sep 17 00:00:00 2001 From: ineszang Date: Tue, 15 Sep 2026 16:35:50 +0200 Subject: [PATCH 045/205] feat(frontend): ajout du job de build --- .github/workflows/dev-front-pipeline.yml | 47 ++++++++++++------------ 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/.github/workflows/dev-front-pipeline.yml b/.github/workflows/dev-front-pipeline.yml index 28abac2..17f4010 100644 --- a/.github/workflows/dev-front-pipeline.yml +++ b/.github/workflows/dev-front-pipeline.yml @@ -24,23 +24,6 @@ on: jobs: - build: - if: ${{ github.event.inputs.job_choice == 'build' }} - # The type of runner that the job will run on - runs-on: ubuntu-latest - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v4 - # Runs a single command using the runners shell - - name: Run a one-line script - run: echo Hello, world! - # Runs a set of commands using the runners shell - # - name: Run a multi-line script - # run: | - # echo Add other actions to build, - # echo test, and deploy your project. - sonarqube: name: SonarQube runs-on: ubuntu-latest @@ -57,10 +40,28 @@ jobs: if: ${{ github.event.inputs.job_choice == 'test' }} runs-on: ubuntu-latest steps: - - run: echo "TEST job is running" + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm test -- --watch=false + + # build: + # # si l'utilisateur a choise le job 'build' ou l'ensemble des jobs avec l'option 'all' + # if: ${{ github.event.inputs.job_choice == 'build' || github.event.inputs.job_choice == 'all' }} + # runs-on: ubuntu-latest + # steps: + # - uses: actions/setup-node@v4 + # with: + # node-version: 24 + # cache: npm + + # - run: npm ci + # - run: npm run build - deploy: - if: ${{ github.event.inputs.job_choice == 'deploy' }} - runs-on: ubuntu-latest - steps: - - run: echo "DEPLOY job is running" + # deploy: + # if: ${{ github.event.inputs.job_choice == 'deploy' }} + # runs-on: ubuntu-latest + # steps: + # - run: echo "DEPLOY job is running" From 3ef7de5baac425c0e4088b96819fae275e66bc07 Mon Sep 17 00:00:00 2001 From: ineszang Date: Tue, 15 Sep 2026 16:46:16 +0200 Subject: [PATCH 046/205] feat(frontend): ajout chemins dans le pipeline CI --- .../{dev-front-pipeline.yml => frontend.yml} | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) rename .github/workflows/{dev-front-pipeline.yml => frontend.yml} (66%) diff --git a/.github/workflows/dev-front-pipeline.yml b/.github/workflows/frontend.yml similarity index 66% rename from .github/workflows/dev-front-pipeline.yml rename to .github/workflows/frontend.yml index 17f4010..923baa1 100644 --- a/.github/workflows/dev-front-pipeline.yml +++ b/.github/workflows/frontend.yml @@ -16,11 +16,14 @@ on: - test - deploy - all - - # push: - # branches: [ "dev" ] + push: + paths: + - "apps/frontend/**" + - ".github/workflows/dev-front-pipeline.yml" pull_request: - branches: [ "dev" ] + paths: + - "apps/frontend/**" + - ".github/workflows/dev-front-pipeline.yml" jobs: @@ -40,6 +43,7 @@ jobs: if: ${{ github.event.inputs.job_choice == 'test' }} runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 @@ -47,18 +51,19 @@ jobs: - run: npm ci - run: npm test -- --watch=false - # build: - # # si l'utilisateur a choise le job 'build' ou l'ensemble des jobs avec l'option 'all' - # if: ${{ github.event.inputs.job_choice == 'build' || github.event.inputs.job_choice == 'all' }} - # runs-on: ubuntu-latest - # steps: - # - uses: actions/setup-node@v4 - # with: - # node-version: 24 - # cache: npm + build: + # si l'utilisateur a choise le job 'build' ou l'ensemble des jobs avec l'option 'all' + if: ${{ github.event.inputs.job_choice == 'build' || github.event.inputs.job_choice == 'all' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm - # - run: npm ci - # - run: npm run build + - run: npm ci + - run: npm run build # deploy: # if: ${{ github.event.inputs.job_choice == 'deploy' }} From e3e0e843d086c7b783763e4f9af135e9b2d0191f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 16:46:37 +0200 Subject: [PATCH 047/205] fix(backend): rebranche la revision data sur la tete d'authentification Le merge de dev apporte trois revisions d'authentification qui partent de la meme racine 5353c0e4f094 que la revision data. Git ne signale rien, mais alembic upgrade head refuse de choisir entre deux tetes. La revision data se greffe desormais sur 821f71be74c0, ce qui rend la chaine lineaire. --- .../alembic/versions/e6d2026091501_create_data_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py index 87146d6..50cc41f 100644 --- a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -1,7 +1,7 @@ """Création des six tables Data et de l'hypertable readings. Revision ID: e6d2026091501 -Revises: 5353c0e4f094 +Revises: 821f71be74c0 """ from alembic import op @@ -9,7 +9,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql revision = "e6d2026091501" -down_revision = "5353c0e4f094" +down_revision = "821f71be74c0" branch_labels = None depends_on = None From cdef30736a6e3090ce5bc64254d08ec10b6ce02c Mon Sep 17 00:00:00 2001 From: valentin Date: Tue, 15 Sep 2026 16:48:55 +0200 Subject: [PATCH 048/205] Creation dashboard (graph chart.js) + tests --- apps/frontend/.gitignore | 1 + apps/frontend/package-lock.json | 19 + apps/frontend/package.json | 1 + apps/frontend/src/app/app.config.ts | 7 +- apps/frontend/src/app/app.html | 354 +----------------- apps/frontend/src/app/app.routes.ts | 5 +- apps/frontend/src/app/app.spec.ts | 7 - .../interceptors/mock-api-interceptor.spec.ts | 67 ++++ .../core/interceptors/mock-api-interceptor.ts | 30 ++ .../src/app/core/mocks/alerts.fixture.ts | 54 +++ .../app/core/mocks/stats-summary.fixture.ts | 67 ++++ .../app/core/services/alerts.service.spec.ts | 43 +++ .../src/app/core/services/alerts.service.ts | 13 + .../app/core/services/stats.service.spec.ts | 39 ++ .../src/app/core/services/stats.service.ts | 13 + .../src/app/features/dashboard/dashboard.html | 48 +++ .../src/app/features/dashboard/dashboard.scss | 130 +++++++ .../app/features/dashboard/dashboard.spec.ts | 41 ++ .../src/app/features/dashboard/dashboard.ts | 39 ++ .../consumption-gauge/consumption-gauge.html | 1 + .../consumption-gauge/consumption-gauge.scss | 6 + .../consumption-gauge.spec.ts | 34 ++ .../consumption-gauge/consumption-gauge.ts | 55 +++ .../site-load-chart/site-load-chart.html | 1 + .../site-load-chart/site-load-chart.scss | 4 + .../site-load-chart/site-load-chart.spec.ts | 38 ++ .../site-load-chart/site-load-chart.ts | 62 +++ .../src/app/shared/models/alert.model.ts | 13 + .../src/app/shared/models/stats.model.ts | 17 + .../environments/environment.development.ts | 3 +- apps/frontend/src/environments/environment.ts | 3 +- 31 files changed, 851 insertions(+), 364 deletions(-) create mode 100644 apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts create mode 100644 apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts create mode 100644 apps/frontend/src/app/core/mocks/alerts.fixture.ts create mode 100644 apps/frontend/src/app/core/mocks/stats-summary.fixture.ts create mode 100644 apps/frontend/src/app/core/services/alerts.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/alerts.service.ts create mode 100644 apps/frontend/src/app/core/services/stats.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/stats.service.ts create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.html create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.scss create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.spec.ts create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.ts create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts create mode 100644 apps/frontend/src/app/shared/models/alert.model.ts create mode 100644 apps/frontend/src/app/shared/models/stats.model.ts diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index 854acd5..e1b6f74 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -34,6 +34,7 @@ yarn-error.log .sass-cache/ /connect.lock /coverage +/test-results /libpeerconnection.log testem.log /typings diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 5ba6595..a60cacb 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -14,6 +14,7 @@ "@angular/forms": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -2038,6 +2039,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", @@ -4220,6 +4227,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 552e346..1c934bc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,6 +18,7 @@ "@angular/forms": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 2261369..d89a118 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,7 +1,12 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import {mockApiInterceptor} from './core/interceptors/mock-api-interceptor'; +import {provideHttpClient, withInterceptors} from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideHttpClient(withInterceptors([mockApiInterceptor])), + ], }; diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 4f4ddf5..0680b43 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -1,353 +1 @@ - - - - - - - - - - - -
- -
- - - - - - - - - - + diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index dc39edb..9852e8b 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,3 +1,6 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard) }, +]; diff --git a/apps/frontend/src/app/app.spec.ts b/apps/frontend/src/app/app.spec.ts index f13c264..75753d6 100644 --- a/apps/frontend/src/app/app.spec.ts +++ b/apps/frontend/src/app/app.spec.ts @@ -13,11 +13,4 @@ describe('App', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it('should render title', async () => { - const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); - }); }); 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 new file mode 100644 index 0000000..34a58b2 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { mockApiInterceptor } from './mock-api-interceptor'; +import { environment } from '../../../environments/environment'; +import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; + +describe('mockApiInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([mockApiInterceptor])), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + environment.useMockFixtures = true; + httpMock.verify(); + }); + + it('renvoie la fixture sans appel réseau quand useMockFixtures est activé', () => { + environment.useMockFixtures = true; + let result: unknown; + + http.get(`${environment.apiUrl}/stats/summary`).subscribe((r) => (result = r)); + + httpMock.expectNone(`${environment.apiUrl}/stats/summary`); + expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe( + STATS_SUMMARY_FIXTURE.total_sites + ); + }); + + it('laisse passer la vraie requête quand useMockFixtures est désactivé', () => { + environment.useMockFixtures = false; + + http.get(`${environment.apiUrl}/stats/summary`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`); + req.flush({}); + }); + + it("laisse passer une requête qui ne correspond à aucune route connue de l'interceptor", () => { + environment.useMockFixtures = true; + + http.get('/api/v1/autre-chose').subscribe(); + + const req = httpMock.expectOne('/api/v1/autre-chose'); + req.flush({}); + }); + + it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => { + environment.useMockFixtures = true; + let result: unknown; + + http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); + + httpMock.expectNone(`${environment.apiUrl}/alerts`); + expect((result as unknown[]).length).toBeGreaterThan(0); +}); +}); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts new file mode 100644 index 0000000..58287ff --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -0,0 +1,30 @@ +import { HttpInterceptorFn, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; +import { ALERTS_FIXTURE } from '../mocks/alerts.fixture'; + +function withJitter(base: typeof STATS_SUMMARY_FIXTURE) { + const jitter = () => (Math.random() - 0.5) * 40; + const totalConsumption = Math.max(0, base.total_consumption_kw + jitter()); + + return { + ...base, + timestamp: new Date().toISOString(), + total_consumption_kw: Math.round(totalConsumption * 100) / 100, + average_load_percent: Math.round((totalConsumption / base.total_capacity_kw) * 1000) / 10, + }; +} + +export const mockApiInterceptor: HttpInterceptorFn = (req, next) => { + if (!environment.useMockFixtures) { + return next(req); + } + if (req.url.endsWith(`${environment.apiUrl}/stats/summary`)) { + return of(new HttpResponse({ status: 200, body: withJitter(STATS_SUMMARY_FIXTURE) })); + } + if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { + return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); + } + return next(req); +}; diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts new file mode 100644 index 0000000..c1f7a9a --- /dev/null +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -0,0 +1,54 @@ +import { Alert } from '../../shared/models/alert.model'; + +export const ALERTS_FIXTURE: Alert[] = [ + { + alert_id: 'ALR-SITE002-1718458320', + timestamp: '2026-09-15T11:12:00', + site_id: 'SITE002', + severity: 'critical', + type: 'outage', + message: 'Risque de surcharge sur Usine Lyon Vénissieux', + value: 812.5, + threshold: 720.0, + }, + { + alert_id: 'ALR-SITE003-1718458321', + timestamp: '2026-09-15T11:05:00', + site_id: 'SITE003', + severity: 'critical', + type: 'sensor', + message: 'Perte réseau totale sur Data Center Marseille', + value: 0, + threshold: 0, + }, + { + alert_id: 'ALR-SITE005-1718458322', + timestamp: '2026-09-15T10:47:00', + site_id: 'SITE005', + severity: 'high', + type: 'threshold', + message: 'Usine Toulouse approche de son seuil de capacité', + value: 410.0, + threshold: 480.0, + }, + { + alert_id: 'ALR-SITE006-1718458323', + timestamp: '2026-09-15T10:30:00', + site_id: 'SITE006', + severity: 'medium', + type: 'sensor', + message: 'Capteur de température défaillant sur Bureau Lille', + value: 0, + threshold: 0, + }, + { + alert_id: 'ALR-SITE004-1718458324', + timestamp: '2026-09-15T09:58:00', + site_id: 'SITE004', + severity: 'low', + type: 'anomaly', + message: 'Comportement de consommation inhabituel sur Bureau Bordeaux', + value: 62.0, + threshold: 55.0, + }, +]; diff --git a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts new file mode 100644 index 0000000..7057fb8 --- /dev/null +++ b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts @@ -0,0 +1,67 @@ +import { StatsSummary } from '../../shared/models/stats.model'; + +export const STATS_SUMMARY_FIXTURE: StatsSummary = { + timestamp: '2026-09-15T11:32:00', + total_sites: 7, + total_consumption_kw: 1826.44, + total_capacity_kw: 3830, + average_load_percent: 55.1, + sites: [ + { + site_id: 'SITE001', + site_name: 'Bureau Paris La Défense', + current_consumption_kw: 87.34, + capacity_kw: 200, + load_percent: 43.7, + data_quality: 'good', + }, + { + site_id: 'SITE002', + site_name: 'Usine Lyon Vénissieux', + current_consumption_kw: 542.10, + capacity_kw: 1000, + load_percent: 54.2, + data_quality: 'good', + }, + { + site_id: 'SITE003', + site_name: 'Data Center Marseille', + current_consumption_kw: null, + capacity_kw: 800, + load_percent: null, + data_quality: 'critical', + }, + { + site_id: 'SITE004', + site_name: 'Bureau Bordeaux', + current_consumption_kw: 62.0, + capacity_kw: 150, + load_percent: 41.3, + data_quality: 'partial', + }, + { + site_id: 'SITE005', + site_name: 'Usine Toulouse', + current_consumption_kw: 410.0, + capacity_kw: 600, + load_percent: 68.3, + data_quality: 'good', + }, + { + site_id: 'SITE006', + site_name: 'Bureau Lille', + current_consumption_kw: 95.0, + capacity_kw: 180, + load_percent: 52.8, + data_quality: 'degraded', + }, + { + site_id: 'SITE007', + site_name: 'Data Center Nantes', + current_consumption_kw: 630.0, + capacity_kw: 900, + load_percent: 70.0, + data_quality: 'good', + }, + ], +}; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts new file mode 100644 index 0000000..68b5740 --- /dev/null +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { AlertsService } from './alerts.service'; +import { environment } from '../../../environments/environment'; + +describe('AlertsService', () => { + let service: AlertsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(AlertsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("appelle le bon endpoint et retourne un tableau d'alertes", () => { + let result: unknown; + service.getAlerts().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); + expect(req.request.method).toBe('GET'); + + 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 as unknown[]).length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts new file mode 100644 index 0000000..ebd00e2 --- /dev/null +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Alert } from '../../shared/models/alert.model'; + +@Service() +export class AlertsService { + private http = inject(HttpClient); + + getAlerts() { + return this.http.get(`${environment.apiUrl}/alerts`); + } +} diff --git a/apps/frontend/src/app/core/services/stats.service.spec.ts b/apps/frontend/src/app/core/services/stats.service.spec.ts new file mode 100644 index 0000000..a65c38f --- /dev/null +++ b/apps/frontend/src/app/core/services/stats.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { StatsService } from './stats.service'; +import { environment } from '../../../environments/environment'; + +describe('StatsService', () => { + let service: StatsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(StatsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne le résumé', () => { + let result: unknown; + service.getSummary().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-15T12:00:00', + total_sites: 7, + total_consumption_kw: 1800, + total_capacity_kw: 3800, + average_load_percent: 47.4, + sites: [], + }); + + expect((result as { total_sites: number }).total_sites).toBe(7); + }); +}); diff --git a/apps/frontend/src/app/core/services/stats.service.ts b/apps/frontend/src/app/core/services/stats.service.ts new file mode 100644 index 0000000..4cb630f --- /dev/null +++ b/apps/frontend/src/app/core/services/stats.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { StatsSummary } from '../../shared/models/stats.model'; + +@Service() +export class StatsService { + private http = inject(HttpClient); + + getSummary() { + return this.http.get(`${environment.apiUrl}/stats/summary`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html new file mode 100644 index 0000000..70b333c --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,48 @@ +
+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+ + @if (stats(); as s) { +
+
+ Consommation vs capacité + + {{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW +
+ +
+ Charge moyenne du parc + {{ s.average_load_percent }} % +
+
+
+
+ +
+ Sites suivis + {{ s.total_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 }} +
  • + } +
+
+ } +
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss new file mode 100644 index 0000000..d0b1088 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -0,0 +1,130 @@ +:host { + --color-good: #2e7d32; + --color-partial: #f9a825; + --color-degraded: #ef6c00; + --color-critical: #c62828; + --color-bg-card: #ffffff; + --color-border: #e5e7eb; + --color-text-muted: #6b7280; + --radius: 10px; + + display: block; + font-family: 'Segoe UI', system-ui, sans-serif; + color: #1f2937; + padding: 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.dashboard__header { + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.dashboard__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.progress-bar { + height: 6px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; + margin-top: 0.25rem; +} + +.progress-bar__fill { + height: 100%; + background: #3b82f6; + border-radius: 999px; + transition: width 0.3s ease; +} + +.chart-section { + margin-bottom: 2.5rem; +} + +.alerts-list { + list-style: none; + 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); + background: #fef2f2; + border: 1px solid #fecaca; +} + +.alert-item__badge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 999px; + color: #fff; + background: var(--color-critical); + flex-shrink: 0; +} + +.alert-item--high .alert-item__badge { background: var(--color-degraded); } +.alert-item--medium .alert-item__badge { background: var(--color-partial); } +.alert-item--low .alert-item__badge { background: var(--color-good); } + +.alert-item__message { + font-size: 0.9rem; +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts new file mode 100644 index 0000000..7dcbe37 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { of } from 'rxjs'; +import { Dashboard } from './dashboard'; +import { StatsService } from '../../core/services/stats.service'; +import { AlertsService } from '../../core/services/alerts.service'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('Dashboard', () => { + it('charge les stats et les alertes au démarrage', async () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + 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(fixture.componentInstance.alerts().length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts new file mode 100644 index 0000000..2fe7768 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -0,0 +1,39 @@ +import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { timer, switchMap } from 'rxjs'; +import { DecimalPipe } from '@angular/common'; +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 {StatsSummary} from '../../shared/models/stats.model'; +import {Alert} from '../../shared/models/alert.model'; + +const REFRESH_INTERVAL_MS = 10000; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class Dashboard implements OnInit { + private statsService = inject(StatsService); + private alertsService = inject(AlertsService); + private destroyRef = inject(DestroyRef); + + stats = signal(null); + alerts = signal([]); + + ngOnInit(): void { + this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts)); + + timer(0, REFRESH_INTERVAL_MS) + .pipe( + switchMap(() => this.statsService.getSummary()), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((stats) => this.stats.set(stats)); + } +} diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss new file mode 100644 index 0000000..1552ce8 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss @@ -0,0 +1,6 @@ +:host { + display: block; + height: 200px; + width: 200px; + margin: 0 auto; +} diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts new file mode 100644 index 0000000..e793c39 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { ConsumptionGauge } from './consumption-gauge'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('ConsumptionGauge', () => { + it('se crée sans erreur avec des entrées valides', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + it('met à jour le graphique quand les valeurs changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + + fixture.componentRef.setInput('consumption', 500); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + + expect(() => fixture.detectChanges()).not.toThrow(); +}); +}); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts new file mode 100644 index 0000000..73544ea --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -0,0 +1,55 @@ +import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { Chart, registerables } from 'chart.js'; + +Chart.register(...registerables); + +@Component({ + selector: 'app-consumption-gauge', + standalone: true, + templateUrl: './consumption-gauge.html', + styleUrl: './consumption-gauge.scss', +}) +export class ConsumptionGauge implements AfterViewInit { + consumption = input.required(); + capacity = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart; + + constructor() { + effect(() => { + const used = this.consumption(); + const remaining = Math.max(0, this.capacity() - used); + if (this.chart) { + this.chart.data.datasets[0].data = [used, remaining]; + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const used = this.consumption(); + const remaining = Math.max(0, this.capacity() - used); + + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'doughnut', + data: { + labels: ['Utilisé', 'Disponible'], + datasets: [ + { + data: [used, remaining], + backgroundColor: ['#3b82f6', '#e5e7eb'], + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '70%', + animation: { duration: 300 }, + plugins: { legend: { display: false } }, + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts new file mode 100644 index 0000000..1e1c46b --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { SiteLoadChart } from './site-load-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('SiteLoadChart', () => { + it('se crée sans erreur avec une liste de sites valide', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + ]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + it('met à jour le graphique quand les sites changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + ]); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + + fixture.componentRef.setInput('sites', [ + { site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' }, + ]); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + + expect(() => fixture.detectChanges()).not.toThrow(); +}); +}); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts new file mode 100644 index 0000000..f8ee5cb --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -0,0 +1,62 @@ +import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { SiteSummary } from '../../models/stats.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#2e7d32', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; + +@Component({ + selector: 'app-site-load-chart', + standalone: true, + templateUrl: './site-load-chart.html', + styleUrl: './site-load-chart.scss', +}) +export class SiteLoadChart implements AfterViewInit { + sites = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart; + + constructor() { + effect(() => { + const sites = this.sites(); + if (this.chart) { + this.chart.data.labels = sites.map((s) => s.site_name); + this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0); + this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]); + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const sites = this.sites(); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'bar', + data: { + labels: sites.map((s) => s.site_name), + datasets: [ + { + data: sites.map((s) => s.load_percent ?? 0), + backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]), + borderRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } }, + }, + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts new file mode 100644 index 0000000..028f35a --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -0,0 +1,13 @@ +export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; +export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; + +export interface Alert { + alert_id: string; + timestamp: string; + site_id: string; + severity: AlertSeverity; + type: AlertType; + message: string; + value: number; + threshold: number; +} diff --git a/apps/frontend/src/app/shared/models/stats.model.ts b/apps/frontend/src/app/shared/models/stats.model.ts new file mode 100644 index 0000000..eb0f03d --- /dev/null +++ b/apps/frontend/src/app/shared/models/stats.model.ts @@ -0,0 +1,17 @@ +export interface SiteSummary { + site_id: string; + site_name: string; + current_consumption_kw: number | null; + capacity_kw: number; + load_percent: number | null; + data_quality: 'good' | 'partial' | 'degraded' | 'critical'; +} + +export interface StatsSummary { + timestamp: string; + total_sites: number; + total_consumption_kw: number; + total_capacity_kw: number; + average_load_percent: number; + sites: SiteSummary[]; +} diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts index 5ebc640..8409d18 100644 --- a/apps/frontend/src/environments/environment.development.ts +++ b/apps/frontend/src/environments/environment.development.ts @@ -1,4 +1,5 @@ export const environment = { production: false, - apiUrl: '/api/v1' + apiUrl: '/api/v1', + useMockFixtures: true, // a passer a false une fois le backend prêt }; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index 5c2010d..bac99a8 100644 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: true, - apiUrl: 'http://localhost:8000/api/v1' + apiUrl: 'http://localhost:8000/api/v1', + useMockFixtures: false, }; From b5cffbf56fa9d9a362e9b10b2243b32ea43e549f Mon Sep 17 00:00:00 2001 From: ineszang Date: Tue, 15 Sep 2026 16:59:34 +0200 Subject: [PATCH 049/205] =?UTF-8?q?fix(frontend):=20changement=20de=20vers?= =?UTF-8?q?ion=20des=20actions=20pour=20raisons=20de=20compatibilit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 923baa1..412e1a0 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,7 +1,7 @@ # Pipeline à multiple scénarios # pour l'environnement de dev -name: Dev Pipeline (frontend) +name: Frontend on: # workflow_dispatch -> lancement manuel des jobs @@ -56,8 +56,8 @@ jobs: if: ${{ github.event.inputs.job_choice == 'build' || github.event.inputs.job_choice == 'all' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm From c733ccfc62d9c6e1d1f357fcd41615d3397d46a3 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:41:48 +0200 Subject: [PATCH 050/205] refactor(backend): passe les tables data au singulier et clarifie alert_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La convention de docs/architecture/40-data.md impose des noms de tables au singulier, que les quatre tables d'authentification respectent déjà. Les six tables data passent donc au singulier, avec leurs contraintes et leurs index. La révision n'étant appliquée que sur des bases locales, elle est modifiée sur place plutôt que doublée d'une migration de renommage. alert_id désignait deux colonnes différentes : la clé métier text de l'API Mock et la clé étrangère bigint de recommendation. La première devient source_alert_id, la seconde pointe désormais vers alert.alert_id. --- .../e6d2026091501_create_data_schema.py | 94 ++++++++++--------- apps/backend/app/models/energy.py | 75 ++++++++------- apps/backend/tests/db/test_data_schema.py | 10 +- 3 files changed, 92 insertions(+), 87 deletions(-) diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py index 50cc41f..8fb3694 100644 --- a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -1,4 +1,4 @@ -"""Création des six tables Data et de l'hypertable readings. +"""Création des six tables Data et de l'hypertable reading. Revision ID: e6d2026091501 Revises: 821f71be74c0 @@ -17,7 +17,7 @@ depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "datasets", + "dataset", sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("dataset_name", sa.Text(), nullable=False), sa.Column("archive_sha256", sa.String(length=64), nullable=False), @@ -26,12 +26,12 @@ def upgrade() -> None: sa.Column( "metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False ), - sa.CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + sa.CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"), sa.PrimaryKeyConstraint("dataset_id"), - sa.UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + sa.UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), ) op.create_table( - "sites", + "site", sa.Column("site_id", sa.Text(), nullable=False), sa.Column("site_name", sa.Text(), nullable=False), sa.Column("site_type", sa.Text(), nullable=False), @@ -41,7 +41,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("site_id"), ) op.create_table( - "predictions", + "prediction", sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column( @@ -59,29 +59,29 @@ def upgrade() -> None: sa.Column("failure_reason", sa.Text(), nullable=True), sa.CheckConstraint( "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)", - name="ck_predictions_status", + name="ck_prediction_status", ), sa.CheckConstraint( "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", - name="ck_predictions_energy_period", + name="ck_prediction_energy_period", ), sa.CheckConstraint( - "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_predictions_metric" + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_prediction_metric" ), sa.CheckConstraint( - "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_predictions_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_prediction_site", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("prediction_id"), - sa.UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), ) op.create_index( - "ix_predictions_site_target", "predictions", ["site_id", "target_at"], unique=False + "ix_prediction_site_target", "prediction", ["site_id", "target_at"], unique=False ) op.create_table( - "readings", + "reading", sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), @@ -114,44 +114,44 @@ def upgrade() -> None: ), sa.CheckConstraint( "(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)", - name="ck_readings_dataset_source", + name="ck_reading_dataset_source", ), sa.CheckConstraint( "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", - name="ck_readings_quality", + name="ck_reading_quality", ), sa.CheckConstraint( - "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + "source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source" ), sa.CheckConstraint( "(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", - name="ck_readings_imputation", + name="ck_reading_imputation", ), sa.ForeignKeyConstraint( - ["dataset_id"], ["datasets.dataset_id"], name="fk_readings_dataset", ondelete="RESTRICT" + ["dataset_id"], ["dataset.dataset_id"], name="fk_reading_dataset", ondelete="RESTRICT" ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_readings_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_reading_site", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("reading_id", "timestamp"), ) - op.create_index("ix_readings_dataset_id", "readings", ["dataset_id"], unique=False) + op.create_index("ix_reading_dataset_id", "reading", ["dataset_id"], unique=False) op.create_index( - "ix_readings_site_timestamp", "readings", ["site_id", "timestamp"], unique=False + "ix_reading_site_timestamp", "reading", ["site_id", "timestamp"], unique=False ) op.create_index( - "uq_readings_source", - "readings", + "uq_reading_source", + "reading", ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], unique=True, ) op.execute( - "SELECT create_hypertable('readings', by_range('timestamp'), create_default_indexes => FALSE)" + "SELECT create_hypertable('reading', by_range('timestamp'), create_default_indexes => FALSE)" ) op.create_table( - "alerts", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("alert_id", sa.Text(), nullable=False), + "alert", + sa.Column("alert_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("source_alert_id", sa.Text(), nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column("source", sa.Text(), nullable=False), sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), @@ -166,27 +166,29 @@ def upgrade() -> None: "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False ), sa.CheckConstraint( - "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" ), - sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), sa.CheckConstraint( - "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" ), sa.ForeignKeyConstraint( ["prediction_id", "site_id"], - ["predictions.prediction_id", "predictions.site_id"], - name="fk_alerts_prediction_site", + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_alerts_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_alert_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("alert_id"), + sa.UniqueConstraint( + "source", "site_id", "source_alert_id", name="uq_alert_source_reference" ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), ) - op.create_index("ix_alerts_site_timestamp", "alerts", ["site_id", "timestamp"], unique=False) + op.create_index("ix_alert_site_timestamp", "alert", ["site_id", "timestamp"], unique=False) op.create_table( - "recommendations", + "recommendation", sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("alert_id", sa.BigInteger(), nullable=False), sa.Column("action", sa.Text(), nullable=False), @@ -199,18 +201,18 @@ def upgrade() -> None: nullable=False, ), sa.ForeignKeyConstraint( - ["alert_id"], ["alerts.id"], name="fk_recommendations_alert", ondelete="RESTRICT" + ["alert_id"], ["alert.alert_id"], name="fk_recommendation_alert", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("recommendation_id"), - sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), ) # ### end Alembic commands ### def downgrade() -> None: - op.drop_table("recommendations") - op.drop_table("alerts") - op.drop_table("readings") - op.drop_table("predictions") - op.drop_table("sites") - op.drop_table("datasets") + op.drop_table("recommendation") + op.drop_table("alert") + op.drop_table("reading") + op.drop_table("prediction") + op.drop_table("site") + op.drop_table("dataset") diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index de27c7c..578ca50 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -28,10 +28,10 @@ from app.db.base import Base class Dataset(Base): - __tablename__ = "datasets" + __tablename__ = "dataset" __table_args__ = ( - CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), - UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"), + UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), ) dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) @@ -44,7 +44,7 @@ class Dataset(Base): class Site(Base): - __tablename__ = "sites" + __tablename__ = "site" site_id: Mapped[str] = mapped_column(Text, primary_key=True) site_name: Mapped[str] = mapped_column(Text) @@ -55,38 +55,38 @@ class Site(Base): class Reading(Base): - __tablename__ = "readings" + __tablename__ = "reading" __table_args__ = ( CheckConstraint( - "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + "source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source" ), CheckConstraint( "(source = 'csv' AND dataset_id IS NOT NULL) OR " "(source IN ('api_current', 'api_history') AND dataset_id IS NULL)", - name="ck_readings_dataset_source", + name="ck_reading_dataset_source", ), CheckConstraint( "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", - name="ck_readings_quality", + name="ck_reading_quality", ), CheckConstraint( "(imputed_values IS NULL AND imputation_method IS NULL) OR " "(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", - name="ck_readings_imputation", + name="ck_reading_imputation", ), - Index("ix_readings_site_timestamp", "site_id", "timestamp"), - Index("ix_readings_dataset_id", "dataset_id"), + Index("ix_reading_site_timestamp", "site_id", "timestamp"), + Index("ix_reading_dataset_id", "dataset_id"), ) reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_readings_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_reading_site", ondelete="RESTRICT") ) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True) source: Mapped[str] = mapped_column(Text) dataset_id: Mapped[int | None] = mapped_column( BigInteger, - ForeignKey("datasets.dataset_id", name="fk_readings_dataset", ondelete="RESTRICT"), + ForeignKey("dataset.dataset_id", name="fk_reading_dataset", ondelete="RESTRICT"), ) consumption_kw: Mapped[float | None] = mapped_column(Double) consumption_kwh: Mapped[float | None] = mapped_column(Double) @@ -109,7 +109,7 @@ class Reading(Base): Index( - "uq_readings_source", + "uq_reading_source", Reading.site_id, Reading.timestamp, Reading.source, @@ -119,32 +119,32 @@ Index( class Prediction(Base): - __tablename__ = "predictions" + __tablename__ = "prediction" __table_args__ = ( - UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), - Index("ix_predictions_site_target", "site_id", "target_at"), + UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), + Index("ix_prediction_site_target", "site_id", "target_at"), CheckConstraint( "target_metric IN ('consumption_kwh', 'consumption_kw')", - name="ck_predictions_metric", + name="ck_prediction_metric", ), CheckConstraint( - "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" ), CheckConstraint( "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", - name="ck_predictions_energy_period", + name="ck_prediction_energy_period", ), CheckConstraint( "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR " "(status IN ('insufficient_data', 'error') AND predicted_value IS NULL " "AND failure_reason IS NOT NULL)", - name="ck_predictions_status", + name="ck_prediction_status", ), ) prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_predictions_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_prediction_site", ondelete="RESTRICT") ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -157,29 +157,31 @@ class Prediction(Base): class Alert(Base): - __tablename__ = "alerts" + __tablename__ = "alert" __table_args__ = ( - UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), - Index("ix_alerts_site_timestamp", "site_id", "timestamp"), + UniqueConstraint( + "source", "site_id", "source_alert_id", name="uq_alert_source_reference" + ), + Index("ix_alert_site_timestamp", "site_id", "timestamp"), ForeignKeyConstraint( ["prediction_id", "site_id"], - ["predictions.prediction_id", "predictions.site_id"], - name="fk_alerts_prediction_site", + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", ondelete="RESTRICT", ), - CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), CheckConstraint( - "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" ), CheckConstraint( - "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" ), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - alert_id: Mapped[str] = mapped_column(Text) + alert_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + source_alert_id: Mapped[str] = mapped_column(Text) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_alerts_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_alert_site", ondelete="RESTRICT") ) source: Mapped[str] = mapped_column(Text) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -194,14 +196,15 @@ class Alert(Base): class Recommendation(Base): - __tablename__ = "recommendations" + __tablename__ = "recommendation" __table_args__ = ( - UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), ) recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) alert_id: Mapped[int] = mapped_column( - BigInteger, ForeignKey("alerts.id", name="fk_recommendations_alert", ondelete="RESTRICT") + BigInteger, + ForeignKey("alert.alert_id", name="fk_recommendation_alert", ondelete="RESTRICT"), ) action: Mapped[str] = mapped_column(Text) explanation: Mapped[str] = mapped_column(Text) diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py index aefc9fa..c564042 100644 --- a/apps/backend/tests/db/test_data_schema.py +++ b/apps/backend/tests/db/test_data_schema.py @@ -41,12 +41,12 @@ async def data_site(data_connection: AsyncConnection) -> str: return site_id -async def test_readings_is_a_time_hypertable_when_migrated( +async def test_reading_is_a_time_hypertable_when_migrated( data_connection: AsyncConnection, ) -> None: query = text( "SELECT column_name FROM timescaledb_information.dimensions " - "WHERE hypertable_schema = 'public' AND hypertable_name = 'readings'" + "WHERE hypertable_schema = 'public' AND hypertable_name = 'reading'" ) result = await data_connection.execute(query) @@ -216,7 +216,7 @@ async def test_alert_rejects_prediction_when_site_differs( async with data_connection.begin_nested(): await data_connection.execute( insert(Alert).values( - alert_id=str(uuid4()), + source_alert_id=str(uuid4()), site_id=other_site, source="enervision", timestamp=MOMENT, @@ -236,7 +236,7 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( await data_connection.execute( insert(Alert) .values( - alert_id=str(uuid4()), + source_alert_id=str(uuid4()), site_id=data_site, source="api_mock", timestamp=MOMENT, @@ -245,7 +245,7 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( message="Test", raw_data={}, ) - .returning(Alert.id) + .returning(Alert.alert_id) ) ).scalar_one() statement = insert(Recommendation).values( From 3eb5a0e8dc08a9b77d07c29aa93bc65f1409a9fb Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:43:12 +0200 Subject: [PATCH 051/205] style(backend): applique ruff format au modele data La cible make check ne lance que ruff check ; la CI lance en plus ruff format --check, qui refusait la contrainte unique repliee. --- apps/backend/app/models/energy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index 578ca50..285ad26 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -159,9 +159,7 @@ class Prediction(Base): class Alert(Base): __tablename__ = "alert" __table_args__ = ( - UniqueConstraint( - "source", "site_id", "source_alert_id", name="uq_alert_source_reference" - ), + UniqueConstraint("source", "site_id", "source_alert_id", name="uq_alert_source_reference"), Index("ix_alert_site_timestamp", "site_id", "timestamp"), ForeignKeyConstraint( ["prediction_id", "site_id"], From 6c1f86b4ceca28197e55c970c7995b7ad37ee54b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:47:10 +0200 Subject: [PATCH 052/205] docs(architecture): remet 40-data.md en accord avec le schema livre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'avertissement affirmait qu'aucune table applicative n'existait, vingt lignes avant la liste des tables d'authentification. La section « Modèle métier » décrivait un modèle candidat que la « Modélisation détaillée » contredit depuis la livraison du schéma : elle disparaît, et le gabarit d'hypertable s'appuie désormais sur la révision réelle. Les conventions annonçaient une colonne de partitionnement nommée horodatage, alors qu'elle s'appelle timestamp. Les six tables data prennent leur nom au singulier, et les questions tranchées par le schéma sortent des questions ouvertes. --- docs/architecture/40-data.md | 109 ++++++++++++++--------------------- 1 file changed, 44 insertions(+), 65 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 5774687..6566753 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le système qui en découle. -## Avertissement +## Ce que couvre ce document -**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne -contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable -n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un -relevé du code. Le modèle sera arrêté au jalon J2. +**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données +d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles +marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne +d'ingestion, les agrégats continus, la compression et la rétention. ## Trois emplacements, trois rôles @@ -35,7 +35,7 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle +- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version` et refuse de s'appliquer si l'extension manque : ```sql @@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, -puis `login_attempt` et `audit_log`, puis `refresh_token`. +puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée +les six tables de données décrites en fin de document et déclare l'hypertable `reading`. ## Cycle de vie d'une mesure -Statut : `Cible`. Aucun de ces maillons n'existe. +Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats +continus, ni la compression, ni la rétention ne sont écrits. ```mermaid flowchart LR src["Source de mesures"] -.-> ing["Ingestion Airflow"] - ing -.-> hy[("Hypertable mesure")] + ing -.-> hy[("Hypertable reading")] hy -.-> agg[("Agrégat continu")] hy -.-> comp["Compression"] hy -.-> ret["Rétention"] @@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est piloté par l'attaquant. -## Modèle métier - -Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles -s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui -évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un -`total_kwh(site_id)`. - -```mermaid -erDiagram - SITE ||--o{ POINT_DE_MESURE : porte - POINT_DE_MESURE ||--o{ MESURE : produit - - SITE { - int id PK - string nom - } - POINT_DE_MESURE { - int id PK - int site_id FK - string libelle - string unite - } - MESURE { - timestamptz horodatage PK - int point_id PK - double valeur - } -``` - -`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé -primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant -de point serait refusée. - ## Gabarit de révision créant une hypertable -Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. +Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision +`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel. ```python def upgrade() -> None: op.create_table( - "mesure", - sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False), - sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False), - sa.Column("valeur", sa.Float(), nullable=False), - sa.PrimaryKeyConstraint("horodatage", "point_id"), + "reading", + sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("reading_id", "timestamp"), + ) + op.execute( + "SELECT create_hypertable('reading', by_range('timestamp'), " + "create_default_indexes => FALSE)" ) - op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))") def downgrade() -> None: - op.drop_table("mesure") + op.drop_table("reading") ``` +La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte +unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul +`reading_id` serait refusée par `create_hypertable`. + +`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule +colonne de temps : les index déclarés dans la révision le couvrent déjà. + `drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions. ## Conventions -- **Noms au singulier**, en minuscules, sans préfixe de table. +- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`. - **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau devient ininterprétable dès le premier changement d'heure. -- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire. +- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle + `timestamp` : c'est un nom de colonne, son type reste `timestamptz`. - **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne découlent pas du schéma applicatif. - **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi @@ -201,13 +182,12 @@ def downgrade() -> None: ## Questions ouvertes -Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif. +Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit +porte sur son exploitation, plus sur sa forme. -- **Quelles sources de mesures**, et selon quel protocole elles sont collectées. - **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure. - **Quels agrégats continus**, et sur quelles fenêtres. - **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. -- **Quelles unités** sont manipulées, et si une même table les mélange. - **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. ## Modélisation détaillée des données @@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur. ### Schéma de données Le diagramme ci-dessous présente les tables et leurs relations. -Il décrit une structure de conception ; les migrations correspondantes -restent à implémenter. +La révision `e6d2026091501` les crée. ![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) -*Figure — Modélisation des données EnerVision.* +*Figure : Modélisation des données EnerVision.* ### Description des tables @@ -234,15 +213,15 @@ des données. | Table | Rôle | Origine des informations | |---|---|---| -| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | -| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | -| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | -| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | -| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | -| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | +| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | +| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | +| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `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 | Les anomalies historiques décrites dans les JSON sont conservées -dans `datasets.metadata`. Elles servent à l’analyse des données +dans `dataset.metadata`. Elles servent à l’analyse des données et ne sont pas considérées comme des alertes actuelles. ### Relations entre les tables From 7b9406965e3bc00a14266d55bab30c086ddefbed Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH 053/205] fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur Sans catchError, la premiere reponse en erreur terminait le flux du timer : le rafraichissement ne repartait jamais et l'ecran restait fige sur des chiffres perimes, sans rien signaler. Le catchError porte sur l'observable interne du switchMap. Place sur le flux externe il terminerait le timer tout autant. Un signal error alimente un bandeau, efface des qu'une reponse valide revient. --- .../src/app/features/dashboard/dashboard.html | 4 ++ .../src/app/features/dashboard/dashboard.scss | 10 ++++ .../app/features/dashboard/dashboard.spec.ts | 56 ++++++++++++++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++++++--- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 70b333c..d324c74 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -4,6 +4,10 @@

Consommation instantanée du parc

+ @if (error(); as message) { + + } + @if (stats(); as s) {
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index d0b1088..3cacb0f 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -37,6 +37,16 @@ h2 { margin: 0 0 1rem; } +.banner-error { + margin: 0 0 1.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-critical); + border-left-width: 4px; + border-radius: var(--radius); + background: #fdecea; + color: var(--color-critical); +} + .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 7dcbe37..f55adac 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; @@ -8,6 +8,7 @@ import { AlertsService } from '../../core/services/alerts.service'; vi.mock('chart.js', () => { class ChartMock { update = vi.fn(); + destroy = vi.fn(); data = { datasets: [{}] }; static register = vi.fn(); } @@ -15,6 +16,8 @@ vi.mock('chart.js', () => { }); describe('Dashboard', () => { + afterEach(() => vi.useRealTimers()); + it('charge les stats et les alertes au démarrage', async () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; @@ -37,5 +40,56 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.error()).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 }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + vi.advanceTimersByTime(1); + expect(statsMock.getSummary).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.stats()).toBeNull(); + + vi.advanceTimersByTime(10000); + expect(statsMock.getSummary).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.stats()).not.toBeNull(); + expect(fixture.componentInstance.error()).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 }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.alerts().length).toBe(0); }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2fe7768..b6a7627 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { timer, switchMap } from 'rxjs'; +import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe } from '@angular/common'; 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 {StatsSummary} from '../../shared/models/stats.model'; -import {Alert} from '../../shared/models/alert.model'; +import { AlertsService } from '../../core/services/alerts.service'; +import { StatsSummary } from '../../shared/models/stats.model'; +import { Alert } from '../../shared/models/alert.model'; const REFRESH_INTERVAL_MS = 10000; +const UNAVAILABLE_MESSAGE = + 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; @Component({ selector: 'app-dashboard', @@ -25,15 +27,31 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); + error = signal(null); ngOnInit(): void { - this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts)); + this.alertsService + .getAlerts() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((alerts) => this.alerts.set(alerts)); + // Piège : le catchError porte sur l'observable interne. Sur le flux externe il + // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( - switchMap(() => this.statsService.getSummary()), + switchMap(() => + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())) + ), takeUntilDestroyed(this.destroyRef) ) - .subscribe((stats) => this.stats.set(stats)); + .subscribe((stats) => { + this.error.set(null); + this.stats.set(stats); + }); + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; } } From 0259f66b62679158c0d64b4e26c0b154134cb4ac Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH 054/205] fix(frontend): detruit les graphiques avec leur composant Chart.js conserve chaque instance dans un registre lie au canvas et lui attache un observateur de redimensionnement. Sans destroy, tout survit a la destruction du composant, et une re-creation sur le meme canvas echoue avec "Canvas is already in use". Les doubles de test gagnent destroy : TestBed detruit les fixtures apres chaque test, un mock sans cette methode fait tomber les specs existantes. --- .../consumption-gauge.spec.ts | 29 ++++++++++++++- .../consumption-gauge/consumption-gauge.ts | 16 +++++++- .../site-load-chart/site-load-chart.spec.ts | 37 ++++++++++++++++++- .../site-load-chart/site-load-chart.ts | 16 +++++++- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts index e793c39..be25fb1 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -1,16 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; +import { Chart } from 'chart.js'; import { ConsumptionGauge } from './consumption-gauge'; vi.mock('chart.js', () => { class ChartMock { - update = vi.fn(); - data = { datasets: [{}] }; + static instances: ChartMock[] = []; static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } } return { Chart: ChartMock, registerables: [] }; }); +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + describe('ConsumptionGauge', () => { it('se crée sans erreur avec des entrées valides', () => { TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); @@ -31,4 +43,17 @@ describe('ConsumptionGauge', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts index 73544ea..bda661a 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); @@ -9,7 +17,7 @@ Chart.register(...registerables); templateUrl: './consumption-gauge.html', styleUrl: './consumption-gauge.scss', }) -export class ConsumptionGauge implements AfterViewInit { +export class ConsumptionGauge implements AfterViewInit, OnDestroy { consumption = input.required(); capacity = input.required(); @@ -52,4 +60,8 @@ export class ConsumptionGauge implements AfterViewInit { }, }); } + + ngOnDestroy(): void { + this.chart?.destroy(); + } } diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts index 1e1c46b..0d5944d 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -1,16 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; +import { Chart } from 'chart.js'; import { SiteLoadChart } from './site-load-chart'; vi.mock('chart.js', () => { class ChartMock { - update = vi.fn(); - data = { datasets: [{}] }; + static instances: ChartMock[] = []; static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } } return { Chart: ChartMock, registerables: [] }; }); +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + describe('SiteLoadChart', () => { it('se crée sans erreur avec une liste de sites valide', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); @@ -35,4 +47,25 @@ describe('SiteLoadChart', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { + site_id: 'S1', + site_name: 'A', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts index f8ee5cb..017ce1b 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; import { Chart, registerables } from 'chart.js'; import { SiteSummary } from '../../models/stats.model'; @@ -17,7 +25,7 @@ const QUALITY_COLORS: Record = { templateUrl: './site-load-chart.html', styleUrl: './site-load-chart.scss', }) -export class SiteLoadChart implements AfterViewInit { +export class SiteLoadChart implements AfterViewInit, OnDestroy { sites = input.required(); @ViewChild('canvas') private canvasRef!: ElementRef; @@ -59,4 +67,8 @@ export class SiteLoadChart implements AfterViewInit { }, }); } + + ngOnDestroy(): void { + this.chart?.destroy(); + } } From da97e6aa8b6236f313068cdc78f59e7c37cc0681 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:40 +0200 Subject: [PATCH 055/205] style(frontend): applique prettier aux fichiers du tableau de bord Les onze fichiers non conformes au .prettierrc du projet etaient exactement ceux introduits ou modifies par cette branche ; les vingt-deux autres du frontend etaient deja propres. Aucune modification de comportement : indentation, virgules finales et longueur de ligne a 100 caracteres. --- apps/frontend/src/app/app.config.ts | 7 +-- apps/frontend/src/app/app.routes.ts | 5 +- .../interceptors/mock-api-interceptor.spec.ts | 14 +++--- .../app/core/mocks/stats-summary.fixture.ts | 2 +- .../src/app/features/dashboard/dashboard.html | 10 +++- .../src/app/features/dashboard/dashboard.scss | 12 +++-- .../app/features/dashboard/dashboard.spec.ts | 2 +- .../src/app/features/dashboard/dashboard.ts | 4 +- .../consumption-gauge.spec.ts | 18 +++---- .../site-load-chart/site-load-chart.spec.ts | 47 ++++++++++++++----- .../site-load-chart/site-load-chart.ts | 4 +- 11 files changed, 82 insertions(+), 43 deletions(-) diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index d89a118..ff4cafd 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,12 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import {mockApiInterceptor} from './core/interceptors/mock-api-interceptor'; -import {provideHttpClient, withInterceptors} from '@angular/common/http'; +import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ - provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideBrowserGlobalErrorListeners(), + provideRouter(routes), provideHttpClient(withInterceptors([mockApiInterceptor])), ], }; diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 9852e8b..8f2739c 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -2,5 +2,8 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard) }, + { + path: 'dashboard', + loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), + }, ]; 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 34a58b2..5d6e343 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 @@ -33,7 +33,7 @@ describe('mockApiInterceptor', () => { httpMock.expectNone(`${environment.apiUrl}/stats/summary`); expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe( - STATS_SUMMARY_FIXTURE.total_sites + STATS_SUMMARY_FIXTURE.total_sites, ); }); @@ -56,12 +56,12 @@ describe('mockApiInterceptor', () => { }); it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => { - environment.useMockFixtures = true; - let result: unknown; + environment.useMockFixtures = true; + let result: unknown; - http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); + http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); - httpMock.expectNone(`${environment.apiUrl}/alerts`); - expect((result as unknown[]).length).toBeGreaterThan(0); -}); + httpMock.expectNone(`${environment.apiUrl}/alerts`); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); }); diff --git a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts index 7057fb8..f71a41b 100644 --- a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts +++ b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts @@ -18,7 +18,7 @@ export const STATS_SUMMARY_FIXTURE: StatsSummary = { { site_id: 'SITE002', site_name: 'Usine Lyon Vénissieux', - current_consumption_kw: 542.10, + current_consumption_kw: 542.1, capacity_kw: 1000, load_percent: 54.2, data_quality: 'good', diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d324c74..a64d5d9 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -12,8 +12,14 @@
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
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 3cacb0f..01cc3a3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -131,9 +131,15 @@ h2 { flex-shrink: 0; } -.alert-item--high .alert-item__badge { background: var(--color-degraded); } -.alert-item--medium .alert-item__badge { background: var(--color-partial); } -.alert-item--low .alert-item__badge { background: var(--color-good); } +.alert-item--high .alert-item__badge { + background: var(--color-degraded); +} +.alert-item--medium .alert-item__badge { + background: var(--color-partial); +} +.alert-item--low .alert-item__badge { + background: var(--color-good); +} .alert-item__message { font-size: 0.9rem; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index f55adac..89a69ec 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -43,7 +43,7 @@ describe('Dashboard', () => { expect(fixture.componentInstance.error()).toBeNull(); }); - it('signale l\'indisponibilité puis repart au rafraîchissement suivant', () => { + it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { vi.useFakeTimers(); const statsMock = { getSummary: vi diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index b6a7627..7733230 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -40,9 +40,9 @@ export class Dashboard implements OnInit { timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())) + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), ), - takeUntilDestroyed(this.destroyRef) + takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { this.error.set(null); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts index be25fb1..672d50f 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -32,17 +32,17 @@ describe('ConsumptionGauge', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); it('met à jour le graphique quand les valeurs changent après initialisation', () => { - TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); - const fixture = TestBed.createComponent(ConsumptionGauge); - fixture.componentRef.setInput('consumption', 300); - fixture.componentRef.setInput('capacity', 1000); - fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais - fixture.componentRef.setInput('consumption', 500); - fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + fixture.componentRef.setInput('consumption', 500); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini - expect(() => fixture.detectChanges()).not.toThrow(); -}); + expect(() => fixture.detectChanges()).not.toThrow(); + }); it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts index 0d5944d..59e6b6b 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -28,25 +28,46 @@ describe('SiteLoadChart', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); const fixture = TestBed.createComponent(SiteLoadChart); fixture.componentRef.setInput('sites', [ - { site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + { + site_id: 'S1', + site_name: 'Test', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, ]); expect(() => fixture.detectChanges()).not.toThrow(); }); it('met à jour le graphique quand les sites changent après initialisation', () => { - TestBed.configureTestingModule({ imports: [SiteLoadChart] }); - const fixture = TestBed.createComponent(SiteLoadChart); - fixture.componentRef.setInput('sites', [ - { site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, - ]); - fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { + site_id: 'S1', + site_name: 'A', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, + ]); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais - fixture.componentRef.setInput('sites', [ - { site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' }, - ]); - fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + fixture.componentRef.setInput('sites', [ + { + site_id: 'S2', + site_name: 'B', + current_consumption_kw: 80, + capacity_kw: 100, + load_percent: 80, + data_quality: 'critical', + }, + ]); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini - expect(() => fixture.detectChanges()).not.toThrow(); -}); + expect(() => fixture.detectChanges()).not.toThrow(); + }); it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts index 017ce1b..6c17803 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -37,7 +37,9 @@ export class SiteLoadChart implements AfterViewInit, OnDestroy { if (this.chart) { this.chart.data.labels = sites.map((s) => s.site_name); this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0); - this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]); + this.chart.data.datasets[0].backgroundColor = sites.map( + (s) => QUALITY_COLORS[s.data_quality], + ); this.chart.update('none'); } }); From e85c83972a62d510e8d418510b4c9c2755be349c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:48 +0200 Subject: [PATCH 056/205] chore(frontend): sort le rapport JUnit du suivi git La ligne /test-results ajoutee au .gitignore n'avait aucun effet : le fichier etait deja suivi, et un .gitignore ne s'applique pas a un fichier indexe. Il reapparaissait donc modifie dans le diff de chacun a chaque execution de ng test, qui le regenere a l'emplacement fixe par angular.json. --- apps/frontend/test-results/junit.xml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 apps/frontend/test-results/junit.xml diff --git a/apps/frontend/test-results/junit.xml b/apps/frontend/test-results/junit.xml deleted file mode 100644 index 28e5ba4..0000000 --- a/apps/frontend/test-results/junit.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - From 580da72effe88610b32e58cc72ba59373e795a8a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:23:15 +0200 Subject: [PATCH 057/205] docs(architecture): acte le tableau de bord dans les vues frontend 30-frontend.md decrivait encore un ng new intact : routes vides, provideHttpClient absent, app.html par defaut, aucune bibliotheque de graphiques. Les sections Arborescence et Flux HTTP passent de Cible a realisees, et le diagramme de sequence montre ou l'intercepteur se place. La section Securite affirmait que l'authentification n'existe pas cote API : elle existe depuis la PR #70, c'est cote interface qu'il n'y a rien. Ajout verifie sur le poste : l'Angular CLI refuse de demarrer en dessous de Node 22.22.3, 24.15.0 ou 26.0.0. --- README.md | 7 +-- docs/architecture/00-vue-ensemble.md | 7 +-- docs/architecture/30-frontend.md | 65 ++++++++++++++++++---------- 3 files changed, 51 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7a8147f..75a1ab6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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` | Squelette | +| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | @@ -27,8 +27,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend -porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers -portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints +correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README +de cadrage, leur contenu fait l'objet d'un ticket dedie. L'etat detaille de chaque brique et les vues d'architecture sont dans [docs/architecture](docs/architecture/README.md). diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 15a54c0..d083985 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -63,8 +63,9 @@ flowchart TB grafana -.-> prom ``` -Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune -API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md). +Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un +intercepteur répond à sa place tant que les endpoints n'existent pas. Voir +[30-frontend.md](30-frontend.md). Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -74,7 +75,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 98da40a..baea451 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -4,30 +4,36 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps ## État actuel -Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la -[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le -disait encore « à initialiser » alors que le squelette existe depuis `49f4697`. +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. Ce qui est en place : - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. -- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test. +- `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` et `AlertsService`, `core/interceptors` l'intercepteur de + fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le + graphique de charge par site, tous deux construits sur Chart.js. +- L'état vit dans des signaux, sans bibliothèque dédiée. +- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- `routes` est un tableau vide. Aucune page, aucune navigation. -- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part : - l'application n'appelle aucune API. -- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris. -- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état. +- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. +- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que + les routes métier de l'API en exigent un. Voir + [31-contrat-authentification.md](31-contrat-authentification.md). +- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. -## Arborescence cible +## Arborescence -Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la -prescrit déjà dans ses gabarits de tests. +Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses +gabarits de tests. ```mermaid flowchart TB @@ -48,22 +54,33 @@ directement : ils passent par un service, ce qui rend le double de test trivial. ## Flux HTTP -Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore. +Statut : `En cours`. Le chemin complet est câblé, mais un intercepteur se place devant et répond +lui-même tant que les endpoints n'existent pas. ```mermaid sequenceDiagram participant C as Composant participant S as Service Angular + participant I as mockApiInterceptor participant P as ng serve, proxy participant A as FastAPI C->>S: appel de méthode - S->>P: GET /api/v1/... - P->>A: http://localhost:8000/api/v1/... - A-->>S: JSON + S->>I: GET /api/v1/... + alt useMockFixtures actif et route connue + I-->>S: fixture locale + else + I->>P: la requête poursuit + P->>A: http://localhost:8000/api/v1/... + A-->>S: JSON + end S-->>C: modèle typé ``` +`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. + En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. @@ -87,6 +104,10 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans | `npm run test` | Vitest en mode observateur | | `npm run test:ci` | Vitest en une passe | +**Version de Node.** L'Angular CLI refuse de démarrer en dessous de 22.22.3, 24.15.0 ou 26.0.0, et +le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni +tester ni construire le frontend. + Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans `docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. @@ -98,8 +119,9 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce - stade. `core/guards` et `core/interceptors` sont prévus pour cela. +- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun + intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui + que les fixtures. ## Tests @@ -107,8 +129,7 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI ## Questions ouvertes -- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà - une partie du besoin. -- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée. +- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs + pages partageront le même état. - **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue au démarrage. From 61b3494d12ae344a75db72034a012238930cf26c Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 10:03:59 +0200 Subject: [PATCH 058/205] correction nom du workflow pour le frontend --- .github/workflows/frontend.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 412e1a0..4696599 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,6 +1,3 @@ -# Pipeline à multiple scénarios -# pour l'environnement de dev - name: Frontend on: @@ -19,11 +16,11 @@ on: push: paths: - "apps/frontend/**" - - ".github/workflows/dev-front-pipeline.yml" + - ".github/workflows/frontend.yml" pull_request: paths: - "apps/frontend/**" - - ".github/workflows/dev-front-pipeline.yml" + - ".github/workflows/frontend.yml" jobs: From 344f82fcdd38844474aa7d8bb1aa81c66deb3a20 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:12:41 +0200 Subject: [PATCH 059/205] feat(backend): documente le contrat d'erreur dans l'OpenAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le schéma ne déclarait aucun code d'erreur : ni 401, ni 403, ni 404, ni 409, ni 429. Swagger affirmait que /auth/login ne pouvait répondre que 200 ou 422, alors que 31-contrat-authentification.md décrit ces codes comme le contrat que le frontend doit traiter. Le 422 publié était pire qu'absent : le schéma exposait HTTPValidationError, le modèle par défaut de FastAPI avec sa clé `loc`, quand validation_error_handler renvoie {"detail": [{"champ", "type"}]}. Un client codé sur la documentation lisait une clé qui n'arrive jamais. Les métadonnées arrivent avec : description, résumé et une description par tag. `servers`, `license_info` et `contact` restent absents, ils poseraient des décisions qui ne sont pas prises. Le cookie de rafraîchissement devient visible par un APIKeyCookie en auto_error=False, purement documentaire : lit_le_cookie() reste seul maître du 401 de /auth/refresh. Au passage, health.py posait son tag deux fois, une fois sur son APIRouter et une fois à l'include_router. --- apps/backend/app/api/openapi.py | 114 ++++++++++++++++++++ apps/backend/app/api/v1/endpoints/auth.py | 67 +++++++++++- apps/backend/app/api/v1/endpoints/health.py | 7 +- apps/backend/app/api/v1/endpoints/users.py | 33 +++++- apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/core/config.py | 3 +- apps/backend/app/main.py | 4 + apps/backend/app/schemas/errors.py | 23 ++++ 8 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 apps/backend/app/api/openapi.py create mode 100644 apps/backend/app/schemas/errors.py diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py new file mode 100644 index 0000000..fedb3bf --- /dev/null +++ b/apps/backend/app/api/openapi.py @@ -0,0 +1,114 @@ +# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`. +# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et +# `/auth/refresh` cesserait de rendre le 401 que le frontend attend. + +from typing import Any, Final + +from fastapi.security import APIKeyCookie + +from app.core.config import REFRESH_COOKIE_DEFAUT +from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse + +Reponses = dict[int | str, dict[str, Any]] + +SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques." + +DESCRIPTION: Final = """ +Toutes les routes sont préfixées par `/api/v1`. + +**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`. +Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il +suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un +nouveau jeton d'accès et fait tourner le cookie. + +**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du +précédent. + +**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut +`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe +provisoire avant toute autre action. + +Le parcours de session complet est décrit dans +`docs/architecture/31-contrat-authentification.md`. +""" + +TAGS: Final[list[dict[str, Any]]] = [ + { + "name": "health", + "description": ( + "Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` " + "que la base répond et que l'extension TimescaleDB est chargée." + ), + }, + { + "name": "auth", + "description": ( + "Ouverture, rotation et fermeture de session, et changement de son propre mot de passe." + ), + }, + { + "name": "users", + "description": "Administration des comptes. Réservé au rôle `admin`.", + }, +] + +cookie_de_rafraichissement = APIKeyCookie( + name=REFRESH_COOKIE_DEFAUT, + scheme_name="Cookie de rafraîchissement", + 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`." + ), + auto_error=False, +) + +# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs, +# aucune validation ne peut échouer et l'annoncer serait faux. +REPONSE_VALIDATION: Final[Reponses] = { + 422: { + "model": ValidationErrorResponse, + "description": ( + "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la " + "valeur envoyée." + ), + }, +} + +REPONSE_SERVEUR: Final[Reponses] = { + 500: { + "model": InternalErrorResponse, + "description": ( + "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas " + "renvoyée au client." + ), + }, +} + +REPONSE_INDISPONIBLE: Final[Reponses] = { + 503: { + "model": ErrorResponse, + "description": "Base injoignable, ou extension TimescaleDB absente de la base.", + }, +} + +REPONSES_AUTHENTIFIEES: Final[Reponses] = { + 401: { + "model": ErrorResponse, + "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=`." + ), + }, +} + +REPONSES_ADMIN: Final[Reponses] = { + **REPONSES_AUTHENTIFIEES, + 403: { + "model": ErrorResponse, + "description": ( + "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut " + "`password_change_required`." + ), + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index faff2b1..9e79763 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -11,6 +11,12 @@ from app.api.deps import ( get_client_ip, require_trusted_origin, ) +from app.api.openapi import ( + REPONSE_VALIDATION, + REPONSES_AUTHENTIFIEES, + Reponses, + cookie_de_rafraichissement, +) from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( @@ -19,6 +25,7 @@ from app.schemas.auth import ( PrincipalResponse, TokenResponse, ) +from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, @@ -32,6 +39,45 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +REPONSES_LOGIN: Reponses = { + **REPONSE_VALIDATION, + 401: { + "model": ErrorResponse, + "description": ( + "Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans " + "les trois cas, et n'apprend donc rien sur l'existence du compte." + ), + }, + 429: { + "model": ErrorResponse, + "description": "Trop de tentatives sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} + +REPONSES_REFRESH: Reponses = { + 401: { + "model": ErrorResponse, + "description": ( + "Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas " + "toute la famille de sessions est révoquée et le cookie est effacé avec la réponse." + ), + }, +} + +REPONSES_MOT_DE_PASSE: Reponses = { + **REPONSE_VALIDATION, + 401: { + "model": ErrorResponse, + "description": "Jeton d'accès invalide, ou mot de passe courant faux.", + }, +} + def repond( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str: return secret -@router.post("/login", response_model=TokenResponse, summary="Ouvre une session") +@router.post( + "/login", + response_model=TokenResponse, + summary="Ouvre une session", + responses=REPONSES_LOGIN, +) async def login( payload: LoginRequest, request: Request, @@ -98,7 +149,8 @@ async def login( "/refresh", response_model=TokenResponse, summary="Fait tourner la session", - dependencies=[Depends(require_trusted_origin)], + dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], + responses=REPONSES_REFRESH, ) async def refresh( request: Request, @@ -133,7 +185,7 @@ async def refresh( "/logout", status_code=status.HTTP_204_NO_CONTENT, summary="Ferme la session courante", - dependencies=[Depends(require_trusted_origin)], + dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], ) async def logout( request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep @@ -150,6 +202,7 @@ async def logout( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme toutes les sessions du compte", dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_AUTHENTIFIEES, ) async def logout_all( principal: CurrentPrincipalDep, @@ -163,7 +216,12 @@ async def logout_all( response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) -@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") +@router.get( + "/me", + response_model=PrincipalResponse, + summary="Décrit le compte connecté", + responses=REPONSES_AUTHENTIFIEES, +) async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: return PrincipalResponse.from_principal(principal) @@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: response_model=TokenResponse, summary="Change son propre mot de passe", dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_MOT_DE_PASSE, ) async def change_password( payload: PasswordChangeRequest, diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..e6d780a 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -3,16 +3,17 @@ from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from app.api.deps import SessionDep, SettingsDep +from app.api.openapi import REPONSE_INDISPONIBLE from app.core.logging import get_logger from app.schemas.health import LivenessStatus, ReadinessStatus logger = get_logger(__name__) -router = APIRouter(tags=["health"]) +router = APIRouter() TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'") -@router.get("/live", summary="Sonde de vivacite") +@router.get("/live", summary="Sonde de vivacité") async def liveness(settings: SettingsDep) -> LivenessStatus: return LivenessStatus( status="ok", @@ -22,7 +23,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: ) -@router.get("/ready", summary="Sonde de disponibilite") +@router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE) async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) diff --git a/apps/backend/app/api/v1/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py index 825645d..794a10a 100644 --- a/apps/backend/app/api/v1/endpoints/users.py +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -3,7 +3,9 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, Response, status from app.api.deps import AdminDep, UserServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses from app.core.logging import get_logger +from app.schemas.errors import ErrorResponse from app.schemas.user import ( TemporaryPasswordResponse, UserCreateRequest, @@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun router = APIRouter() logger = get_logger(__name__) +REPONSES_CREATION: Reponses = { + **REPONSE_VALIDATION, + 409: {"model": ErrorResponse, "description": "Adresse déjà portée par un autre compte."}, +} + +REPONSES_INTROUVABLE: Reponses = { + **REPONSE_VALIDATION, + 404: {"model": ErrorResponse, "description": "Aucun compte ne porte cet identifiant."}, +} + +REPONSES_MODIFICATION: Reponses = { + **REPONSES_INTROUVABLE, + 400: {"model": ErrorResponse, "description": "Corps vide, aucune modification demandée."}, + 409: { + "model": ErrorResponse, + "description": ( + "L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de " + "rétrograder le dernier ou de le désactiver." + ), + }, +} + @router.get("", response_model=list[UserResponse], summary="Liste les comptes") async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]: @@ -27,6 +51,7 @@ async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse] response_model=TemporaryPasswordResponse, status_code=status.HTTP_201_CREATED, summary="Crée un compte avec un mot de passe provisoire", + responses=REPONSES_CREATION, ) async def create_user( payload: UserCreateRequest, @@ -55,7 +80,12 @@ async def create_user( ) -@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation") +@router.patch( + "/{user_id}", + response_model=UserResponse, + summary="Change le rôle ou l'activation", + responses=REPONSES_MODIFICATION, +) async def update_user( user_id: UUID, payload: UserUpdateRequest, @@ -92,6 +122,7 @@ async def update_user( "/{user_id}/password-reset", response_model=TemporaryPasswordResponse, summary="Réinitialise le mot de passe et ferme les sessions", + responses=REPONSES_INTROUVABLE, ) async def reset_password( user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..4a35810 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter +from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN from app.api.v1.endpoints import auth, health, users -api_router = APIRouter() +api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) -api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 4731f81..6733b3a 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"] SameSite = Literal["lax", "strict", "none"] SECRET_KEY_MIN_LENGTH = 32 +REFRESH_COOKIE_DEFAUT = "ev_refresh" SENTINELLES_INTERDITES = frozenset( {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} ) @@ -38,7 +39,7 @@ class Settings(BaseSettings): access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000) - refresh_cookie_name: str = "ev_refresh" + refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT cookie_path: str = "/api/v1/auth" cookie_samesite: SameSite = "strict" cookie_secure: bool | None = None diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 6c3c866..de1235e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware +from app.api.openapi import DESCRIPTION, SUMMARY, TAGS from app.api.security import require_metrics_token from app.api.v1.router import api_router from app.core.config import Settings, get_settings @@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: application = FastAPI( title=resolved.name, version=resolved.version, + summary=SUMMARY, + description=DESCRIPTION, + openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, docs_url="/docs" if documentee else None, diff --git a/apps/backend/app/schemas/errors.py b/apps/backend/app/schemas/errors.py new file mode 100644 index 0000000..5ed1d6c --- /dev/null +++ b/apps/backend/app/schemas/errors.py @@ -0,0 +1,23 @@ +# Piège : ces modèles ne décrivent rien, ils publient. Ce sont eux que Swagger montre, donc ils +# doivent suivre `validation_error_handler()` et `unhandled_error_handler()` d'`app/api/errors.py` +# à la lettre. Un champ renommé là-bas sans l'être ici rend la documentation fausse en silence. + +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + detail: str + + +class FieldError(BaseModel): + champ: str + type: str + + +class ValidationErrorResponse(BaseModel): + detail: list[FieldError] + + +class InternalErrorResponse(BaseModel): + detail: str + correlation: str From da481d7485c20d20c35e50cf013da23e6320ad6b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:12:50 +0200 Subject: [PATCH 060/205] =?UTF-8?q?feat(backend):=20verse=20le=20contrat?= =?UTF-8?q?=20OpenAPI=20au=20d=C3=A9p=C3=B4t=20et=20le=20garde=20honn?= =?UTF-8?q?=C3=AAte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make openapi` écrit apps/backend/openapi.json, et un test compare le fichier versionné au schéma généré. Une route qui change son contrat public le montre donc dans la diff d'une pull request, et une PR qui oublie de régénérer échoue en CI : le fichier vit sous apps/backend, que le filtre de chemins de backend.yml couvre. Le schéma exporté ne lit ni le .env du poste ni les variables APP_ : tout ce qui l'atteint est posé par settings_du_contrat(), sans quoi le fichier changerait de machine en machine. main() réclamait un mot de passe avant de lire la commande. Le branchement passe devant, sinon l'export serait resté bloqué sur getpass. --- Makefile | 5 +- apps/backend/app/cli.py | 46 + apps/backend/openapi.json | 1150 ++++++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 91 ++ apps/backend/tests/test_cli.py | 52 ++ 5 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 apps/backend/openapi.json create mode 100644 apps/backend/tests/api/test_openapi.py diff --git a/Makefile b/Makefile index bf45b61..7035680 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BACKEND := apps/backend .DEFAULT_GOAL := help .PHONY: help install dev lint format typecheck test test-cov test-integration check \ - docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin + openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -34,6 +34,9 @@ test-integration: ## Exécute les tests exigeant une base joignable check: lint typecheck test ## Chaîne de vérification complète +openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées + cd $(BACKEND) && uv run python -m app.cli export-openapi + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 74d7a50..37e94fd 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -7,18 +7,25 @@ import argparse import asyncio +import json import secrets import sys from getpass import getpass +from pathlib import Path +from typing import Any + +from pydantic import SecretStr from app.core.config import Settings, get_settings from app.core.hashing import build_hasher from app.core.roles import Role from app.db.session import get_session_factory +from app.main import create_app from app.repositories.user import UserRepository LONGUEUR_MOT_DE_PASSE_GENERE = 24 LONGUEUR_MINIMALE = 12 +CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" async def create_admin( @@ -55,6 +62,35 @@ async def create_admin( ) +# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le +# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle +# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris. +def settings_du_contrat() -> Settings: + return Settings( + _env_file=None, + name="EnerVision API", + version="0.1.0", + env="local", + api_prefix="/api/v1", + secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"), + database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision", + ) + + +def schema_du_contrat() -> dict[str, Any]: + schema: dict[str, Any] = create_app(settings_du_contrat()).openapi() + return schema + + +def rend_le_contrat() -> str: + return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n" + + +def export_openapi(destination: Path) -> str: + destination.write_text(rend_le_contrat(), encoding="utf-8") + return f"Contrat OpenAPI écrit dans {destination}" + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision") sous_commandes = parser.add_subparsers(dest="commande", required=True) @@ -67,6 +103,11 @@ def build_parser() -> argparse.ArgumentParser: admin.add_argument( "--force", action="store_true", help="Crée le compte même si un administrateur existe" ) + + contrat = sous_commandes.add_parser( + "export-openapi", help="Écrit le contrat OpenAPI sur disque" + ) + contrat.add_argument("--output", default=str(CHEMIN_CONTRAT)) return parser @@ -86,6 +127,11 @@ def read_password(*, generate: bool) -> str: def main(argv: list[str] | None = None) -> int: arguments = build_parser().parse_args(argv) + + if arguments.commande == "export-openapi": + print(export_openapi(Path(arguments.output))) + return 0 + mot_de_passe = read_password(generate=arguments.generate) succes, message = asyncio.run( diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json new file mode 100644 index 0000000..8462c4d --- /dev/null +++ b/apps/backend/openapi.json @@ -0,0 +1,1150 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "EnerVision API", + "summary": "Collecte, analyse et restitution de séries temporelles énergétiques.", + "description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n", + "version": "0.1.0" + }, + "paths": { + "/api/v1/health/live": { + "get": { + "tags": [ + "health" + ], + "summary": "Sonde de vivacité", + "operationId": "liveness_api_v1_health_live_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LivenessStatus" + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/v1/health/ready": { + "get": { + "tags": [ + "health" + ], + "summary": "Sonde de disponibilité", + "operationId": "readiness_api_v1_health_ready_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessStatus" + } + } + } + }, + "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" + } + } + } + }, + "503": { + "description": "Base injoignable, ou extension TimescaleDB absente de la base.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Ouvre une session", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "401": { + "description": "Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans les trois cas, et n'apprend donc rien sur l'existence du compte.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Trop de tentatives sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "auth" + ], + "summary": "Fait tourner la session", + "operationId": "refresh_api_v1_auth_refresh_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "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": "Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas toute la famille de sessions est révoquée et le cookie est effacé avec la réponse.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Cookie de rafraîchissement": [] + } + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Ferme la session courante", + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "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" + } + } + } + } + }, + "security": [ + { + "Cookie de rafraîchissement": [] + } + ] + } + }, + "/api/v1/auth/logout-all": { + "post": { + "tags": [ + "auth" + ], + "summary": "Ferme toutes les sessions du compte", + "operationId": "logout_all_api_v1_auth_logout_all_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "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" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "auth" + ], + "summary": "Décrit le compte connecté", + "operationId": "me_api_v1_auth_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrincipalResponse" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/auth/password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Change son propre mot de passe", + "operationId": "change_password_api_v1_auth_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "401": { + "description": "Jeton d'accès invalide, ou mot de passe courant faux.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/users": { + "get": { + "tags": [ + "users" + ], + "summary": "Liste les comptes", + "operationId": "list_users_api_v1_users_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserResponse" + }, + "type": "array", + "title": "Response List Users Api V1 Users 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" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + }, + "post": { + "tags": [ + "users" + ], + "summary": "Crée un compte avec un mot de passe provisoire", + "operationId": "create_user_api_v1_users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryPasswordResponse" + } + } + } + }, + "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" + } + } + } + }, + "409": { + "description": "Adresse déjà portée par un autre compte.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/users/{user_id}": { + "patch": { + "tags": [ + "users" + ], + "summary": "Change le rôle ou l'activation", + "operationId": "update_user_api_v1_users__user_id__patch", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "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" + } + } + } + }, + "404": { + "description": "Aucun compte ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Corps vide, aucune modification demandée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de rétrograder le dernier ou de le désactiver.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/users/{user_id}/password-reset": { + "post": { + "tags": [ + "users" + ], + "summary": "Réinitialise le mot de passe et ferme les sessions", + "operationId": "reset_password_api_v1_users__user_id__password_reset_post", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryPasswordResponse" + } + } + } + }, + "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" + } + } + } + }, + "404": { + "description": "Aucun compte ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountKind": { + "type": "string", + "enum": [ + "human", + "service" + ], + "title": "AccountKind" + }, + "ErrorResponse": { + "properties": { + "detail": { + "type": "string", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "detail" + ], + "title": "ErrorResponse" + }, + "FieldError": { + "properties": { + "champ": { + "type": "string", + "title": "Champ" + }, + "type": { + "type": "string", + "title": "Type" + } + }, + "type": "object", + "required": [ + "champ", + "type" + ], + "title": "FieldError" + }, + "InternalErrorResponse": { + "properties": { + "detail": { + "type": "string", + "title": "Detail" + }, + "correlation": { + "type": "string", + "title": "Correlation" + } + }, + "type": "object", + "required": [ + "detail", + "correlation" + ], + "title": "InternalErrorResponse" + }, + "LivenessStatus": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status" + }, + "service": { + "type": "string", + "title": "Service" + }, + "version": { + "type": "string", + "title": "Version" + }, + "environment": { + "type": "string", + "title": "Environment" + } + }, + "type": "object", + "required": [ + "status", + "service", + "version", + "environment" + ], + "title": "LivenessStatus" + }, + "LoginRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Password" + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "LoginRequest" + }, + "PasswordChangeRequest": { + "properties": { + "current_password": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Current Password" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 12, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChangeRequest" + }, + "PrincipalResponse": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind" + }, + "must_change_password": { + "type": "boolean", + "title": "Must Change Password" + } + }, + "type": "object", + "required": [ + "id", + "email", + "role", + "kind", + "must_change_password" + ], + "title": "PrincipalResponse" + }, + "ReadinessStatus": { + "properties": { + "status": { + "type": "string", + "const": "ready", + "title": "Status" + }, + "database": { + "type": "string", + "const": "reachable", + "title": "Database" + }, + "timescaledb": { + "type": "string", + "const": "loaded", + "title": "Timescaledb" + } + }, + "type": "object", + "required": [ + "status", + "database", + "timescaledb" + ], + "title": "ReadinessStatus" + }, + "Role": { + "type": "string", + "enum": [ + "lecteur", + "operateur", + "admin" + ], + "title": "Role" + }, + "TemporaryPasswordResponse": { + "properties": { + "user": { + "$ref": "#/components/schemas/UserResponse" + }, + "temporary_password": { + "type": "string", + "title": "Temporary Password" + } + }, + "type": "object", + "required": [ + "user", + "temporary_password" + ], + "title": "TemporaryPasswordResponse" + }, + "TokenResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token" + }, + "token_type": { + "type": "string", + "const": "bearer", + "title": "Token Type", + "default": "bearer" + }, + "expires_in": { + "type": "integer", + "title": "Expires In" + }, + "principal": { + "$ref": "#/components/schemas/PrincipalResponse" + } + }, + "type": "object", + "required": [ + "access_token", + "expires_in", + "principal" + ], + "title": "TokenResponse" + }, + "UserCreateRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "full_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "title": "Full Name" + } + }, + "type": "object", + "required": [ + "email", + "role" + ], + "title": "UserCreateRequest" + }, + "UserResponse": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "must_change_password": { + "type": "boolean", + "title": "Must Change Password" + }, + "full_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Full Name" + }, + "last_login_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Login At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "email", + "role", + "kind", + "is_active", + "must_change_password", + "full_name", + "last_login_at", + "created_at" + ], + "title": "UserResponse" + }, + "UserUpdateRequest": { + "properties": { + "role": { + "anyOf": [ + { + "$ref": "#/components/schemas/Role" + }, + { + "type": "null" + } + ] + }, + "is_active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Active" + } + }, + "type": "object", + "title": "UserUpdateRequest" + }, + "ValidationErrorResponse": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/FieldError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "detail" + ], + "title": "ValidationErrorResponse" + } + }, + "securitySchemes": { + "Cookie de rafraîchissement": { + "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": { + "type": "http", + "scheme": "bearer" + } + } + }, + "tags": [ + { + "name": "health", + "description": "Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` que la base répond et que l'extension TimescaleDB est chargée." + }, + { + "name": "auth", + "description": "Ouverture, rotation et fermeture de session, et changement de son propre mot de passe." + }, + { + "name": "users", + "description": "Administration des comptes. Réservé au rôle `admin`." + } + ] +} diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py new file mode 100644 index 0000000..ca979d5 --- /dev/null +++ b/apps/backend/tests/api/test_openapi.py @@ -0,0 +1,91 @@ +# Pourquoi : `openapi.json` est versionné, donc une route qui change son contrat public le montre +# dans la diff d'une pull request. `test_the_committed_contract_matches_the_generated_one` est ce +# qui empêche le fichier de dériver du code sans que personne ne le voie. + +import json +from typing import Any + +import pytest + +from app import cli + +METHODES = {"get", "post", "patch", "put", "delete"} + +# `/auth/logout` lit le cookie mais ne le réclame pas : sans session elle répond 204, et un 401 +# documenté y serait faux. +SANS_REFUS = {("POST", "/api/v1/auth/logout")} + + +@pytest.fixture(scope="module") +def schema() -> dict[str, Any]: + return cli.schema_du_contrat() + + +def operations(schema: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]: + return [ + (methode.upper(), chemin, operation) + for chemin, operations_du_chemin in schema["paths"].items() + for methode, operation in operations_du_chemin.items() + if methode in METHODES + ] + + +def test_the_committed_contract_matches_the_generated_one(schema: dict[str, Any]) -> None: + publie = json.loads(cli.CHEMIN_CONTRAT.read_text(encoding="utf-8")) + + assert publie == schema, "lancer `make openapi` et versionner le fichier obtenu" + + +def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, Any]) -> None: + muettes = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if operation.get("security") + and (methode, chemin) not in SANS_REFUS + and "401" not in operation["responses"] + ] + + assert muettes == [] + + +def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: + sans_403 = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if "users" in operation.get("tags", []) and "403" not in operation["responses"] + ] + + assert sans_403 == [] + + +def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None: + modeles = { + operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"] + for _, _, operation in operations(schema) + if "422" in operation["responses"] + } + + assert modeles == {"#/components/schemas/ValidationErrorResponse"} + assert "HTTPValidationError" not in schema["components"]["schemas"] + + +def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> None: + trop_de_tentatives = schema["paths"]["/api/v1/auth/login"]["post"]["responses"]["429"] + + assert "Retry-After" in trop_de_tentatives["headers"] + + +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" + + +def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None: + decrits = {tag["name"] for tag in schema["tags"]} + + for methode, chemin, operation in operations(schema): + poses = operation.get("tags", []) + assert len(poses) == len(set(poses)), f"tag en double sur {methode} {chemin}" + assert set(poses) <= decrits, f"tag non décrit sur {methode} {chemin}" diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index d8465b5..40b8317 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + import pytest from app import cli @@ -55,3 +58,52 @@ def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyP with pytest.raises(SystemExit): cli.read_password(generate=False) + + +def test_build_parser_reads_the_export_openapi_arguments() -> None: + arguments = cli.build_parser().parse_args( + ["export-openapi", "--output", "ailleurs/contrat.json"] + ) + + assert arguments.commande == "export-openapi" + assert arguments.output == "ailleurs/contrat.json" + + +def test_build_parser_defaults_the_export_to_the_versioned_contract() -> None: + arguments = cli.build_parser().parse_args(["export-openapi"]) + + assert arguments.output == str(cli.CHEMIN_CONTRAT) + + +def test_settings_of_the_contract_ignore_the_local_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("APP_API_PREFIX", "/api/v9") + monkeypatch.setenv("APP_NAME", "API du poste de Johan") + + settings = cli.settings_du_contrat() + + assert settings.api_prefix == "/api/v1" + assert settings.name == "EnerVision API" + + +def test_export_openapi_writes_a_readable_schema_where_asked(tmp_path: Path) -> None: + destination = tmp_path / "contrat.json" + + cli.export_openapi(destination) + + assert json.loads(destination.read_text(encoding="utf-8"))["openapi"].startswith("3.") + + +# Piège : `main()` réclamait un mot de passe avant de lire la commande. Sans le branchement, +# l'export resterait bloqué sur `getpass` et aucune CI ne pourrait le rejouer. +def test_main_exports_the_contract_without_asking_for_a_password( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + destination = tmp_path / "contrat.json" + + code = cli.main(["export-openapi", "--output", str(destination)]) + + assert code == 0 + assert destination.exists() + assert str(destination) in capsys.readouterr().out From 3347fa5bdbcf7f348d469410fc037914fe6c924b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:14:19 +0200 Subject: [PATCH 061/205] docs(architecture): acte le contrat OpenAPI dans la vue backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20-backend.md gagne une section qui dit où vit le schéma, comment on le régénère, pourquoi il est versionné en plus d'être servi, et pourquoi servers, license_info et contact restent absents. La table des routes gagne la colonne des codes d'erreur déclarés. 31-contrat-authentification.md renvoyait le frontend vers /docs, donc vers une API qui tourne. Il renvoie maintenant vers le fichier, lisible sans rien lancer. --- apps/backend/README.md | 6 +- docs/architecture/20-backend.md | 74 +++++++++++++++---- .../31-contrat-authentification.md | 4 +- docs/architecture/README.md | 2 +- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 12fd9ba..400498c 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -28,7 +28,7 @@ de demarrer sans elles. ## Commandes Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`, -`make format`, `make typecheck`, `make test`, `make check`, `make docker-build`. +`make format`, `make typecheck`, `make test`, `make check`, `make openapi`, `make docker-build`. Directement depuis ce dossier : @@ -39,8 +39,12 @@ uv run ruff format . # format uv run mypy app # typage strict uv run pytest # tests + couverture uv run pytest -m integration # tests exigeant une base joignable +uv run python -m app.cli export-openapi # régénère openapi.json ``` +`openapi.json` est versionné : `tests/api/test_openapi.py` échoue si le fichier ne correspond +plus aux routes déclarées. Toute PR qui change une route le régénère dans le même commit. + Les conventions de tests, les gabarits et le detail des marqueurs sont dans [`TESTING.md`](TESTING.md). diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..1454a39 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -126,22 +126,25 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke ## Routes exposées -| Méthode | Chemin | Dans l'OpenAPI | Rôle | +| Méthode | Chemin | Rôle | Erreurs déclarées | |---|---|---|---| -| GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base | -| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée | -| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique | -| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement | -| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente | -| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte | -| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe | -| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté | -| GET | `/api/v1/users` | oui | Liste les comptes. `admin` | -| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | -| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | -| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | -| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | -| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | +| GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 | +| GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 | +| POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 | +| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 500 | +| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 500 | +| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 500 | +| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 422, 500 | +| GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 | +| GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 | +| POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 | +| PATCH | `/api/v1/users/{id}` | Change le rôle ou l'activation. `admin` | 400, 401, 403, 404, 409, 422, 500 | +| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 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` | | + +Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier +`openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. **Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et @@ -182,6 +185,47 @@ sequenceDiagram end ``` +## Contrat OpenAPI + +Statut : `Fait`. + +Le schéma est servi sur `/openapi.json`, `/docs` et `/redoc`, fermés en `staging` et en `prod`. +Il est aussi **versionné** dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json) : + +```bash +make openapi +``` + +Pourquoi un fichier en plus de la route. Une route qui change son contrat public le montre alors +dans la diff de la pull request, et le frontend dispose d'une référence lisible sans lancer l'API. +`tests/api/test_openapi.py` compare le fichier au schéma généré et échoue si l'un bouge sans +l'autre ; le fichier vivant sous `apps/backend/`, le filtre de chemins de `backend.yml` le couvre. + +**Le schéma exporté ne dépend pas du poste.** `settings_du_contrat()` pose le nom, la version et +le préfixe, et coupe la lecture du `.env`. Sans cela, un `APP_API_PREFIX` local suffirait à faire +diverger le fichier d'une machine à l'autre, et le test deviendrait un oracle de configuration +plutôt qu'un garde-fou de contrat. + +Trois champs sont volontairement absents d'`info`, parce qu'ils poseraient une décision qui n'est +pas prise : + +| Champ | Pourquoi | +|---|---| +| `servers` | L'URL publique dépend de l'ingress, question ouverte dans [10-infra.md](10-infra.md) | +| `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 +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. + +Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renvoient réellement. +`ValidationErrorResponse` remplace le `HTTPValidationError` par défaut de FastAPI, dont la clé +`loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et +`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence. + ## Sécurité Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index f02fd1b..ec852c1 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -26,7 +26,9 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | PATCH | `/api/v1/users/{id}` | jeton d'accès, `admin` | `200` `UserResponse` | | POST | `/api/v1/users/{id}/password-reset` | jeton d'accès, `admin` | `200` `TemporaryPasswordResponse` | -Le schéma exact est dans `/docs` (Swagger), servi en local et en développement. +Le schéma exact est dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json), +lisible sans lancer l'API, et servi par `/docs` en local et en développement. La table des +codes d'erreur ci-dessous reste la référence de comportement, le schéma celle de forme. ## Charges utiles diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c6b91f0..1c8c9a9 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -10,7 +10,7 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. |---|---| | [00-vue-ensemble.md](00-vue-ensemble.md) | Jalons du projet, contexte, conteneurs, sécurité, flux bout en bout | | [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms | -| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration | +| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI | | [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | | [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | From 5e7cb005acb8a4c14ecbb0c44cd9ce52655ff4a8 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:25:22 +0200 Subject: [PATCH 062/205] feat(build): branche le frontend sur make dev Ajoute install-frontend/dev-frontend au Makefile, dev/install deviennent composites (backend + frontend lances ensemble), et met a jour README et docs/architecture en consequence. Closes #75 --- Makefile | 22 +++++++++++++++++++--- README.md | 15 +++++++++------ docs/architecture/10-infra.md | 7 ++++--- docs/architecture/30-frontend.md | 5 +++-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index bf45b61..1576eae 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,34 @@ BACKEND := apps/backend +FRONTEND := apps/frontend .DEFAULT_GOAL := help -.PHONY: help install dev lint format typecheck test test-cov test-integration check \ +.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \ + lint format typecheck test test-cov test-integration check \ docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: ## Installe les dépendances du backend +install: install-backend install-frontend ## Installe les dépendances backend et frontend + +install-backend: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups -dev: ## Lance l'API en rechargement à chaud +install-frontend: ## Installe les dépendances du frontend + cd $(FRONTEND) && npm ci + +dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud + @trap 'kill 0' EXIT INT TERM; \ + $(MAKE) dev-backend & \ + $(MAKE) dev-frontend & \ + wait + +dev-backend: ## Lance l'API seule en rechargement à chaud cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 +dev-frontend: ## Lance le frontend seul en rechargement à chaud + cd $(FRONTEND) && npm start + lint: ## Analyse statique du backend cd $(BACKEND) && uv run ruff check . diff --git a/README.md b/README.md index 75a1ab6..27affce 100644 --- a/README.md +++ b/README.md @@ -63,16 +63,17 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans ## Demarrage -Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul. +Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que +`uv` installe seul. ```bash cp .env.example .env # variables de docker-compose cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433 -make install # dependances du backend +make install # dependances du backend et du frontend make migrate # applique les migrations Alembic -make dev # API sur http://localhost:8000, docs sur /docs +make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200 make check # lint + typage + tests ``` @@ -83,9 +84,11 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker 5432, souvent deja pris par une autre base. La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en -conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette, -et n'embarque pas le source, donc toute modification y demande un -`docker compose up -d --build backend`. +conteneur, le 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`. Verifier que la base repond et que l'extension est chargee : diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 4e82445..745c6f5 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -35,9 +35,10 @@ flowchart TB | `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | **La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` : -seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le -service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se -lancent donc pas ensemble. +seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement +à chaud, lancés ensemble par `make dev` (`make dev-backend`/`make dev-frontend` pour lancer l'un +des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le +port 8000, ils ne se lancent donc pas ensemble. Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index baea451..3c361e5 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -108,8 +108,9 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni tester ni construire le frontend. -Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans -`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 +Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`, +englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en +développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200 n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans From 11baea7117750757b9e434b2909137c2de9c5cca Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 10:29:47 +0200 Subject: [PATCH 063/205] =?UTF-8?q?changement=20d'ordre=20des=20jobs=20+?= =?UTF-8?q?=20ajout=20des=20d=C3=A9pendances=20entre=20les=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend.yml | 59 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 4696599..1aa8981 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,4 +1,5 @@ name: Frontend +# Pipeline à choix multiple on: # workflow_dispatch -> lancement manuel des jobs @@ -12,7 +13,7 @@ on: - sonarqube - test - deploy - - all + - all # lancer tous les jobs push: paths: - "apps/frontend/**" @@ -22,34 +23,11 @@ on: - "apps/frontend/**" - ".github/workflows/frontend.yml" +# Ordre de lancement des jobs +# build -> test -> sonarqube -> deploy jobs: - sonarqube: - name: SonarQube - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - test: - if: ${{ github.event.inputs.job_choice == 'test' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npm test -- --watch=false - build: - # si l'utilisateur a choise le job 'build' ou l'ensemble des jobs avec l'option 'all' if: ${{ github.event.inputs.job_choice == 'build' || github.event.inputs.job_choice == 'all' }} runs-on: ubuntu-latest steps: @@ -61,9 +39,36 @@ jobs: - run: npm ci - run: npm run build + + test: + if: ${{ github.event.inputs.job_choice == 'test' || github.event.inputs.job_choice == 'all' }} + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm test -- --watch=false + + sonarqube: + if: ${{ github.event.inputs.job_choice == 'sonarqube' || github.event.inputs.job_choice == 'all' }} + needs: [build, test] + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # deploy: - # if: ${{ github.event.inputs.job_choice == 'deploy' }} # runs-on: ubuntu-latest # steps: # - run: echo "DEPLOY job is running" From 16a0cc4d3b4dfb35e7ec9425b0bc333afcde7e86 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:36:31 +0200 Subject: [PATCH 064/205] fix(build): stabilise make dev pour le frontend Desactive le prompt d'analytics Angular CLI (bloquait ng serve en sous-processus non interactif) et affiche les URLs backend/frontend au demarrage de make dev. --- Makefile | 6 ++++-- apps/frontend/angular.json | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1576eae..05a8f7d 100644 --- a/Makefile +++ b/Makefile @@ -19,14 +19,16 @@ install-frontend: ## Installe les dépendances du frontend dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud @trap 'kill 0' EXIT INT TERM; \ - $(MAKE) dev-backend & \ - $(MAKE) dev-frontend & \ + $(MAKE) --no-print-directory dev-backend & \ + $(MAKE) --no-print-directory dev-frontend & \ wait dev-backend: ## Lance l'API seule en rechargement à chaud + @echo "backend -> http://localhost:8000 (docs sur /docs)" cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 dev-frontend: ## Lance le frontend seul en rechargement à chaud + @echo "frontend -> http://localhost:4200" cd $(FRONTEND) && npm start lint: ## Analyse statique du backend diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index ddf87a3..814e4f8 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { From 1325a75e9afec660e168d9828064f9c1498eddaf Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:01:13 +0200 Subject: [PATCH 065/205] feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..095eafa 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From fc6600aeafbaddd8795bc9a3d55f7663c572e1ca Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:02:00 +0200 Subject: [PATCH 066/205] Revert "feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id}" This reverts commit 1325a75e9afec660e168d9828064f9c1498eddaf. --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 095eafa..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except (SQLAlchemyError, OSError): + except SQLAlchemyError, OSError: logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From 50dddf952b5b236fde081473ecbc7d854989bfc7 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:03:06 +0200 Subject: [PATCH 067/205] feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} --- apps/backend/README.md | 2 + apps/backend/app/api/deps.py | 9 ++ apps/backend/app/api/v1/endpoints/sites.py | 24 ++++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/repositories/site.py | 19 +++ apps/backend/app/schemas/site.py | 12 ++ apps/backend/app/services/site.py | 26 ++++ apps/backend/tests/api/test_sites.py | 141 +++++++++++++++++++ apps/backend/tests/factories.py | 14 ++ apps/backend/tests/repositories/test_site.py | 58 ++++++++ apps/backend/tests/services/test_site.py | 49 +++++++ docs/architecture/00-vue-ensemble.md | 4 +- docs/architecture/20-backend.md | 19 ++- docs/architecture/owasp-traceabilite.md | 7 +- 14 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/sites.py create mode 100644 apps/backend/app/repositories/site.py create mode 100644 apps/backend/app/schemas/site.py create mode 100644 apps/backend/app/services/site.py create mode 100644 apps/backend/tests/api/test_sites.py create mode 100644 apps/backend/tests/repositories/test_site.py create mode 100644 apps/backend/tests/services/test_site.py diff --git a/apps/backend/README.md b/apps/backend/README.md index 12fd9ba..7be51b8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | +| `/api/v1/sites` | Liste les sites | `lecteur` | +| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4dc32cb..f16d167 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -24,8 +24,10 @@ from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.site import SiteService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -131,6 +133,13 @@ def get_user_service( UserServiceDep = Annotated[UserService, Depends(get_user_service)] +def get_site_service(session: SessionDep) -> SiteService: + return SiteService(sites=SiteRepository(session)) + + +SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py new file mode 100644 index 0000000..c71ec7f --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import LecteurDep, SiteServiceDep +from app.schemas.site import SiteResponse +from app.services.site import SiteNotFoundError + +router = APIRouter() + + +@router.get("", response_model=list[SiteResponse], summary="Liste les sites") +async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]: + sites = await service.list_all() + return [SiteResponse.model_validate(site) for site in sites] + + +@router.get("/{site_id}", response_model=SiteResponse, summary="Décrit un site") +async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse: + try: + site = await service.get_by_id(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteResponse.model_validate(site) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..4d151be 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, users +from app.api.v1.endpoints import auth, health, sites, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(sites.router, prefix="/sites", tags=["sites"]) diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..383a566 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -0,0 +1,19 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site + + +class SiteRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Site]: + requete = select(Site).order_by(Site.site_id) + return (await self._session.scalars(requete)).all() + + async def get_by_id(self, site_id: str) -> Site | None: + requete = select(Site).where(Site.site_id == site_id) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py new file mode 100644 index 0000000..82035f5 --- /dev/null +++ b/apps/backend/app/schemas/site.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class SiteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + site_type: str + location: str | None + capacity_kw: float | None + status: str | None diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py new file mode 100644 index 0000000..515497a --- /dev/null +++ b/apps/backend/app/services/site.py @@ -0,0 +1,26 @@ +from collections.abc import Sequence + +from app.models.energy import Site +from app.repositories.site import SiteRepository + + +class SiteError(Exception): + pass + + +class SiteNotFoundError(SiteError): + pass + + +class SiteService: + def __init__(self, *, sites: SiteRepository) -> None: + self._sites = sites + + async def list_all(self) -> Sequence[Site]: + return await self._sites.list_all() + + async def get_by_id(self, site_id: str) -> Site: + site = await self._sites.get_by_id(site_id) + if site is None: + raise SiteNotFoundError(site_id) + return site diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py new file mode 100644 index 0000000..3692565 --- /dev/null +++ b/apps/backend/tests/api/test_sites.py @@ -0,0 +1,141 @@ +from collections.abc import Callable, Iterator +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_site_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Site +from app.services.site import SiteNotFoundError + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def site(site_id: str = "site-1") -> Site: + return Site( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.site = site() + + async def list_all(self) -> list[Site]: + return [self.site] + + async def get_by_id(self, site_id: str) -> Site: + if self._erreur is not None: + raise self._erreur + return self.site + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Exception | None], FauxService]]: + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + app.dependency_overrides[get_site_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_site_service, None) + + +async def test_list_sites_returns_the_sites( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "site_id": "site-1", + "site_name": "Site de test", + "site_type": "industriel", + "location": "Toulouse", + "capacity_kw": 42.0, + "status": "actif", + } + ] + + +async def test_get_site_returns_the_matching_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1") + + assert response.status_code == 200 + assert response.json()["site_id"] == "site-1" + + +async def test_get_site_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu") + + assert response.status_code == 404 + + +async def test_list_sites_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[site("a"), site("b")]) + + response = await client.get("/api/v1/sites") + + assert response.status_code == 200 + assert [s["site_id"] for s in response.json()] == ["a", "b"] + + +async def test_get_site_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=site("a")) + + response = await client.get("/api/v1/sites/a") + + assert response.status_code == 200 + assert response.json()["site_id"] == "a" + + +async def test_get_site_returns_404_when_the_session_finds_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/sites/inconnu") + + assert response.status_code == 404 diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 17433c5..c606ee0 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Any from app.core.config import Settings @@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = { } +class FakeScalars: + """Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies.""" + + def __init__(self, rows: Sequence[object]) -> None: + self._rows = rows + + def all(self) -> Sequence[object]: + return self._rows + + class FakeSession: """Session factice : renvoie `result`, ou leve `failure` si elle est fournie.""" @@ -25,6 +36,9 @@ class FakeSession: async def execute(self, *_: object, **__: object) -> object: return self._repondre() + async def scalars(self, *_: object, **__: object) -> FakeScalars: + return FakeScalars(self._repondre() or []) + def _repondre(self) -> object: if self._failure is not None: raise self._failure diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..222d398 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -0,0 +1,58 @@ +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site +from app.repositories.site import SiteRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"site-{uuid.uuid4().hex[:12]}" + + +async def creer(session: AsyncSession, **overrides: object) -> Site: + site = Site( + site_id=overrides.get("site_id", identifiant()), + site_name=overrides.get("site_name", "Site de test"), + site_type=overrides.get("site_type", "industriel"), + location=overrides.get("location", "Toulouse"), + capacity_kw=overrides.get("capacity_kw", 42.0), + status=overrides.get("status", "actif"), + ) + session.add(site) + await session.flush() + return site + + +async def test_get_by_id_returns_the_matching_site(session: AsyncSession) -> None: + depot = SiteRepository(session) + cree = await creer(session) + + trouve = await depot.get_by_id(cree.site_id) + nom = trouve.site_name if trouve else None + await session.rollback() + + assert nom == "Site de test" + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await SiteRepository(session).get_by_id(identifiant()) + + assert trouve is None + + +async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None: + depot = SiteRepository(session) + await creer(session, site_id=f"zz-{identifiant()}") + await creer(session, site_id=f"aa-{identifiant()}") + + sites = await depot.list_all() + identifiants = [site.site_id for site in sites] + await session.rollback() + + assert identifiants == sorted(identifiants) diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py new file mode 100644 index 0000000..73ef21f --- /dev/null +++ b/apps/backend/tests/services/test_site.py @@ -0,0 +1,49 @@ +import pytest + +from app.models.energy import Site +from app.services.site import SiteNotFoundError, SiteService + + +def site(site_id: str = "site-1") -> Site: + return Site( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +class FakeRepository: + def __init__(self, sites: list[Site]) -> None: + self._sites = sites + + async def list_all(self) -> list[Site]: + return self._sites + + async def get_by_id(self, site_id: str) -> Site | None: + return next((s for s in self._sites if s.site_id == site_id), None) + + +async def test_list_all_returns_the_repository_sites() -> None: + service = SiteService(sites=FakeRepository([site("a"), site("b")])) + + sites = await service.list_all() + + assert [s.site_id for s in sites] == ["a", "b"] + + +async def test_get_by_id_returns_the_matching_site() -> None: + service = SiteService(sites=FakeRepository([site("a")])) + + trouve = await service.get_by_id("a") + + assert trouve.site_id == "a" + + +async def test_get_by_id_raises_when_the_site_is_unknown() -> None: + service = SiteService(sites=FakeRepository([])) + + with pytest.raises(SiteNotFoundError): + await service.get_by_id("inconnu") diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index d083985..96fb992 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,9 +74,9 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| -| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | -| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | +| 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`) | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..2972a24 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users"] + ep["endpoints
health, auth, users, sites"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log"] - md["models
4 tables"] + sv["services
AuthService, UserService,
SiteService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site"] + md["models
10 tables"] db[("PostgreSQL")] ep --> sc @@ -140,6 +140,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | | PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | | POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/api/v1/sites` | oui | Liste les sites. `lecteur` | +| GET | `/api/v1/sites/{site_id}` | oui | Décrit un site. `lecteur` | | GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | | GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | @@ -148,7 +150,14 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. -Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser +pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre +couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables +déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, +contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par +`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par +`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base +réelle. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index ada1a45..ac4a8af 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -8,8 +8,9 @@ de réponse honnête. Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse, et une section qui dit ce qui n'est pas couvert et pourquoi. -Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier -n'existent pas encore, donc plusieurs lignes resteront à compléter. +Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et +`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes +resteront à compléter une fois les endpoints d'écriture posés. ## Contrôles en place @@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | Item | État | Raison | |---|---|---| -| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | +| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | | **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | | **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | From 31a9cb109f2de407d8b49f1f8edc45b9d4d3b688 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:04:27 +0200 Subject: [PATCH 068/205] fix(backend): corrige la syntaxe except invalide de la sonde /health/ready --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..095eafa 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From fabd073aaffe39cad03fcab99db7f2ae8c7fc5f4 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:16:38 +0200 Subject: [PATCH 069/205] fix(backend): documente le 403 CSRF de require_trusted_origin Le contrat OpenAPI et 31-contrat-authentification.md passaient sous silence le 403 leve par require_trusted_origin sur refresh, logout, logout-all et password. Ajoute REPONSE_ORIGINE_REFUSEE, regenere openapi.json et etend test_openapi.py pour verifier que ces quatre routes le declarent. --- apps/backend/app/api/openapi.py | 7 ++++ apps/backend/app/api/v1/endpoints/auth.py | 10 ++++- apps/backend/openapi.json | 40 +++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 17 ++++++++ docs/architecture/20-backend.md | 8 ++-- .../31-contrat-authentification.md | 1 + 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index fedb3bf..c96e351 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -112,3 +112,10 @@ REPONSES_ADMIN: Final[Reponses] = { ), }, } + +REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { + 403: { + "model": ErrorResponse, + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 9e79763..32bf8b2 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, Reponses, @@ -61,6 +62,7 @@ REPONSES_LOGIN: Reponses = { } REPONSES_REFRESH: Reponses = { + **REPONSE_ORIGINE_REFUSEE, 401: { "model": ErrorResponse, "description": ( @@ -70,8 +72,13 @@ REPONSES_REFRESH: Reponses = { }, } +REPONSES_LOGOUT: Reponses = {**REPONSE_ORIGINE_REFUSEE} + +REPONSES_LOGOUT_ALL: Reponses = {**REPONSES_AUTHENTIFIEES, **REPONSE_ORIGINE_REFUSEE} + REPONSES_MOT_DE_PASSE: Reponses = { **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, 401: { "model": ErrorResponse, "description": "Jeton d'accès invalide, ou mot de passe courant faux.", @@ -186,6 +193,7 @@ async def refresh( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme la session courante", dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], + responses=REPONSES_LOGOUT, ) async def logout( request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep @@ -202,7 +210,7 @@ async def logout( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme toutes les sessions du compte", dependencies=[Depends(require_trusted_origin)], - responses=REPONSES_AUTHENTIFIEES, + responses=REPONSES_LOGOUT_ALL, ) async def logout_all( principal: CurrentPrincipalDep, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 8462c4d..cca65d0 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -186,6 +186,16 @@ } } }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "401": { "description": "Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas toute la famille de sessions est révoquée et le cookie est effacé avec la réponse.", "content": { @@ -224,6 +234,16 @@ } } } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -263,6 +283,16 @@ } } } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -366,6 +396,16 @@ } } }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "401": { "description": "Jeton d'accès invalide, ou mot de passe courant faux.", "content": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index ca979d5..96297c0 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -15,6 +15,13 @@ METHODES = {"get", "post", "patch", "put", "delete"} # documenté y serait faux. SANS_REFUS = {("POST", "/api/v1/auth/logout")} +ORIGINE_VERIFIEE = { + ("POST", "/api/v1/auth/refresh"), + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/logout-all"), + ("POST", "/api/v1/auth/password"), +} + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: @@ -58,6 +65,16 @@ def test_every_administration_route_documents_the_role_refusal(schema: dict[str, assert sans_403 == [] +def test_every_origin_checked_route_documents_the_csrf_refusal(schema: dict[str, Any]) -> None: + sans_403 = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if (methode, chemin) in ORIGINE_VERIFIEE and "403" not in operation["responses"] + ] + + assert sans_403 == [] + + def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None: modeles = { operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 1454a39..7158c9a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -131,10 +131,10 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 | | GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 | | POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 | -| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 500 | -| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 500 | -| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 500 | -| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 422, 500 | +| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 | +| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 403, 500 | +| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 403, 500 | +| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 403, 422, 500 | | GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 | | GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 | | POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 | diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index ec852c1..9c9fe66 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -68,6 +68,7 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion | | `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe | | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | +| `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | ## Les quatre règles qui comptent From 596cf43eda9012708a7bc77242f72cb23ed9de08 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 11:22:58 +0200 Subject: [PATCH 070/205] test(frontend): lancement manuel du workflow --- .github/workflows/frontend.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 1aa8981..774d9dc 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -6,18 +6,15 @@ on: workflow_dispatch: inputs: job_choice: - type: choice + required: true description: "Choix du job" + type: choice + default: all options: - build - sonarqube - test - - deploy - all # lancer tous les jobs - push: - paths: - - "apps/frontend/**" - - ".github/workflows/frontend.yml" pull_request: paths: - "apps/frontend/**" @@ -36,12 +33,15 @@ jobs: with: node-version: 24 cache: npm + cache-dependency-path: apps/frontend/package-lock.json - run: npm ci + working-directory: apps/frontend - run: npm run build + working-directory: apps/frontend test: - if: ${{ github.event.inputs.job_choice == 'test' || github.event.inputs.job_choice == 'all' }} + if: ${{ always() && (github.event.inputs.job_choice == 'test' || github.event.inputs.job_choice == 'all') }} needs: build runs-on: ubuntu-latest steps: @@ -50,11 +50,14 @@ jobs: with: node-version: 24 cache: npm + cache-dependency-path: apps/frontend/package-lock.json - run: npm ci + working-directory: apps/frontend - run: npm test -- --watch=false + working-directory: apps/frontend sonarqube: - if: ${{ github.event.inputs.job_choice == 'sonarqube' || github.event.inputs.job_choice == 'all' }} + if: ${{ always() && (github.event.inputs.job_choice == 'sonarqube' || github.event.inputs.job_choice == 'all') }} needs: [build, test] name: SonarQube runs-on: ubuntu-latest From 078983a41dee32f13e5095c2ff7ab672cb469f48 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 11:25:55 +0200 Subject: [PATCH 071/205] =?UTF-8?q?test(frontend):=20suppression=20de=20la?= =?UTF-8?q?=20propri=C3=A9t=C3=A9=20'pull-request'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 774d9dc..1cc9af3 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -15,10 +15,7 @@ on: - sonarqube - test - all # lancer tous les jobs - pull_request: - paths: - - "apps/frontend/**" - - ".github/workflows/frontend.yml" + # Ordre de lancement des jobs # build -> test -> sonarqube -> deploy From 1f0eb410eb067619b0e57b58c709aed6cf592cb2 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 11:28:49 +0200 Subject: [PATCH 072/205] test(frontend): suppression des conditions if --- .github/workflows/frontend.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 1cc9af3..0f437a6 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -22,7 +22,6 @@ on: jobs: build: - if: ${{ github.event.inputs.job_choice == 'build' || github.event.inputs.job_choice == 'all' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -37,8 +36,7 @@ jobs: - run: npm run build working-directory: apps/frontend - test: - if: ${{ always() && (github.event.inputs.job_choice == 'test' || github.event.inputs.job_choice == 'all') }} + test: needs: build runs-on: ubuntu-latest steps: @@ -54,7 +52,6 @@ jobs: working-directory: apps/frontend sonarqube: - if: ${{ always() && (github.event.inputs.job_choice == 'sonarqube' || github.event.inputs.job_choice == 'all') }} needs: [build, test] name: SonarQube runs-on: ubuntu-latest From 22ff1d93f4c2391007806189e46ac5b88ca4c5b8 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:29:18 +0200 Subject: [PATCH 073/205] fix(backend): type le retour de SiteRepository.get_by_id pour mypy strict --- apps/backend/app/repositories/site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py index 383a566..c7abbe8 100644 --- a/apps/backend/app/repositories/site.py +++ b/apps/backend/app/repositories/site.py @@ -16,4 +16,5 @@ class SiteRepository: async def get_by_id(self, site_id: str) -> Site | None: requete = select(Site).where(Site.site_id == site_id) - return await self._session.scalar(requete) + site: Site | None = await self._session.scalar(requete) + return site From d1e4d8cfa04f70f3d200f3dbd1a647bd8bcb4f7b Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 11:31:10 +0200 Subject: [PATCH 074/205] =?UTF-8?q?test(frontend):=20lancement=20automatiq?= =?UTF-8?q?ue=20du=20workflow=20apr=C3=A8s=20un=20push=20ou=20avec=20une?= =?UTF-8?q?=20pull=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 0f437a6..3bc627e 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -15,8 +15,14 @@ on: - sonarqube - test - all # lancer tous les jobs - - + push: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" + pull_request: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" # Ordre de lancement des jobs # build -> test -> sonarqube -> deploy From d25e544db62713320cd5d4d5ff603dea0e8da387 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:41:53 +0200 Subject: [PATCH 075/205] =?UTF-8?q?fix(backend):=20contourne=20un=20bug=20?= =?UTF-8?q?de=20ruff=20format=20sur=20le=20except=20=C3=A0=20deux=20types?= =?UTF-8?q?=20de=20health.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/app/api/v1/endpoints/health.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 095eafa..fab0a1e 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except (SQLAlchemyError, OSError): + # `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce + # `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`). + except (SQLAlchemyError, OSError): # fmt: skip logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From 50dcb4de32b22a06ab8d0e44bc44e90a2227c756 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:45:58 +0200 Subject: [PATCH 076/205] feat(backend): expose GET /api/v1/stats/summary Ajoute le resume instantane de consommation du parc attendu par le frontend (deja developpe contre ce contrat en mode mock). Nouveaux SiteRepository et ReadingRepository (derniere lecture par site via DISTINCT ON), StatsService pour l'agregation et les cas de repli (capacite nulle, absence de lecture, data_quality inconnue), et le endpoint lecteur-seul correspondant. Documentation des routes et du schema des couches mises a jour. --- apps/backend/app/api/deps.py | 10 ++ apps/backend/app/api/v1/endpoints/stats.py | 16 +++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/repositories/reading.py | 21 ++++ apps/backend/app/repositories/site.py | 15 +++ apps/backend/app/schemas/stats.py | 26 +++++ apps/backend/app/services/stats.py | 81 +++++++++++++ apps/backend/tests/api/test_stats.py | 73 ++++++++++++ .../tests/repositories/test_reading.py | 72 ++++++++++++ apps/backend/tests/repositories/test_site.py | 31 +++++ apps/backend/tests/services/test_stats.py | 107 ++++++++++++++++++ docs/architecture/20-backend.md | 11 +- 12 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/stats.py create mode 100644 apps/backend/app/repositories/reading.py create mode 100644 apps/backend/app/repositories/site.py create mode 100644 apps/backend/app/schemas/stats.py create mode 100644 apps/backend/app/services/stats.py create mode 100644 apps/backend/tests/api/test_stats.py create mode 100644 apps/backend/tests/repositories/test_reading.py create mode 100644 apps/backend/tests/repositories/test_site.py create mode 100644 apps/backend/tests/services/test_stats.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4dc32cb..3407de3 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,9 +23,12 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.reading import ReadingRepository from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.stats import StatsService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -131,6 +134,13 @@ def get_user_service( UserServiceDep = Annotated[UserService, Depends(get_user_service)] +def get_stats_service(session: SessionDep) -> StatsService: + return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/stats.py b/apps/backend/app/api/v1/endpoints/stats.py new file mode 100644 index 0000000..5a8502e --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/stats.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.deps import LecteurDep, StatsServiceDep +from app.schemas.stats import StatsSummaryResponse + +router = APIRouter() + + +@router.get( + "/summary", + response_model=StatsSummaryResponse, + summary="Résume la consommation instantanée du parc", +) +async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse: + resume = await service.summary() + return StatsSummaryResponse.model_validate(resume) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..06e8852 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, users +from app.api.v1.endpoints import auth, health, stats, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py new file mode 100644 index 0000000..5424b46 --- /dev/null +++ b/apps/backend/app/repositories/reading.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading + + +class ReadingRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def latest_by_site(self) -> Sequence[Reading]: + # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule + # ligne par site, la plus récente grâce à l'ordre composite qui suit. + requete = ( + select(Reading) + .distinct(Reading.site_id) + .order_by(Reading.site_id, Reading.timestamp.desc()) + ) + return (await self._session.execute(requete)).scalars().all() diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..cd36329 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -0,0 +1,15 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site + + +class SiteRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Site]: + requete = select(Site).order_by(Site.site_id) + return (await self._session.execute(requete)).scalars().all() diff --git a/apps/backend/app/schemas/stats.py b/apps/backend/app/schemas/stats.py new file mode 100644 index 0000000..b119d50 --- /dev/null +++ b/apps/backend/app/schemas/stats.py @@ -0,0 +1,26 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class SiteSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + current_consumption_kw: float | None + capacity_kw: float + load_percent: float | None + data_quality: Literal["good", "partial", "degraded", "critical"] + + +class StatsSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + total_sites: int + total_consumption_kw: float + total_capacity_kw: float + average_load_percent: float + sites: list[SiteSummaryResponse] diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py new file mode 100644 index 0000000..c98eace --- /dev/null +++ b/apps/backend/app/services/stats.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + + +@dataclass(frozen=True, slots=True) +class SiteConsumption: + site_id: str + site_name: str + current_consumption_kw: float | None + capacity_kw: float + load_percent: float | None + data_quality: DataQuality + + +@dataclass(frozen=True, slots=True) +class ConsumptionSummary: + timestamp: datetime + total_sites: int + total_consumption_kw: float + total_capacity_kw: float + average_load_percent: float + sites: list[SiteConsumption] + + +class StatsService: + def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None: + self._sites = sites + self._readings = readings + + async def summary(self) -> ConsumptionSummary: + sites = await self._sites.list_all() + dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()} + + resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites] + consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes) + capacite_totale = sum(r.capacity_kw for r in resumes) + + return ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=len(resumes), + total_consumption_kw=consommation_totale, + total_capacity_kw=capacite_totale, + average_load_percent=( + consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0 + ), + sites=resumes, + ) + + @staticmethod + def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption: + capacite = site.capacity_kw or 0 + # Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le + # producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas, + # `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas. + qualite: DataQuality = "critical" + consommation = None + if derniere is not None and derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] + consommation = derniere.consumption_kw + + charge = ( + consommation / capacite * 100 if consommation is not None and capacite > 0 else None + ) + + return SiteConsumption( + site_id=site.site_id, + site_name=site.site_name, + current_consumption_kw=consommation, + capacity_kw=capacite, + load_percent=charge, + data_quality=qualite, + ) diff --git a/apps/backend/tests/api/test_stats.py b/apps/backend/tests/api/test_stats.py new file mode 100644 index 0000000..8e4c439 --- /dev/null +++ b/apps/backend/tests/api/test_stats.py @@ -0,0 +1,73 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_stats_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.stats import ConsumptionSummary, SiteConsumption + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxService: + def __init__(self) -> None: + self.resume = ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=1, + total_consumption_kw=87.34, + total_capacity_kw=200, + average_load_percent=43.7, + sites=[ + SiteConsumption( + site_id="SITE001", + site_name="Bureau Paris La Défense", + current_consumption_kw=87.34, + capacity_kw=200, + load_percent=43.7, + data_quality="good", + ) + ], + ) + + async def summary(self) -> ConsumptionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_stats_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_stats_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_summary_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/stats/summary") + + assert response.status_code == 200 + corps = response.json() + assert corps["total_sites"] == 1 + assert corps["sites"][0]["site_id"] == "SITE001" + assert corps["sites"][0]["data_quality"] == "good" diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py new file mode 100644 index 0000000..650d49a --- /dev/null +++ b/apps/backend/tests/repositories/test_reading.py @@ -0,0 +1,72 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"SITE-{uuid.uuid4().hex[:8]}" + + +def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading: + return Reading( + site_id=site_id, + timestamp=timestamp, + source="api_current", + consumption_kw=consumption_kw, + data_quality="good", + raw_data={}, + ) + + +async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None: + site_id = identifiant() + maintenant = datetime.now(UTC) + session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100)) + await session.flush() + session.add_all( + [ + lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10), + lecture(site_id, timestamp=maintenant, consumption_kw=42), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + consommations = [r.consumption_kw for r in resultats if r.site_id == site_id] + await session.rollback() + + assert consommations == [42] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier, second = identifiant(), identifiant() + maintenant = datetime.now(UTC) + session.add_all( + [ + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100), + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200), + ] + ) + await session.flush() + session.add_all( + [ + lecture(premier, timestamp=maintenant, consumption_kw=10), + lecture(second, timestamp=maintenant, consumption_kw=20), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)} + await session.rollback() + + assert identifiants == {premier, second} diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..499e808 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -0,0 +1,31 @@ +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site +from app.repositories.site import SiteRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"SITE-{uuid.uuid4().hex[:8]}" + + +async def test_list_all_returns_every_site_sorted_by_id(session: AsyncSession) -> None: + premier, second = sorted([identifiant(), identifiant()]) + session.add_all( + [ + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=100), + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=50), + ] + ) + await session.flush() + depot = SiteRepository(session) + + sites = await depot.list_all() + identifiants = [site.site_id for site in sites if site.site_id in (premier, second)] + await session.rollback() + + assert identifiants == [premier, second] diff --git a/apps/backend/tests/services/test_stats.py b/apps/backend/tests/services/test_stats.py new file mode 100644 index 0000000..1962678 --- /dev/null +++ b/apps/backend/tests/services/test_stats.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +from app.services.stats import StatsService + + +@dataclass +class FauxSite: + site_id: str + site_name: str + capacity_kw: float | None + + +@dataclass +class FauxLecture: + site_id: str + consumption_kw: float | None + data_quality: str | None + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotLectures: + def __init__(self, lectures: list[FauxLecture]) -> None: + self._lectures = lectures + + async def latest_by_site(self) -> list[FauxLecture]: + return self._lectures + + +async def test_summary_computes_totals_and_the_average_load() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200), FauxSite("B", "Site B", 800)]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture("A", 100, "good"), + FauxLecture("B", 400, "good"), + ] + ), + ) + + resume = await service.summary() + + assert resume.total_sites == 2 + assert resume.total_consumption_kw == 500 + assert resume.total_capacity_kw == 1000 + assert resume.average_load_percent == 50 + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].load_percent == 50 + assert par_site["B"].load_percent == 50 + + +async def test_summary_treats_a_site_without_any_reading_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + assert site.load_percent is None + + +async def test_summary_treats_a_reading_with_an_unknown_quality_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, None)]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + + +async def test_summary_exposes_a_missing_capacity_as_zero_without_dividing_by_it() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, "good")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.capacity_kw == 0 + assert site.current_consumption_kw == 50 + assert site.load_percent is None + + +async def test_summary_returns_zero_average_load_when_no_site_has_a_capacity() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.average_load_percent == 0 diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..d361323 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users"] + ep["endpoints
health, auth, users, stats"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log"] - md["models
4 tables"] + sv["services
AuthService, UserService, StatsService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, reading"] + md["models
6 tables"] db[("PostgreSQL")] ep --> sc @@ -140,6 +140,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | | PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | | POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/api/v1/stats/summary` | oui | Résume la consommation instantanée du parc. `lecteur` | | GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | | GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | @@ -148,7 +149,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. -Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From ad149db0cb37397fcbc16e501cd9dc93e765d268 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:55:03 +0200 Subject: [PATCH 077/205] fix(backend): corrige une assertion tautologique dans test_list_all_returns_the_sites_sorted_by_identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'assertion comparait le résultat à lui-même trié, donc vraie quel que soit l'ordre réellement renvoyé par SiteRepository.list_all(). Compare désormais à des identifiants connus à l'avance. --- apps/backend/tests/repositories/test_site.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py index 222d398..a9864a6 100644 --- a/apps/backend/tests/repositories/test_site.py +++ b/apps/backend/tests/repositories/test_site.py @@ -48,11 +48,12 @@ async def test_get_by_id_returns_nothing_for_an_unknown_identifier( async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None: depot = SiteRepository(session) - await creer(session, site_id=f"zz-{identifiant()}") - await creer(session, site_id=f"aa-{identifiant()}") + premier, second = sorted([f"zz-{identifiant()}", f"aa-{identifiant()}"]) + await creer(session, site_id=second) + await creer(session, site_id=premier) sites = await depot.list_all() - identifiants = [site.site_id for site in sites] + identifiants = [site.site_id for site in sites if site.site_id in (premier, second)] await session.rollback() - assert identifiants == sorted(identifiants) + assert identifiants == [premier, second] From 04e4913952e981573b5c4c711d851bfeafb90149 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 12:20:15 +0200 Subject: [PATCH 078/205] fix(frontend): code smells --- .github/workflows/frontend.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 3bc627e..04a5208 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -37,7 +37,7 @@ jobs: cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci + - run: npm ci --ignore-scripts working-directory: apps/frontend - run: npm run build working-directory: apps/frontend @@ -52,7 +52,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci + - run: npm ci --ignore-scripts working-directory: apps/frontend - run: npm test -- --watch=false working-directory: apps/frontend From f43c9f76a0e8cc1d3b76e955e2705907bcbdaf47 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 12:29:54 +0200 Subject: [PATCH 079/205] test(frontend): workflow --- .github/workflows/frontend.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 04a5208..adb608d 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -37,7 +37,7 @@ jobs: cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci --ignore-scripts + - run: npm ci working-directory: apps/frontend - run: npm run build working-directory: apps/frontend @@ -52,8 +52,6 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci --ignore-scripts - working-directory: apps/frontend - run: npm test -- --watch=false working-directory: apps/frontend From 730adb69b188301c76bdf5b9384b4513a5e7bdf0 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 12:32:28 +0200 Subject: [PATCH 080/205] chore(frontend): faux positifs cwe --- .github/workflows/frontend.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index adb608d..98d5d53 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -52,6 +52,8 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json + - run: npm ci + working-directory: apps/frontend - run: npm test -- --watch=false working-directory: apps/frontend From e50921c90779da1fb4530fc4112be9fca24c1d64 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 13:06:37 +0200 Subject: [PATCH 081/205] feat(backend): expose GET /api/v1/alerts Consultation des alertes de consommation, filtrable par site_id et severity a l'identique du contrat GET /alerts de l'API Mock. Reprend le gabarit endpoints -> services -> repositories -> models pose par sites, sur la table alert deja creee par la revision Alembic e6d2026091501. Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela du seul tag users, pour que l'ajout d'alerts a la liste des routes protegees par role soit reellement verifie. Closes #59 --- apps/backend/app/api/deps.py | 9 + apps/backend/app/api/openapi.py | 5 + apps/backend/app/api/v1/endpoints/alerts.py | 23 ++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/repositories/alert.py | 21 ++ apps/backend/app/schemas/alert.py | 34 +++ apps/backend/app/services/alert.py | 14 ++ apps/backend/openapi.json | 214 ++++++++++++++++++ apps/backend/tests/api/test_alerts.py | 136 +++++++++++ apps/backend/tests/api/test_openapi.py | 16 +- apps/backend/tests/repositories/test_alert.py | 90 ++++++++ apps/backend/tests/services/test_alert.py | 55 +++++ docs/architecture/20-backend.md | 9 +- 13 files changed, 624 insertions(+), 7 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/alerts.py create mode 100644 apps/backend/app/repositories/alert.py create mode 100644 apps/backend/app/schemas/alert.py create mode 100644 apps/backend/app/services/alert.py create mode 100644 apps/backend/tests/api/test_alerts.py create mode 100644 apps/backend/tests/repositories/test_alert.py create mode 100644 apps/backend/tests/services/test_alert.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index f16d167..7415616 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -21,11 +21,13 @@ from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy from app.core.security import decode_access_token as decode_token from app.db.session import get_session +from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.site import SiteRepository from app.repositories.user import UserRepository +from app.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy from app.services.site import SiteService from app.services.user import UserService @@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService: SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] +def get_alert_service(session: SessionDep) -> AlertService: + return AlertService(alerts=AlertRepository(session)) + + +AlertServiceDep = Annotated[AlertService, Depends(get_alert_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 1eaa3a9..f090ce2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.", }, + { + "name": "alerts", + "description": "Consultation des alertes de consommation. Accessible à partir du rôle " + "`lecteur`.", + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/alerts.py b/apps/backend/app/api/v1/endpoints/alerts.py new file mode 100644 index 0000000..ac9f5ae --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/alerts.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from app.api.deps import AlertServiceDep, LecteurDep +from app.api.openapi import REPONSE_VALIDATION +from app.schemas.alert import AlertResponse, AlertSeverity + +router = APIRouter() + + +@router.get( + "", + response_model=list[AlertResponse], + summary="Liste les alertes", + responses=REPONSE_VALIDATION, +) +async def list_alerts( + _: LecteurDep, + service: AlertServiceDep, + site_id: str | None = None, + severity: AlertSeverity | None = None, +) -> list[AlertResponse]: + alertes = await service.list_all(site_id=site_id, severity=severity) + return [AlertResponse.model_validate(alerte) for alerte in alertes] diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index edb035b..c72873f 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,10 +1,13 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import auth, health, sites, users +from app.api.v1.endpoints import alerts, auth, health, sites, users api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN) api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR) +api_router.include_router( + alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR +) diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py new file mode 100644 index 0000000..4b0766f --- /dev/null +++ b/apps/backend/app/repositories/alert.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert + + +class AlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> Sequence[Alert]: + requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc()) + if site_id is not None: + requete = requete.where(Alert.site_id == site_id) + if severity is not None: + requete = requete.where(Alert.severity == severity) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/schemas/alert.py b/apps/backend/app/schemas/alert.py new file mode 100644 index 0000000..a041b07 --- /dev/null +++ b/apps/backend/app/schemas/alert.py @@ -0,0 +1,34 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class AlertType(StrEnum): + SPIKE = "spike" + THRESHOLD = "threshold" + ANOMALY = "anomaly" + OUTAGE = "outage" + SENSOR = "sensor" + + +class AlertSeverity(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class AlertResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + alert_id: int + site_id: str + timestamp: datetime + type: AlertType + severity: AlertSeverity + message: str + value: float | None + threshold: float | None + metric: str | None + prediction_id: int | None diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py new file mode 100644 index 0000000..a3ad16e --- /dev/null +++ b/apps/backend/app/services/alert.py @@ -0,0 +1,14 @@ +from collections.abc import Sequence + +from app.models.energy import Alert +from app.repositories.alert import AlertRepository + + +class AlertService: + def __init__(self, *, alerts: AlertRepository) -> None: + self._alerts = alerts + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> Sequence[Alert]: + return await self._alerts.list_all(site_id=site_id, severity=severity) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 3e8dc01..c7009e6 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -920,6 +920,110 @@ } } } + }, + "/api/v1/alerts": { + "get": { + "tags": [ + "alerts" + ], + "summary": "Liste les alertes", + "operationId": "list_alerts_api_v1_alerts_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + }, + { + "name": "severity", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AlertSeverity" + }, + { + "type": "null" + } + ], + "title": "Severity" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlertResponse" + }, + "title": "Response List Alerts Api V1 Alerts 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": "Mot de passe provisoire à changer (`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": { @@ -932,6 +1036,112 @@ ], "title": "AccountKind" }, + "AlertResponse": { + "properties": { + "alert_id": { + "type": "integer", + "title": "Alert Id" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "type": { + "$ref": "#/components/schemas/AlertType" + }, + "severity": { + "$ref": "#/components/schemas/AlertSeverity" + }, + "message": { + "type": "string", + "title": "Message" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Value" + }, + "threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Threshold" + }, + "metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metric" + }, + "prediction_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Prediction Id" + } + }, + "type": "object", + "required": [ + "alert_id", + "site_id", + "timestamp", + "type", + "severity", + "message", + "value", + "threshold", + "metric", + "prediction_id" + ], + "title": "AlertResponse" + }, + "AlertSeverity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "title": "AlertSeverity" + }, + "AlertType": { + "type": "string", + "enum": [ + "spike", + "threshold", + "anomaly", + "outage", + "sensor" + ], + "title": "AlertType" + }, "ErrorResponse": { "properties": { "detail": { @@ -1395,6 +1605,10 @@ { "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`." + }, + { + "name": "alerts", + "description": "Consultation des alertes de consommation. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_alerts.py b/apps/backend/tests/api/test_alerts.py new file mode 100644 index 0000000..840d6c9 --- /dev/null +++ b/apps/backend/tests/api/test_alerts.py @@ -0,0 +1,136 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_alert_service, get_current_principal +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Alert + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity=severity, + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +class FauxService: + def __init__(self) -> None: + self.alert = alert() + self.appels: list[tuple[str | None, str | None]] = [] + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return [self.alert] + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_alert_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_alert_service, None) + + +async def test_list_alerts_returns_the_alerts( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/alerts") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "alert_id": 1, + "site_id": "site-1", + "timestamp": "2026-09-16T00:00:00Z", + "type": "threshold", + "severity": "high", + "message": "Dépassement du seuil configuré", + "value": 812.5, + "threshold": 720.0, + "metric": "consumption_kw", + "prediction_id": None, + } + ] + + +async def test_list_alerts_transmits_the_site_id_filter( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi() + + await client.get("/api/v1/alerts?site_id=site-1") + + assert service.appels == [("site-1", None)] + + +async def test_list_alerts_transmits_the_severity_filter( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi() + + await client.get("/api/v1/alerts?severity=critical") + + assert service.appels == [(None, "critical")] + + +async def test_list_alerts_returns_422_for_an_unknown_severity( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/alerts?severity=invalide") + + assert response.status_code == 422 + + +async def test_list_alerts_returns_an_empty_list_when_there_is_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[]) + + response = await client.get("/api/v1/alerts") + + assert response.status_code == 200 + assert response.json() == [] diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 96297c0..05b5dfe 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -22,6 +22,18 @@ ORIGINE_VERIFIEE = { ("POST", "/api/v1/auth/password"), } +# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour +# `password_change_required`, pas seulement les routes `admin`. +ROUTES_A_ROLE = { + ("GET", "/api/v1/users"), + ("POST", "/api/v1/users"), + ("PATCH", "/api/v1/users/{id}"), + ("POST", "/api/v1/users/{id}/password-reset"), + ("GET", "/api/v1/sites"), + ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/alerts"), +} + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: @@ -55,11 +67,11 @@ def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, assert muettes == [] -def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: +def test_every_role_guarded_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: sans_403 = [ (methode, chemin) for methode, chemin, operation in operations(schema) - if "users" in operation.get("tags", []) and "403" not in operation["responses"] + if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"] ] assert sans_403 == [] diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py new file mode 100644 index 0000000..45ab41a --- /dev/null +++ b/apps/backend/tests/repositories/test_alert.py @@ -0,0 +1,90 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert +from app.repositories.alert import AlertRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +pytestmark = pytest.mark.integration + + +async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert: + alerte = Alert( + source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"), + site_id=site_id, + source=overrides.get("source", "enervision"), + timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)), + type=overrides.get("type", "threshold"), + severity=overrides.get("severity", "high"), + message=overrides.get("message", "Dépassement du seuil configuré"), + value=overrides.get("value", 812.5), + threshold=overrides.get("threshold", 720.0), + metric=overrides.get("metric", "consumption_kw"), + prediction_id=overrides.get("prediction_id"), + raw_data=overrides.get("raw_data", {}), + ) + session.add(alerte) + await session.flush() + return alerte + + +async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + ancienne = await creer_alerte( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_alerte( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + alertes = await depot.list_all() + identifiants = [ + a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id) + ] + await session.rollback() + + assert identifiants == [recente.alert_id, ancienne.alert_id] + + +async def test_list_all_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = AlertRepository(session) + voulue = await creer_alerte(session, site_id=premier.site_id) + await creer_alerte(session, site_id=second.site_id) + + alertes = await depot.list_all(site_id=premier.site_id) + identifiants = [a.alert_id for a in alertes] + await session.rollback() + + assert identifiants == [voulue.alert_id] + + +async def test_list_all_filters_by_severity(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + voulue = await creer_alerte(session, site_id=site.site_id, severity="critical") + await creer_alerte(session, site_id=site.site_id, severity="low") + + alertes = await depot.list_all(severity="critical") + identifiants = [a.alert_id for a in alertes] + await session.rollback() + + assert identifiants == [voulue.alert_id] + + +async def test_list_all_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = AlertRepository(session) + + alertes = await depot.list_all(site_id=identifiant_site()) + + assert list(alertes) == [] diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py new file mode 100644 index 0000000..4a88802 --- /dev/null +++ b/apps/backend/tests/services/test_alert.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime + +from app.models.energy import Alert +from app.services.alert import AlertService + + +def alert( + alert_id: int = 1, + site_id: str = "site-1", + severity: str = "high", +) -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity=severity, + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +class FakeRepository: + def __init__(self, alerts: list[Alert]) -> None: + self._alerts = alerts + self.appels: list[tuple[str | None, str | None]] = [] + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return self._alerts + + +async def test_list_all_returns_the_repository_alerts() -> None: + service = AlertService(alerts=FakeRepository([alert(1), alert(2)])) + + alertes = await service.list_all() + + assert [a.alert_id for a in alertes] == [1, 2] + + +async def test_list_all_relays_the_filters_to_the_repository() -> None: + depot = FakeRepository([]) + service = AlertService(alerts=depot) + + await service.list_all(site_id="site-1", severity="critical") + + assert depot.appels == [("site-1", "critical")] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index f48178f..e814ecd 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 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` | | @@ -153,10 +154,10 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. -`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser -pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre -couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables -déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, +`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour +`GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les +quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des +tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par `execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base From 1654e4dd811139e015dfd2defdc94745fcc4acc0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 13:27:41 +0200 Subject: [PATCH 082/205] docs(backend): documente la checklist d'ajout d'une route metier La generalisation de ROUTES_A_ROLE (commit precedent) avait deja ete approuvee sur feat/openapi-contrat mais poussee apres la fermeture de la PR #76 : elle n'a donc jamais atteint dev, et sa documentation non plus. Complete ce qui manquait pour que le passage a l'echelle du contrat OpenAPI soit reellement utilisable par la prochaine route. --- docs/architecture/20-backend.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index e814ecd..5347e3e 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -236,6 +236,21 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv `loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et `type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence. +### Ajouter une route métier + +Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts` (`reading`, `dataset`, +`prediction`, `recommendation`) : + +1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` + au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux + (404, 409, ...) directement sur l'endpoint qui les rend. +2. Décrire son tag dans `TAGS`. +3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à + `ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, + l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : + une route oubliée n'y est pas détectée automatiquement. +4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. + ## Sécurité Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les From 781644b28ea5916730c76d969249c29635fc84a2 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 13:30:51 +0200 Subject: [PATCH 083/205] feat(backend): ajoute GET /recommendations et GET /recommendations/{recommendation_id} --- apps/backend/README.md | 2 + apps/backend/app/api/deps.py | 9 + apps/backend/app/api/openapi.py | 7 + .../app/api/v1/endpoints/recommendations.py | 40 ++++ apps/backend/app/api/v1/router.py | 8 +- .../app/repositories/recommendation.py | 22 ++ apps/backend/app/schemas/recommendation.py | 14 ++ apps/backend/app/services/recommendation.py | 26 +++ apps/backend/openapi.json | 190 ++++++++++++++++++ .../backend/tests/api/test_recommendations.py | 144 +++++++++++++ .../tests/repositories/test_recommendation.py | 85 ++++++++ .../tests/services/test_recommendation.py | 55 +++++ docs/architecture/00-vue-ensemble.md | 2 +- docs/architecture/20-backend.md | 7 +- docs/architecture/owasp-traceabilite.md | 6 +- 15 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/recommendations.py create mode 100644 apps/backend/app/repositories/recommendation.py create mode 100644 apps/backend/app/schemas/recommendation.py create mode 100644 apps/backend/app/services/recommendation.py create mode 100644 apps/backend/tests/api/test_recommendations.py create mode 100644 apps/backend/tests/repositories/test_recommendation.py create mode 100644 apps/backend/tests/services/test_recommendation.py diff --git a/apps/backend/README.md b/apps/backend/README.md index 875d8ca..91f9608 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -109,6 +109,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | | `/api/v1/sites` | Liste les sites | `lecteur` | | `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | +| `/api/v1/recommendations` | Liste les recommandations | `lecteur` | +| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index f16d167..29eb394 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,10 +23,12 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.recommendation import RecommendationService from app.services.site import SiteService from app.services.user import UserService @@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService: SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] +def get_recommendation_service(session: SessionDep) -> RecommendationService: + return RecommendationService(recommendations=RecommendationRepository(session)) + + +RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_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 1eaa3a9..05907a2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -54,6 +54,13 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.", }, + { + "name": "recommendations", + "description": ( + "Consultation des recommandations issues des alertes. Accessible à partir du rôle " + "`lecteur`." + ), + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py new file mode 100644 index 0000000..87e8be1 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.schemas.errors import ErrorResponse +from app.schemas.recommendation import RecommendationResponse +from app.services.recommendation import RecommendationNotFoundError + +router = APIRouter() + +REPONSES_INTROUVABLE: Reponses = { + **REPONSE_VALIDATION, + 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, +} + + +@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations") +async def list_recommendations( + _: LecteurDep, service: RecommendationServiceDep +) -> list[RecommendationResponse]: + recommendations = await service.list_all() + return [RecommendationResponse.model_validate(r) for r in recommendations] + + +@router.get( + "/{recommendation_id}", + response_model=RecommendationResponse, + summary="Décrit une recommandation", + responses=REPONSES_INTROUVABLE, +) +async def get_recommendation( + recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep +) -> RecommendationResponse: + try: + recommendation = await service.get_by_id(recommendation_id) + except RecommendationNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable" + ) from erreur + return RecommendationResponse.model_validate(recommendation) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index edb035b..05db75f 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.v1.endpoints import auth, health, sites, users +from app.api.v1.endpoints import auth, health, recommendations, sites, users api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN) api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR) +api_router.include_router( + recommendations.router, + prefix="/recommendations", + tags=["recommendations"], + responses=REPONSES_LECTEUR, +) diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py new file mode 100644 index 0000000..7870131 --- /dev/null +++ b/apps/backend/app/repositories/recommendation.py @@ -0,0 +1,22 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Recommendation + + +class RecommendationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Recommendation]: + requete = select(Recommendation).order_by(Recommendation.recommendation_id) + return (await self._session.scalars(requete)).all() + + async def get_by_id(self, recommendation_id: int) -> Recommendation | None: + requete = select(Recommendation).where( + Recommendation.recommendation_id == recommendation_id + ) + recommendation: Recommendation | None = await self._session.scalar(requete) + return recommendation diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py new file mode 100644 index 0000000..8764615 --- /dev/null +++ b/apps/backend/app/schemas/recommendation.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class RecommendationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + recommendation_id: int + alert_id: int + action: str + explanation: str + rule_reference: str + created_at: datetime diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py new file mode 100644 index 0000000..31115ae --- /dev/null +++ b/apps/backend/app/services/recommendation.py @@ -0,0 +1,26 @@ +from collections.abc import Sequence + +from app.models.energy import Recommendation +from app.repositories.recommendation import RecommendationRepository + + +class RecommendationError(Exception): + pass + + +class RecommendationNotFoundError(RecommendationError): + pass + + +class RecommendationService: + def __init__(self, *, recommendations: RecommendationRepository) -> None: + self._recommendations = recommendations + + async def list_all(self) -> Sequence[Recommendation]: + return await self._recommendations.list_all() + + async def get_by_id(self, recommendation_id: int) -> Recommendation: + recommendation = await self._recommendations.get_by_id(recommendation_id) + if recommendation is None: + raise RecommendationNotFoundError(recommendation_id) + return recommendation diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 3e8dc01..507a698 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -920,6 +920,153 @@ } } } + }, + "/api/v1/recommendations": { + "get": { + "tags": [ + "recommendations" + ], + "summary": "Liste les recommandations", + "operationId": "list_recommendations_api_v1_recommendations_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RecommendationResponse" + }, + "type": "array", + "title": "Response List Recommendations Api V1 Recommendations 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": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/recommendations/{recommendation_id}": { + "get": { + "tags": [ + "recommendations" + ], + "summary": "Décrit une recommandation", + "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "recommendation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recommendation Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "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" + } + } + } + }, + "404": { + "description": "Aucune recommandation ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -1111,6 +1258,45 @@ ], "title": "ReadinessStatus" }, + "RecommendationResponse": { + "properties": { + "recommendation_id": { + "type": "integer", + "title": "Recommendation Id" + }, + "alert_id": { + "type": "integer", + "title": "Alert Id" + }, + "action": { + "type": "string", + "title": "Action" + }, + "explanation": { + "type": "string", + "title": "Explanation" + }, + "rule_reference": { + "type": "string", + "title": "Rule Reference" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "recommendation_id", + "alert_id", + "action", + "explanation", + "rule_reference", + "created_at" + ], + "title": "RecommendationResponse" + }, "Role": { "type": "string", "enum": [ @@ -1395,6 +1581,10 @@ { "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`." + }, + { + "name": "recommendations", + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py new file mode 100644 index 0000000..d01db09 --- /dev/null +++ b/apps/backend/tests/api/test_recommendations.py @@ -0,0 +1,144 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_recommendation_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Recommendation +from app.services.recommendation import RecommendationNotFoundError + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def recommendation(recommendation_id: int = 1) -> Recommendation: + return Recommendation( + recommendation_id=recommendation_id, + alert_id=1, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + created_at=MOMENT, + ) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.recommendation = recommendation() + + async def list_all(self) -> list[Recommendation]: + return [self.recommendation] + + async def get_by_id(self, recommendation_id: int) -> Recommendation: + if self._erreur is not None: + raise self._erreur + return self.recommendation + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Exception | None], FauxService]]: + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + app.dependency_overrides[get_recommendation_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_recommendation_service, None) + + +async def test_list_recommendations_returns_the_recommendations( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/recommendations") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "recommendation_id": 1, + "alert_id": 1, + "action": "Vérifier la consommation", + "explanation": "Pic détecté", + "rule_reference": "spike-v1", + "created_at": "2024-01-01T00:00:00Z", + } + ] + + +async def test_get_recommendation_returns_the_matching_recommendation( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/recommendations/1") + + assert response.status_code == 200 + assert response.json()["recommendation_id"] == 1 + + +async def test_get_recommendation_returns_404_for_an_unknown_recommendation( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(RecommendationNotFoundError(404)) + + response = await client.get("/api/v1/recommendations/404") + + assert response.status_code == 404 + + +async def test_list_recommendations_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[recommendation(1), recommendation(2)]) + + response = await client.get("/api/v1/recommendations") + + assert response.status_code == 200 + assert [r["recommendation_id"] for r in response.json()] == [1, 2] + + +async def test_get_recommendation_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=recommendation(1)) + + response = await client.get("/api/v1/recommendations/1") + + assert response.status_code == 200 + assert response.json()["recommendation_id"] == 1 + + +async def test_get_recommendation_returns_404_when_the_session_finds_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/recommendations/404") + + assert response.status_code == 404 diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py new file mode 100644 index 0000000..075c9eb --- /dev/null +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -0,0 +1,85 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert, Recommendation, Site +from app.repositories.recommendation import RecommendationRepository + +pytestmark = pytest.mark.integration + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +async def creer_site(session: AsyncSession) -> str: + site_id = f"TEST-{uuid.uuid4()}" + session.add(Site(site_id=site_id, site_name="Site de test", site_type="office")) + await session.flush() + return site_id + + +async def creer_alerte(session: AsyncSession) -> int: + site_id = await creer_site(session) + alerte = Alert( + source_alert_id=str(uuid.uuid4()), + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + session.add(alerte) + await session.flush() + return alerte.alert_id + + +async def creer(session: AsyncSession, **overrides: object) -> Recommendation: + recommendation = Recommendation( + alert_id=overrides.get("alert_id") or await creer_alerte(session), + action=overrides.get("action", "Vérifier la consommation"), + explanation=overrides.get("explanation", "Pic détecté"), + rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"), + ) + session.add(recommendation) + await session.flush() + return recommendation + + +async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + cree = await creer(session) + + trouve = await depot.get_by_id(cree.recommendation_id) + action = trouve.action if trouve else None + await session.rollback() + + assert action == "Vérifier la consommation" + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await RecommendationRepository(session).get_by_id(0) + + assert trouve is None + + +async def test_list_all_returns_the_recommendations_sorted_by_identifier( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + premiere = await creer(session) + seconde = await creer(session) + + recommendations = await depot.list_all() + identifiants = [ + r.recommendation_id + for r in recommendations + if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id) + ] + await session.rollback() + + assert identifiants == sorted(identifiants) diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py new file mode 100644 index 0000000..e8ed2b2 --- /dev/null +++ b/apps/backend/tests/services/test_recommendation.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Recommendation +from app.services.recommendation import RecommendationNotFoundError, RecommendationService + + +def recommendation(recommendation_id: int = 1) -> Recommendation: + return Recommendation( + recommendation_id=recommendation_id, + alert_id=1, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +class FakeRepository: + def __init__(self, recommendations: list[Recommendation]) -> None: + self._recommendations = recommendations + + async def list_all(self) -> list[Recommendation]: + return self._recommendations + + async def get_by_id(self, recommendation_id: int) -> Recommendation | None: + return next( + (r for r in self._recommendations if r.recommendation_id == recommendation_id), None + ) + + +async def test_list_all_returns_the_repository_recommendations() -> None: + service = RecommendationService( + recommendations=FakeRepository([recommendation(1), recommendation(2)]) + ) + + recommendations = await service.list_all() + + assert [r.recommendation_id for r in recommendations] == [1, 2] + + +async def test_get_by_id_returns_the_matching_recommendation() -> None: + service = RecommendationService(recommendations=FakeRepository([recommendation(1)])) + + trouve = await service.get_by_id(1) + + assert trouve.recommendation_id == 1 + + +async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None: + service = RecommendationService(recommendations=FakeRepository([])) + + with pytest.raises(RecommendationNotFoundError): + await service.get_by_id(404) diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 96fb992..b49a70f 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,7 +74,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| -| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites` et `recommendations` en lecture (endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index f48178f..9006e3a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | +| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | | 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` | | @@ -160,7 +162,10 @@ déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par `execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base -réelle. Le contrat détaillé pour le frontend est dans +réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même +gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne +porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore +exposée. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index ac4a8af..15c2b51 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -9,8 +9,8 @@ Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, et une section qui dit ce qui n'est pas couvert et pourquoi. Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et -`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes -resteront à compléter une fois les endpoints d'écriture posés. +`GET /recommendations`, chacune avec sa route de détail, sont les premiers endpoints métier, en +lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints d'écriture posés. ## Contrôles en place @@ -49,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | Item | État | Raison | |---|---|---| -| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | +| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | | **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | | **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | From 970a4a50b8bf29f8cf5ea38a8073487150e8656a Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 14:22:50 +0200 Subject: [PATCH 084/205] =?UTF-8?q?fix(frontend):=20suppression=20de=20d?= =?UTF-8?q?=C3=A9pendance=20dans=20le=20service=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1a3983c..3d0ea63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,17 +43,11 @@ services: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped - frontend: - build: ./apps/frontend - # si backend fonctionnel - depends_on: - backend: - condition: service_healthy - environment: - - ports: - - "${FRONTEND_PORT:-3000}:80" - restart: unless-stopped + frontend: + build: ./apps/frontend + ports: + - "${FRONTEND_PORT:-3000}:80" + restart: unless-stopped volumes: From 6ecec1afefa68acb0a982c26ef421295eb6fe4cf Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 14:40:55 +0200 Subject: [PATCH 085/205] chore(frontend): ajout du dockerignore et du dockerfile --- .dockerignore | 27 ++++++++++++++++++++++ apps/frontend/Dockerfile | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 .dockerignore create mode 100644 apps/frontend/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..16abb4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Dépendances (réinstallées dans l'image) +node_modules/ +vendor/ +__pycache__/ +*.pyc + +# Git et IDE +.git/ +.gitignore +.vscode/ +.idea/ +*.swp + +# Fichiers de build locaux +dist/ +build/ +*.log + +# Secrets et config locale (CRITIQUE : risque d'exfiltration) +.env +.env.local +*.pem +*.key +secrets/ +.npmrc +.pypirc +kubeconfig \ No newline at end of file diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000..b6029dc --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,50 @@ +# ================== +# Étape 1 : Build +# ================== + +# Image pour frontend +FROM dhi.io/node:24-alpine3.22 AS builder + +WORKDIR /app + +# Installation des dépendances du projet avec npm +RUN npm ci + +COPY package.json package-lock.json* ./ + + + + +# Copie du code source vers le conteneur +COPY . . + +# Build +RUN npm run build + +# ================== +# Étape 2 : Runner +# ================== + + +FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner + +# Copie de la configuration de nginx +COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf + +# Copy the static build output from the build stage to Nginx's default HTML serving directory +COPY --chown=nginx:nginx --from=builder /app/dist/*/browser /usr/share/nginx/html + +# Create necessary directories with proper permissions for nginx +RUN mkdir -p /var/log/nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html + +# Use a non-root user for security best practices +USER nginx + +# Frontend : port 3000 +# Backend : port 8000 +EXPOSE 3000 + +# Start Nginx directly with custom config +ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"] +CMD ["-g", "daemon off;"] \ No newline at end of file From 76fa90dfcbf6836fa43abb7c1f9801c0ea3db5fb Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 14:43:27 +0200 Subject: [PATCH 086/205] test(backend): exerce AlertSeverity comme enum plutot qu'une chaine dans les tests alerts Le filtre severity passait par une chaine brute dans les tests, sans jamais exercer le trajet reel AlertSeverity (enum) -> SQLAlchemy -> PostgreSQL. --- apps/backend/tests/api/test_alerts.py | 3 ++- apps/backend/tests/repositories/test_alert.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/tests/api/test_alerts.py b/apps/backend/tests/api/test_alerts.py index 840d6c9..fc5f110 100644 --- a/apps/backend/tests/api/test_alerts.py +++ b/apps/backend/tests/api/test_alerts.py @@ -10,6 +10,7 @@ from app.api.deps import get_alert_service, get_current_principal from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Alert +from app.schemas.alert import AlertSeverity def principal(role: Role = Role.LECTEUR) -> Principal: @@ -112,7 +113,7 @@ async def test_list_alerts_transmits_the_severity_filter( await client.get("/api/v1/alerts?severity=critical") - assert service.appels == [(None, "critical")] + assert service.appels == [(None, AlertSeverity.CRITICAL)] async def test_list_alerts_returns_422_for_an_unknown_severity( diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py index 45ab41a..d2a78d0 100644 --- a/apps/backend/tests/repositories/test_alert.py +++ b/apps/backend/tests/repositories/test_alert.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert from app.repositories.alert import AlertRepository +from app.schemas.alert import AlertSeverity from tests.repositories.test_site import creer as creer_site from tests.repositories.test_site import identifiant as identifiant_site @@ -73,7 +74,7 @@ async def test_list_all_filters_by_severity(session: AsyncSession) -> None: voulue = await creer_alerte(session, site_id=site.site_id, severity="critical") await creer_alerte(session, site_id=site.site_id, severity="low") - alertes = await depot.list_all(severity="critical") + alertes = await depot.list_all(severity=AlertSeverity.CRITICAL) identifiants = [a.alert_id for a in alertes] await session.rollback() From 77440281f8910c52045c8ef0517c0135bda11630 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 14:53:54 +0200 Subject: [PATCH 087/205] feat(backend): expose GET /api/v1/sensors/status pour l'issue #32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dérive l'état de santé de 5 capteurs par site et un statut overall depuis la dernière lecture (data_quality, null_reasons, nullité des colonnes), sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin. --- apps/backend/app/api/deps.py | 8 + apps/backend/app/api/openapi.py | 4 + apps/backend/app/api/v1/endpoints/sensors.py | 16 ++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/schemas/sensor.py | 42 ++++ apps/backend/app/services/sensor.py | 137 +++++++++++++ apps/backend/openapi.json | 173 ++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sensors.py | 91 +++++++++ apps/backend/tests/services/test_sensor.py | 197 +++++++++++++++++++ docs/architecture/20-backend.md | 18 +- 11 files changed, 683 insertions(+), 9 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/sensors.py create mode 100644 apps/backend/app/schemas/sensor.py create mode 100644 apps/backend/app/services/sensor.py create mode 100644 apps/backend/tests/api/test_sensors.py create mode 100644 apps/backend/tests/services/test_sensor.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..5b39098 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -32,6 +32,7 @@ from app.repositories.user import UserRepository from app.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy from app.services.recommendation import RecommendationService +from app.services.sensor import SensorService from app.services.site import SiteService from app.services.stats import StatsService from app.services.user import UserService @@ -167,6 +168,13 @@ def get_stats_service(session: SessionDep) -> StatsService: StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] +def get_sensor_service(session: SessionDep) -> SensorService: + return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +SensorServiceDep = Annotated[SensorService, Depends(get_sensor_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 6eb02a2..85b7775 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -71,6 +71,10 @@ TAGS: Final[list[dict[str, Any]]] = [ "description": "Statistiques agrégées de consommation. Accessible à partir du rôle " "`lecteur`.", }, + { + "name": "sensors", + "description": "État de santé des capteurs par site. Réservé au rôle `admin`.", + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/sensors.py b/apps/backend/app/api/v1/endpoints/sensors.py new file mode 100644 index 0000000..40cb409 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/sensors.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.deps import AdminDep, SensorServiceDep +from app.schemas.sensor import SensorStatusResponse + +router = APIRouter() + + +@router.get( + "/status", + response_model=SensorStatusResponse, + summary="État de santé des capteurs par site", +) +async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse: + etat = await service.status() + return SensorStatusResponse.model_validate(etat) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 60171df..f5075ca 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import alerts, auth, health, recommendations, sites, stats, users +from app.api.v1.endpoints import alerts, auth, health, recommendations, sensors, sites, stats, users api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) @@ -18,3 +18,6 @@ api_router.include_router( responses=REPONSES_LECTEUR, ) api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR) +api_router.include_router( + sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN +) diff --git a/apps/backend/app/schemas/sensor.py b/apps/backend/app/schemas/sensor.py new file mode 100644 index 0000000..6a36a83 --- /dev/null +++ b/apps/backend/app/schemas/sensor.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SensorDiagnosticResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + status: Literal["ok", "failing"] + since: datetime | None = Field( + description=( + "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la " + "panne : l'historique ne permet pas de le dater sans requête supplémentaire." + ) + ) + + +class SiteSensorsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + consumption: SensorDiagnosticResponse + electrical: SensorDiagnosticResponse + temperature: SensorDiagnosticResponse + humidity: SensorDiagnosticResponse + network: SensorDiagnosticResponse + + +class SiteSensorStatusResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + sensors: SiteSensorsResponse + overall: Literal["ok", "degraded", "critical"] + + +class SensorStatusResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + sites: list[SiteSensorStatusResponse] diff --git a/apps/backend/app/services/sensor.py b/apps/backend/app/services/sensor.py new file mode 100644 index 0000000..1d707e0 --- /dev/null +++ b/apps/backend/app/services/sensor.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository + +CapteurStatus = Literal["ok", "failing"] +OverallStatus = Literal["ok", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + +RAISON_VERS_CAPTEUR: dict[str, str] = { + "consumption_sensor_failure": "consumption", + "electrical_sensor_failure": "electrical", + "temperature_sensor_failure": "temperature", + "humidity_sensor_failure": "humidity", + "network_loss": "network", +} + +CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = { + "consumption": ("consumption_kw",), + "electrical": ("voltage_v", "current_a", "power_factor"), + "temperature": ("temperature_celsius",), + "humidity": ("humidity_percent",), +} + + +@dataclass(frozen=True, slots=True) +class DiagnosticCapteur: + status: CapteurStatus + since: datetime | None + + +@dataclass(frozen=True, slots=True) +class SanteCapteurs: + consumption: DiagnosticCapteur + electrical: DiagnosticCapteur + temperature: DiagnosticCapteur + humidity: DiagnosticCapteur + network: DiagnosticCapteur + + +@dataclass(frozen=True, slots=True) +class SanteSite: + site_id: str + site_name: str + sensors: SanteCapteurs + overall: OverallStatus + + +@dataclass(frozen=True, slots=True) +class EtatCapteurs: + timestamp: datetime + sites: list[SanteSite] + + +class SensorService: + def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None: + self._sites = sites + self._readings = readings + + async def status(self) -> EtatCapteurs: + sites = await self._sites.list_all() + dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()} + + return EtatCapteurs( + timestamp=datetime.now(UTC), + sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites], + ) + + +def _sante_site(site: Site, derniere: Reading | None) -> SanteSite: + if derniere is None: + return SanteSite( + site_id=site.site_id, + site_name=site.site_name, + sensors=_tout_en_echec(since=None), + overall="critical", + ) + + qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + overall = _overall_depuis_qualite(qualite) + + if overall == "critical": + return SanteSite( + site_id=site.site_id, + site_name=site.site_name, + sensors=_tout_en_echec(since=derniere.timestamp), + overall="critical", + ) + + raisons_signalees = { + RAISON_VERS_CAPTEUR[raison] + for raison in (derniere.null_reasons or []) + if raison in RAISON_VERS_CAPTEUR + } + + return SanteSite( + site_id=site.site_id, + site_name=site.site_name, + sensors=SanteCapteurs( + consumption=_diagnostic("consumption", derniere, raisons_signalees), + electrical=_diagnostic("electrical", derniere, raisons_signalees), + temperature=_diagnostic("temperature", derniere, raisons_signalees), + humidity=_diagnostic("humidity", derniere, raisons_signalees), + network=_diagnostic("network", derniere, raisons_signalees), + ), + overall=overall, + ) + + +def _overall_depuis_qualite(qualite: str) -> OverallStatus: + if qualite == "good": + return "ok" + if qualite in ("partial", "degraded"): + return "degraded" + return "critical" + + +def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur: + champs = CHAMPS_PAR_CAPTEUR.get(capteur, ()) + en_echec = capteur in raisons_signalees or any( + getattr(derniere, champ) is None for champ in champs + ) + return DiagnosticCapteur( + status="failing" if en_echec else "ok", + since=derniere.timestamp if en_echec else None, + ) + + +def _tout_en_echec(since: datetime | None) -> SanteCapteurs: + echec = DiagnosticCapteur(status="failing", since=since) + return SanteCapteurs( + consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..84f9c08 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1227,6 +1227,62 @@ } ] } + }, + "/api/v1/sensors/status": { + "get": { + "tags": [ + "sensors" + ], + "summary": "État de santé des capteurs par site", + "operationId": "get_status_api_v1_sensors_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorStatusResponse" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } } }, "components": { @@ -1572,6 +1628,59 @@ ], "title": "Role" }, + "SensorDiagnosticResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "failing" + ], + "title": "Status" + }, + "since": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Since", + "description": "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la panne : l'historique ne permet pas de le dater sans requête supplémentaire." + } + }, + "type": "object", + "required": [ + "status", + "since" + ], + "title": "SensorDiagnosticResponse" + }, + "SensorStatusResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SiteSensorStatusResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "sites" + ], + "title": "SensorStatusResponse" + }, "SiteResponse": { "properties": { "site_id": { @@ -1631,6 +1740,66 @@ ], "title": "SiteResponse" }, + "SiteSensorStatusResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "sensors": { + "$ref": "#/components/schemas/SiteSensorsResponse" + }, + "overall": { + "type": "string", + "enum": [ + "ok", + "degraded", + "critical" + ], + "title": "Overall" + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "sensors", + "overall" + ], + "title": "SiteSensorStatusResponse" + }, + "SiteSensorsResponse": { + "properties": { + "consumption": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "electrical": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "temperature": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "humidity": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "network": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + } + }, + "type": "object", + "required": [ + "consumption", + "electrical", + "temperature", + "humidity", + "network" + ], + "title": "SiteSensorsResponse" + }, "SiteSummaryResponse": { "properties": { "site_id": { @@ -1959,6 +2128,10 @@ { "name": "stats", "description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`." + }, + { + "name": "sensors", + "description": "État de santé des capteurs par site. Réservé au rôle `admin`." } ] } diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..8b600bf 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -35,6 +35,7 @@ ROUTES_A_ROLE = { ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), ("GET", "/api/v1/stats/summary"), + ("GET", "/api/v1/sensors/status"), } diff --git a/apps/backend/tests/api/test_sensors.py b/apps/backend/tests/api/test_sensors.py new file mode 100644 index 0000000..e91ab64 --- /dev/null +++ b/apps/backend/tests/api/test_sensors.py @@ -0,0 +1,91 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_sensor_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +def principal(role: Role = Role.ADMIN) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxService: + def __init__(self) -> None: + ok = DiagnosticCapteur(status="ok", since=None) + en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP) + self.etat = EtatCapteurs( + timestamp=TIMESTAMP, + sites=[ + SanteSite( + site_id="SITE001", + site_name="Bureau Paris La Défense", + sensors=SanteCapteurs( + consumption=ok, + electrical=ok, + temperature=en_echec, + humidity=ok, + network=ok, + ), + overall="degraded", + ) + ], + ) + + async def status(self) -> EtatCapteurs: + return self.etat + + +@pytest.fixture +def admin_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_sensor_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_sensor_service, None) + + +async def test_get_status_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sensors/status") + + assert response.status_code == 200 + corps = response.json() + assert corps["sites"][0]["site_id"] == "SITE001" + assert corps["sites"][0]["overall"] == "degraded" + assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing" + assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok" + + +async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR) + + response = await client.get("/api/v1/sensors/status") + + assert response.status_code == 403 diff --git a/apps/backend/tests/services/test_sensor.py b/apps/backend/tests/services/test_sensor.py new file mode 100644 index 0000000..9a8d62f --- /dev/null +++ b/apps/backend/tests/services/test_sensor.py @@ -0,0 +1,197 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from app.services.sensor import SensorService + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +@dataclass +class FauxSite: + site_id: str + site_name: str + + +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime + data_quality: str | None + null_reasons: list[str] | None = field(default_factory=list) + consumption_kw: float | None = 10.0 + voltage_v: float | None = 230.0 + current_a: float | None = 5.0 + power_factor: float | None = 0.95 + temperature_celsius: float | None = 21.0 + humidity_percent: float | None = 40.0 + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotLectures: + def __init__(self, lectures: list[FauxLecture]) -> None: + self._lectures = lectures + + async def latest_by_site(self) -> list[FauxLecture]: + return self._lectures + + +async def test_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> ( + None +): + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "critical" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "failing" + assert capteur.since is None + + +async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type] + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "ok" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "ok" + assert capteur.since is None + + +async def test_status_flags_the_sensor_named_in_null_reasons() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture( + "A", + TIMESTAMP, + "partial", + null_reasons=["temperature_sensor_failure"], + temperature_celsius=None, + ) + ] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "degraded" + assert site.sensors.temperature.status == "failing" + assert site.sensors.temperature.since == TIMESTAMP + assert site.sensors.consumption.status == "ok" + assert site.sensors.electrical.status == "ok" + assert site.sensors.humidity.status == "ok" + assert site.sensors.network.status == "ok" + + +async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.sensors.humidity.status == "failing" + assert site.sensors.humidity.since == TIMESTAMP + + +async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.sensors.electrical.status == "failing" + + +async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type] + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "critical" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "failing" + assert capteur.since == TIMESTAMP + + +async def test_status_treats_an_unknown_data_quality_as_critical() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type] + ) + + etat = await service.status() + + assert etat.sites[0].overall == "critical" + + +async def test_status_ignores_an_unknown_null_reason() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "ok" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "ok" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..d803893 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites,
recommendations, stats"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, sensors"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService, RecommendationService,
StatsService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, recommendation, reading"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, SensorService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading"] md["models
10 tables"] db[("PostgreSQL")] @@ -146,6 +146,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | +| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | 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` | | @@ -167,9 +168,10 @@ contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore -exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) -dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +exposée. `GET /stats/summary` et `GET /sensors/status` agrègent chacune deux repositories +(`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : +elles n'entrent donc pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est +dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` @@ -246,8 +248,8 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv ### Ajouter une route métier -Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats` -(`reading`, `dataset`, `prediction`) : +Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ +`sensors` (`reading`, `dataset`, `prediction`) : 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux From 1c6b6105bd4905c15405986016d69018c80176e0 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 14:54:12 +0200 Subject: [PATCH 088/205] chore(frontend): ajout de la configuration nginx --- apps/frontend/nginx.conf | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/frontend/nginx.conf diff --git a/apps/frontend/nginx.conf b/apps/frontend/nginx.conf new file mode 100644 index 0000000..08e703d --- /dev/null +++ b/apps/frontend/nginx.conf @@ -0,0 +1,32 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + + server { + listen 3000; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~ /\. { + deny all; + } + } +} From 06cb60463cd1677d8c5f31a4b9167991d4b5c4f7 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Wed, 16 Sep 2026 15:12:04 +0200 Subject: [PATCH 089/205] =?UTF-8?q?fix(frontend):=20droit=20d'acc=C3=A8s?= =?UTF-8?q?=20au=20fichier=20de=20config=20de=20nginx,=20r=C3=A9duction=20?= =?UTF-8?q?de=20code=20smells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/Dockerfile | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index b6029dc..1890bc1 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -3,17 +3,14 @@ # ================== # Image pour frontend -FROM dhi.io/node:24-alpine3.22 AS builder +FROM node:24-alpine3.22 AS builder WORKDIR /app -# Installation des dépendances du projet avec npm -RUN npm ci - COPY package.json package-lock.json* ./ - - +# Installation des dépendances du projet avec npm +RUN npm ci # Copie du code source vers le conteneur COPY . . @@ -29,10 +26,10 @@ RUN npm run build FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner # Copie de la configuration de nginx -COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf +COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf # Copy the static build output from the build stage to Nginx's default HTML serving directory -COPY --chown=nginx:nginx --from=builder /app/dist/*/browser /usr/share/nginx/html +COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html # Create necessary directories with proper permissions for nginx RUN mkdir -p /var/log/nginx /var/cache/nginx && \ From 07ea8d21dc6ef7a01b141369da8d4a74b10c1e26 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:25:14 +0200 Subject: [PATCH 090/205] feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant la vérification d'existence déjà en place pour GET /sites/{site_id} : SiteService gagne une dépendance ReadingRepository, sur le modèle de composition déjà utilisé par StatsService/SensorService. Un site connu sans lecture rend 200 avec les champs de mesure à null et data_quality="critical" ; seul un site_id absent rend 404. --- apps/backend/app/api/deps.py | 2 +- apps/backend/app/api/v1/endpoints/sites.py | 20 +- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/schemas/site.py | 20 ++ apps/backend/app/services/site.py | 65 +++++- apps/backend/openapi.json | 221 +++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sites.py | 52 ++++- apps/backend/tests/services/test_site.py | 89 ++++++++- docs/architecture/20-backend.md | 7 +- 10 files changed, 474 insertions(+), 12 deletions(-) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..eb78758 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -140,7 +140,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)] def get_site_service(session: SessionDep) -> SiteService: - return SiteService(sites=SiteRepository(session)) + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..93923e9 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -34,3 +34,21 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, service: SiteServiceDep +) -> SiteCurrentResponse: + try: + actuel = await service.current(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 5424b46..c05ae60 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -19,3 +19,12 @@ class ReadingRepository: .order_by(Reading.site_id, Reading.timestamp.desc()) ) return (await self._session.execute(requete)).scalars().all() + + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc()) + .limit(1) + ) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -10,3 +13,20 @@ class SiteResponse(BaseModel): location: str | None capacity_kw: float | None status: str | None + + +class SiteCurrentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: Literal["good", "partial", "degraded", "critical"] diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..25a819d 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,8 +1,16 @@ from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal from app.models.energy import Site +from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + class SiteError(Exception): pass @@ -12,9 +20,26 @@ class SiteNotFoundError(SiteError): pass +@dataclass(frozen=True, slots=True) +class SiteCurrentReading: + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: DataQuality + + class SiteService: - def __init__(self, *, sites: SiteRepository) -> None: + def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None: self._sites = sites + self._readings = readings async def list_all(self) -> Sequence[Site]: return await self._sites.list_all() @@ -24,3 +49,41 @@ class SiteService: if site is None: raise SiteNotFoundError(site_id) return site + + async def current(self, site_id: str) -> SiteCurrentReading: + site = await self.get_by_id(site_id) + derniere = await self._readings.latest_for_site(site_id) + + if derniere is None: + return SiteCurrentReading( + timestamp=None, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=None, + consumption_kwh=None, + voltage_v=None, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + null_reasons=[], + data_quality="critical", + ) + + qualite: DataQuality = ( + derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + ) + return SiteCurrentReading( + timestamp=derniere.timestamp, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=derniere.consumption_kw, + consumption_kwh=derniere.consumption_kwh, + voltage_v=derniere.voltage_v, + current_a=derniere.current_a, + power_factor=derniere.power_factor, + temperature_celsius=derniere.temperature_celsius, + humidity_percent=derniere.humidity_percent, + null_reasons=derniere.null_reasons or [], + data_quality=qualite, + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..6844f72 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -921,6 +921,93 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCurrentResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "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" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ @@ -1572,6 +1659,140 @@ ], "title": "Role" }, + "SiteCurrentResponse": { + "properties": { + "timestamp": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "voltage_v": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Voltage V" + }, + "current_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current A" + }, + "power_factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Power Factor" + }, + "temperature_celsius": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature Celsius" + }, + "humidity_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Humidity Percent" + }, + "null_reasons": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Null Reasons" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "timestamp", + "site_id", + "site_type", + "consumption_kw", + "consumption_kwh", + "voltage_v", + "current_a", + "power_factor", + "temperature_celsius", + "humidity_percent", + "null_reasons", + "data_quality" + ], + "title": "SiteCurrentResponse" + }, "SiteResponse": { "properties": { "site_id": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..3112937 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -31,6 +31,7 @@ ROUTES_A_ROLE = { ("POST", "/api/v1/users/{id}/password-reset"), ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/sites/{site_id}/current"), ("GET", "/api/v1/alerts"), ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Site -from app.services.site import SiteNotFoundError +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site: ) +def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading: + return SiteCurrentReading( + timestamp=TIMESTAMP, + site_id=site_id, + site_type="industriel", + consumption_kw=87.34, + consumption_kwh=87.34, + voltage_v=401.2, + current_a=132.5, + power_factor=0.923, + temperature_celsius=22.1, + humidity_percent=58.4, + null_reasons=[], + data_quality="good", + ) + + class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.site = site() + self.actuel = lecture_actuelle() async def list_all(self) -> list[Site]: return [self.site] @@ -46,6 +67,11 @@ class FauxService: raise self._erreur return self.site + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site( assert response.status_code == 404 +async def test_get_current_returns_the_latest_reading( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["site_id"] == "site-1" + assert corps["data_quality"] == "good" + assert corps["consumption_kw"] == 87.34 + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 + + async def test_list_sites_reaches_the_repository_through_the_session( lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient ) -> None: diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..7e555e8 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -1,8 +1,13 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + import pytest from app.models.energy import Site from app.services.site import SiteNotFoundError, SiteService +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + def site(site_id: str = "site-1") -> Site: return Site( @@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site: ) +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime = TIMESTAMP + consumption_kw: float | None = 87.34 + consumption_kwh: float | None = 87.34 + voltage_v: float | None = 401.2 + current_a: float | None = 132.5 + power_factor: float | None = 0.923 + temperature_celsius: float | None = 22.1 + humidity_percent: float | None = 58.4 + null_reasons: list[str] | None = field(default_factory=list) + data_quality: str | None = "good" + + class FakeRepository: def __init__(self, sites: list[Site]) -> None: self._sites = sites @@ -26,24 +46,79 @@ class FakeRepository: return next((s for s in self._sites if s.site_id == site_id), None) -async def test_list_all_returns_the_repository_sites() -> None: - service = SiteService(sites=FakeRepository([site("a"), site("b")])) +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures - sites = await service.list_all() + async def latest_for_site(self, site_id: str) -> FauxLecture | None: + return self._lectures.get(site_id) + + +def service( + sites: list[Site], lectures: dict[str, FauxLecture] | None = None +) -> SiteService: + return SiteService( + sites=FakeRepository(sites), # type: ignore[arg-type] + readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] + ) + + +async def test_list_all_returns_the_repository_sites() -> None: + svc = service([site("a"), site("b")]) + + sites = await svc.list_all() assert [s.site_id for s in sites] == ["a", "b"] async def test_get_by_id_returns_the_matching_site() -> None: - service = SiteService(sites=FakeRepository([site("a")])) + svc = service([site("a")]) - trouve = await service.get_by_id("a") + trouve = await svc.get_by_id("a") assert trouve.site_id == "a" async def test_get_by_id_raises_when_the_site_is_unknown() -> None: - service = SiteService(sites=FakeRepository([])) + svc = service([]) with pytest.raises(SiteNotFoundError): - await service.get_by_id("inconnu") + await svc.get_by_id("inconnu") + + +async def test_current_raises_when_the_site_is_unknown() -> None: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + await svc.current("inconnu") + + +async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None: + svc = service([site("a")]) + + actuel = await svc.current("a") + + assert actuel.timestamp is None + assert actuel.consumption_kw is None + assert actuel.data_quality == "critical" + assert actuel.null_reasons == [] + + +async def test_current_copies_every_field_from_the_latest_reading() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a")}) + + actuel = await svc.current("a") + + assert actuel.timestamp == TIMESTAMP + assert actuel.site_type == "industriel" + assert actuel.consumption_kw == 87.34 + assert actuel.voltage_v == 401.2 + assert actuel.data_quality == "good" + + +async def test_current_treats_an_unknown_data_quality_as_critical() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)}) + + actuel = await svc.current("a") + + assert actuel.data_quality == "critical" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..32253fb 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | @@ -169,7 +170,11 @@ gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`, mais +`SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la +dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de +mesure à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From 2f97e4d4344deb8831559e6cf8a0d74e3061911e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:27:05 +0200 Subject: [PATCH 091/205] fix(backend): corrige formatage ruff et typage mypy sur sites/current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non annoté, assignation Literal non étroite). Corrige sans changer le comportement. --- apps/backend/app/api/v1/endpoints/sites.py | 4 +--- apps/backend/app/repositories/reading.py | 3 ++- apps/backend/app/services/site.py | 6 +++--- apps/backend/tests/services/test_site.py | 4 +--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 93923e9..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -42,9 +42,7 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site summary="Dernière mesure d'un site", responses=REPONSES_INTROUVABLE, ) -async def get_current( - site_id: str, _: LecteurDep, service: SiteServiceDep -) -> SiteCurrentResponse: +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: try: actuel = await service.current(site_id) except SiteNotFoundError as erreur: diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index c05ae60..7981b9e 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -27,4 +27,5 @@ class ReadingRepository: .order_by(Reading.timestamp.desc()) .limit(1) ) - return await self._session.scalar(requete) + lecture: Reading | None = await self._session.scalar(requete) + return lecture diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 25a819d..50d2e24 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -70,9 +70,9 @@ class SiteService: data_quality="critical", ) - qualite: DataQuality = ( - derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" - ) + qualite: DataQuality = "critical" + if derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] return SiteCurrentReading( timestamp=derniere.timestamp, site_id=site.site_id, diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 7e555e8..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -54,9 +54,7 @@ class FauxDepotLectures: return self._lectures.get(site_id) -def service( - sites: list[Site], lectures: dict[str, FauxLecture] | None = None -) -> SiteService: +def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService: return SiteService( sites=FakeRepository(sites), # type: ignore[arg-type] readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] From 5669cd63ec9ae9b4e6ebcdc9db71434cec92a96b Mon Sep 17 00:00:00 2001 From: Valentin Date: Wed, 16 Sep 2026 16:07:28 +0200 Subject: [PATCH 092/205] feat(frontend): authentification frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard --- apps/frontend/angular.json | 3 +- apps/frontend/src/app/app.config.ts | 12 +- apps/frontend/src/app/app.routes.ts | 6 +- .../src/app/core/guards/auth-guard.spec.ts | 67 ++++++++ .../src/app/core/guards/auth-guard.ts | 21 +++ .../interceptors/auth-interceptor.spec.ts | 161 ++++++++++++++++++ .../app/core/interceptors/auth-interceptor.ts | 77 +++++++++ .../app/core/services/auth.service.spec.ts | 86 ++++++++++ .../src/app/core/services/auth.service.ts | 69 ++++++++ .../auth/change-password/change-password.html | 31 ++++ .../auth/change-password/change-password.scss | 88 ++++++++++ .../change-password/change-password.spec.ts | 88 ++++++++++ .../auth/change-password/change-password.ts | 41 +++++ .../src/app/features/auth/login/login.html | 36 ++++ .../src/app/features/auth/login/login.scss | 81 +++++++++ .../src/app/features/auth/login/login.spec.ts | 110 ++++++++++++ .../src/app/features/auth/login/login.ts | 55 ++++++ .../src/app/features/dashboard/dashboard.html | 7 + .../src/app/features/dashboard/dashboard.scss | 27 +++ .../app/features/dashboard/dashboard.spec.ts | 56 ++++++ .../src/app/features/dashboard/dashboard.ts | 15 ++ .../src/app/shared/models/auth.model.ts | 26 +++ apps/frontend/src/environments/environment.ts | 2 +- 23 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/app/core/guards/auth-guard.spec.ts create mode 100644 apps/frontend/src/app/core/guards/auth-guard.ts create mode 100644 apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts create mode 100644 apps/frontend/src/app/core/interceptors/auth-interceptor.ts create mode 100644 apps/frontend/src/app/core/services/auth.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/auth.service.ts create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.html create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.scss create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.ts create mode 100644 apps/frontend/src/app/features/auth/login/login.html create mode 100644 apps/frontend/src/app/features/auth/login/login.scss create mode 100644 apps/frontend/src/app/features/auth/login/login.spec.ts create mode 100644 apps/frontend/src/app/features/auth/login/login.ts create mode 100644 apps/frontend/src/app/shared/models/auth.model.ts diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index ddf87a3..814e4f8 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index ff4cafd..66ed3d3 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,13 +1,21 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import {catchError, firstValueFrom, of} from 'rxjs'; +import {AuthService} from './core/services/auth.service'; +import {authInterceptor} from './core/interceptors/auth-interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(withInterceptors([mockApiInterceptor])), + provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor])), + provideAppInitializer(() => { + const auth = inject(AuthService); + // Un 401 ici est normal : ça veut juste dire qu'il n'y a pas de session. + return firstValueFrom(auth.refreshShared().pipe(catchError(() => of(null)))); + }), ], }; diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 8f2739c..b3e97d8 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,9 +1,13 @@ import { Routes } from '@angular/router'; +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: 'dashboard', - loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), + canActivate: [authGuard], + loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), }, ]; diff --git a/apps/frontend/src/app/core/guards/auth-guard.spec.ts b/apps/frontend/src/app/core/guards/auth-guard.spec.ts new file mode 100644 index 0000000..ebf9256 --- /dev/null +++ b/apps/frontend/src/app/core/guards/auth-guard.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, ActivatedRouteSnapshot } from '@angular/router'; +import { vi } from 'vitest'; +import { authGuard } from './auth-guard'; +import { AuthService } from '../services/auth.service'; + +describe('authGuard', () => { + let authMock: { isAuthenticated: ReturnType; principal: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(() => { + authMock = { isAuthenticated: vi.fn(), principal: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + }); + + it('redirige vers /login si non authentifié', () => { + authMock.isAuthenticated.mockReturnValue(false); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('redirige vers /login si le rôle ne correspond pas', () => { + authMock.isAuthenticated.mockReturnValue(true); + authMock.principal.mockReturnValue({ role: 'lecteur' }); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('autorise si authentifié et rôle correspondant', () => { + authMock.isAuthenticated.mockReturnValue(true); + authMock.principal.mockReturnValue({ role: 'admin' }); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(true); + }); + + it('autorise si authentifié et aucun rôle requis', () => { + authMock.isAuthenticated.mockReturnValue(true); + authMock.principal.mockReturnValue({ role: 'lecteur' }); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(true); + }); +}); diff --git a/apps/frontend/src/app/core/guards/auth-guard.ts b/apps/frontend/src/app/core/guards/auth-guard.ts new file mode 100644 index 0000000..c6252cc --- /dev/null +++ b/apps/frontend/src/app/core/guards/auth-guard.ts @@ -0,0 +1,21 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +export const authGuard: CanActivateFn = (route) => { + const auth = inject(AuthService); + const router = inject(Router); + + if (!auth.isAuthenticated()) { + router.navigate(['/login']); + return false; + } + + const requiredRole = route.data['role'] as string | undefined; + if (requiredRole && auth.principal()?.role !== requiredRole) { + router.navigate(['/login']); + return false; + } + + return true; +}; diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts new file mode 100644 index 0000000..8f74cd8 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts @@ -0,0 +1,161 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClient, + HttpHandlerFn, + HttpHeaders, + HttpRequest, + provideHttpClient, + withInterceptors +} from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { authInterceptor } from './auth-interceptor'; +import { AuthService } from '../services/auth.service'; + +describe('authInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + let authMock: { getAccessToken: ReturnType; clearSession: ReturnType; refreshShared: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(() => { + authMock = { + getAccessToken: vi.fn().mockReturnValue('fake-token'), + clearSession: vi.fn(), + refreshShared: vi.fn(), + }; + routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('ajoute le header Authorization quand un token est disponible', () => { + http.get('/api/v1/stats/summary').subscribe(); + const req = httpMock.expectOne('/api/v1/stats/summary'); + expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token'); + req.flush({}); + }); + + it("n'ajoute pas le header Authorization sur /auth/login", () => { + http.post('/api/v1/auth/login', {}).subscribe(); + const req = httpMock.expectOne('/api/v1/auth/login'); + expect(req.request.headers.has('Authorization')).toBe(false); + req.flush({}); + }); + + it('ajoute withCredentials sur les routes /auth/*', () => { + http.post('/api/v1/auth/login', {}).subscribe(); + const req = httpMock.expectOne('/api/v1/auth/login'); + expect(req.request.withCredentials).toBe(true); + req.flush({}); + }); + + it('redirige vers /change-password sur un 403 avec ce detail précis', () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush({ detail: 'password_change_required' }, { status: 403, statusText: 'Forbidden' }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']); + }); + + it('ne redirige pas sur un 403 avec un autre detail', () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush({ detail: 'Droits insuffisants' }, { status: 403, statusText: 'Forbidden' }); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('déconnecte et redirige vers /login sur un 401 avec error="invalid_token"', () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush( + {}, + { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="invalid_token"' }) } + ); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('déconnecte directement sur un 401 provenant de /auth/refresh, sans tenter de rafraîchir', () => { + http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/auth/refresh'); + req.flush({}, { status: 401, statusText: 'Unauthorized' }); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => { + authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' })); + authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token'); + + let result: unknown; + http.get('/api/v1/dashboard').subscribe((r) => (result = r)); + + const firstReq = httpMock.expectOne('/api/v1/dashboard'); + firstReq.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) }); + + const retriedReq = httpMock.expectOne('/api/v1/dashboard'); + expect(retriedReq.request.headers.get('Authorization')).toBe('Bearer new-token'); + retriedReq.flush({ ok: true }); + + expect(result).toEqual({ ok: true }); + }); + + it('déconnecte si le rafraîchissement échoue après un 401 "expired"', () => { + authMock.refreshShared.mockReturnValue(throwError(() => new Error('refresh failed'))); + + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) }); + + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it("propage l'erreur telle quelle si ce n'est pas une HttpErrorResponse", () => { + const req = new HttpRequest('GET', '/api/v1/dashboard'); + const boom = new Error('erreur inattendue, pas HTTP'); + const next: HttpHandlerFn = () => throwError(() => boom); + + let captured: unknown; + TestBed.runInInjectionContext(() => { + authInterceptor(req, next).subscribe({ error: (e) => (captured = e) }); + }); + + expect(captured).toBe(boom); +}); + +it('propage un 401 sur /auth/login sans tenter de rafraîchir ni déconnecter', () => { + http.post('/api/v1/auth/login', {}).subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/auth/login'); + req.flush({}, { status: 401, statusText: 'Unauthorized' }); + + expect(authMock.refreshShared).not.toHaveBeenCalled(); + expect(authMock.clearSession).not.toHaveBeenCalled(); +}); + +it("propage un 401 dont le WWW-Authenticate ne correspond à aucun cas connu", () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush( + {}, + { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="unknown_case"' }) } + ); + + expect(authMock.refreshShared).not.toHaveBeenCalled(); + expect(authMock.clearSession).not.toHaveBeenCalled(); +}); +}); diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts new file mode 100644 index 0000000..46ba124 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts @@ -0,0 +1,77 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable, catchError, switchMap, throwError } from 'rxjs'; +import { AuthService } from '../services/auth.service'; +import { TokenResponse } from '../../shared/models/auth.model'; + +function parseAuthError(response: HttpErrorResponse): string | null { + const header = response.headers?.get('WWW-Authenticate') ?? ''; + const match = header.match(/error="([^"]+)"/); + return match ? match[1] : null; +} + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const auth = inject(AuthService); + const router = inject(Router); + + const isAuthRoute = req.url.includes('/auth/'); + let request = isAuthRoute ? req.clone({ withCredentials: true }) : req; + + const token = auth.getAccessToken(); + if (token && !req.url.endsWith('/auth/login')) { + request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); + } + + return next(request).pipe( + catchError((error: unknown) => { + if (!(error instanceof HttpErrorResponse)) { + return throwError(() => error); + } + + if (error.status === 403) { + const detail = (error.error as { detail?: string })?.detail; + if (detail === 'password_change_required') { + router.navigate(['/change-password']); + } + return throwError(() => error); + } + + if (error.status !== 401 || req.url.endsWith('/auth/login')) { + return throwError(() => error); + } + + if (req.url.endsWith('/auth/refresh')) { + auth.clearSession(); + router.navigate(['/login']); + return throwError(() => error); + } + + const kind = parseAuthError(error); + + if (kind === 'invalid_token') { + auth.clearSession(); + router.navigate(['/login']); + return throwError(() => error); + } + + if (kind === 'expired' || kind === 'token_stale') { + return (auth.refreshShared() as Observable).pipe( + switchMap(() => { + const retried = request.clone({ + setHeaders: { Authorization: `Bearer ${auth.getAccessToken()}` }, + }); + return next(retried); + }), + catchError((refreshError) => { + auth.clearSession(); + router.navigate(['/login']); + return throwError(() => refreshError); + }) + ); + } + + return throwError(() => error); + }) + ); +}; diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts new file mode 100644 index 0000000..bff86c4 --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { AuthService } from './auth.service'; +import { environment } from '../../../environments/environment'; + +describe('AuthService', () => { + let service: AuthService; + let httpMock: HttpTestingController; + + const tokenResponse = { + access_token: 'abc123', + token_type: 'bearer', + expires_in: 900, + principal: { + id: '1', + email: 'a@a.com', + role: 'admin' as const, + kind: 'human' as const, + must_change_password: false, + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('stocke le token et le principal après un login réussi', () => { + service.login({ email: 'a@a.com', password: 'secret' }).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/auth/login`); + expect(req.request.withCredentials).toBe(true); + req.flush(tokenResponse); + + expect(service.getAccessToken()).toBe('abc123'); + expect(service.principal()?.email).toBe('a@a.com'); + expect(service.isAuthenticated()).toBe(true); + }); + + it('efface la session au logout', () => { + service.login({ email: 'a@a.com', password: 'secret' }).subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse); + + service.logout().subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/logout`).flush(null); + + expect(service.getAccessToken()).toBeNull(); + expect(service.isAuthenticated()).toBe(false); + }); + + it("ne déclenche qu'un seul appel réseau si refreshShared est appelé plusieurs fois avant la réponse", () => { + service.refreshShared().subscribe(); + service.refreshShared().subscribe(); + service.refreshShared().subscribe(); + + const requests = httpMock.match(`${environment.apiUrl}/auth/refresh`); + expect(requests.length).toBe(1); + requests[0].flush(tokenResponse); + }); + + it('met à jour la session après un changement de mot de passe réussi', () => { + service.changePassword({ current_password: 'old', new_password: 'new-password-1234' }).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/auth/password`); + req.flush(tokenResponse); + + expect(service.getAccessToken()).toBe('abc123'); + }); + + it('récupère le principal courant via /auth/me', () => { + let result: unknown; + service.me().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/auth/me`); + expect(req.request.method).toBe('GET'); + req.flush(tokenResponse.principal); + + expect(result).toEqual(tokenResponse.principal); +}); +}); diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..d27c1db --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -0,0 +1,69 @@ +import { Service, signal, computed, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, tap, finalize, shareReplay } from 'rxjs'; +import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; +import { environment } from '../../../environments/environment'; + +@Service() +export class AuthService { + private http = inject(HttpClient); + + // Jamais de localStorage/sessionStorage/cookie côté JS : juste un signal en + // mémoire. Un rechargement de page le perd, c'est voulu par le contrat. + private accessTokenSignal = signal(null); + private principalSignal = signal(null); + + readonly principal = this.principalSignal.asReadonly(); + readonly isAuthenticated = computed(() => this.principalSignal() !== null); + + private rotation$?: Observable; + + getAccessToken(): string | null { + return this.accessTokenSignal(); + } + + private setSession(response: TokenResponse): void { + this.accessTokenSignal.set(response.access_token); + this.principalSignal.set(response.principal); + } + + clearSession(): void { + this.accessTokenSignal.set(null); + this.principalSignal.set(null); + } + + login(credentials: LoginRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } + + // Un seul rafraîchissement en vol à la fois, partagé entre tous les + // appelants (sinon le serveur révoque toute la session sur des rotations concurrentes). + refreshShared(): Observable { + this.rotation$ ??= this.http + .post(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true }) + .pipe( + tap((response) => this.setSession(response)), + finalize(() => (this.rotation$ = undefined)), + shareReplay(1) + ); + return this.rotation$; + } + + logout(): Observable { + return this.http + .post(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true }) + .pipe(tap(() => this.clearSession())); + } + + changePassword(payload: PasswordChangeRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } + + me(): Observable { + return this.http.get(`${environment.apiUrl}/auth/me`); + } +} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html new file mode 100644 index 0000000..edf2146 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -0,0 +1,31 @@ +
+
+

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + + + + + 12 à 128 caractères + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + +
+
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss new file mode 100644 index 0000000..f44fcb8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -0,0 +1,88 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts new file mode 100644 index 0000000..63e1872 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ChangePassword } from './change-password'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('ChangePassword', () => { + let authMock: { changePassword: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { changePassword: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ChangePassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'old', new_password: 'trop-court' }); + + component.onSubmit(); + expect(authMock.changePassword).not.toHaveBeenCalled(); + }); + + it('redirige vers /dashboard après un changement réussi', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + + authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); + + component.onSubmit(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + + authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); + + component.onSubmit(); + fixture.detectChanges(); // rend le bloc @if (errorMessage()) + + expect(component.errorMessage()).toContain('incorrect'); + const errorEl = fixture.nativeElement.querySelector('.auth-error'); + expect(errorEl?.textContent).toContain('incorrect'); + }); + + it('désactive le bouton tant que le formulaire est invalide', () => { + const fixture = TestBed.createComponent(ChangePassword); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(button.disabled).toBe(true); + expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + }); + + it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + fixture.detectChanges(); + + authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); + + const form = fixture.nativeElement.querySelector('form'); + form.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + + expect(authMock.changePassword).toHaveBeenCalledWith({ + current_password: 'ancien-mot-de-passe', + new_password: 'un-nouveau-mot-de-passe-valide', + }); +}); + +}); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts new file mode 100644 index 0000000..507af14 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -0,0 +1,41 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-change-password', + standalone: true, + imports: [ReactiveFormsModule], + templateUrl: './change-password.html', + styleUrl: './change-password.scss', +}) +export class ChangePassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + + errorMessage = signal(null); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + current_password: ['', Validators.required], + new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + }); + + onSubmit(): void { + if (this.form.invalid) return; + this.isLoading.set(true); + this.errorMessage.set(null); + + this.auth.changePassword(this.form.getRawValue()).subscribe({ + next: (response) => { + this.router.navigate(['/dashboard']); + }, + error: () => { + this.isLoading.set(false); + this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html new file mode 100644 index 0000000..0083bd2 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -0,0 +1,36 @@ +
+
+

Connexion

+

Accédez à votre espace EnerVision

+ + + + + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + +
+
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss new file mode 100644 index 0000000..cc415b8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -0,0 +1,81 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts new file mode 100644 index 0000000..3c9bac1 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { Login } from './login'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('Login', () => { + let authMock: { login: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { login: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [Login, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide', () => { + const fixture = TestBed.createComponent(Login); + fixture.componentInstance.onSubmit(); + expect(authMock.login).not.toHaveBeenCalled(); + }); + + it('redirige vers /change-password si must_change_password est vrai', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'secret' }); + + authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } })); + + component.onSubmit(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']); + }); + + it('redirige vers /dashboard si le mot de passe est déjà à jour', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'secret' }); + + authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } })); + + component.onSubmit(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('affiche un message générique sur un 401', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'wrong' }); + + authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 }))); + + component.onSubmit(); + fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template + + expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); + const errorEl = fixture.nativeElement.querySelector('.auth-error'); + expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); + }); + + it("affiche le délai d'attente sur un 429 avec Retry-After", () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'wrong' }); + + authMock.login.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) })) + ); + + component.onSubmit(); + fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) + + expect(component.retryAfterSeconds()).toBe(30); + const errorEl = fixture.nativeElement.querySelector('.auth-error'); + expect(errorEl?.textContent).toContain('30s'); + }); + + it('désactive le bouton tant que le formulaire est invalide', () => { + const fixture = TestBed.createComponent(Login); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(button.disabled).toBe(true); + expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + }); + + it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'secret' }); + fixture.detectChanges(); + + authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } })); + + const form = fixture.nativeElement.querySelector('form'); + form.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + + expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' }); + }); +}); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts new file mode 100644 index 0000000..34b9ff2 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -0,0 +1,55 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ReactiveFormsModule], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class Login { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + + errorMessage = signal(null); + retryAfterSeconds = signal(null); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + onSubmit(): void { + if (this.form.invalid) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + this.retryAfterSeconds.set(null); + + this.auth.login(this.form.getRawValue()).subscribe({ + next: (response) => { + if (response.principal.must_change_password) { + this.router.navigate(['/change-password']); + return; + } + this.router.navigate(['/dashboard']); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 429) { + const retryAfter = error.headers.get('Retry-After'); + this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null); + this.errorMessage.set('Trop de tentatives, réessayez plus tard.'); + return; + } + this.errorMessage.set('Email ou mot de passe incorrect.'); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index a64d5d9..8499bb3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,3 +1,10 @@ +
+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+ +

Vue d'ensemble

diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 01cc3a3..75976e1 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -144,3 +144,30 @@ h2 { .alert-item__message { font-size: 0.9rem; } +.dashboard__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.logout-button { + padding: 0.5rem 1rem; + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + color: #374151; + cursor: pointer; + + &:hover { + background: #f3f4f6; + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 89a69ec..5030b0d 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,8 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import {AuthService} from '../../core/services/auth.service'; +import {Router} from '@angular/router'; vi.mock('chart.js', () => { class ChartMock { @@ -92,4 +94,58 @@ describe('Dashboard', () => { expect(fixture.componentInstance.alerts().length).toBe(0); }); + + 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() }; + const routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('.logout-button'); + button.click(); + + expect(authMock.logout).toHaveBeenCalled(); + expect(routerMock.navigate).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(), + }; + const routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('.logout-button'); + button.click(); + + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); +}); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 7733230..c6a6a56 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -2,10 +2,12 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe } from '@angular/common'; +import { Router } 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 { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; import { Alert } from '../../shared/models/alert.model'; @@ -23,6 +25,8 @@ const UNAVAILABLE_MESSAGE = export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); + private auth = inject(AuthService); + private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); @@ -50,6 +54,17 @@ export class Dashboard implements OnInit { }); } + onLogout(): void { + this.auth.logout().subscribe({ + next: () => this.router.navigate(['/login']), + error: () => { + // Même si l'appel réseau échoue, on considère l'utilisateur déconnecté localement. + this.auth.clearSession(); + this.router.navigate(['/login']); + }, + }); + } + private reportUnavailable(): Observable { this.error.set(UNAVAILABLE_MESSAGE); return EMPTY; diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts new file mode 100644 index 0000000..932572f --- /dev/null +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -0,0 +1,26 @@ +export type Role = 'lecteur' | 'operateur' | 'admin'; + +export interface LoginRequest { + email: string; + password: string; +} + +export interface PasswordChangeRequest { + current_password: string; + new_password: string; +} + +export interface Principal { + id: string; + email: string; + role: Role; + kind: 'human'; + must_change_password: boolean; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + principal: Principal; +} diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index bac99a8..1f39f6f 100644 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,5 +1,5 @@ export const environment = { production: true, - apiUrl: 'http://localhost:8000/api/v1', + apiUrl: '/api/v1', useMockFixtures: false, }; From 0174272bdd4a47dca48462f33f5730c732a784b9 Mon Sep 17 00:00:00 2001 From: ValentinDeFaria <123947752+ValentinDeFaria@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:00:52 +0200 Subject: [PATCH 093/205] Update dashboard.html --- .../src/app/features/dashboard/dashboard.html | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 8499bb3..684b444 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,14 +1,10 @@ -
-
-

Vue d'ensemble

-

Consommation instantanée du parc

-
- -
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+
@if (error(); as message) { From 515a92b3950e4a85444faa413ea87750f30c9879 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 09:02:53 +0200 Subject: [PATCH 094/205] fix(frontend): isole les fichiers de tests vitest pour eviter la pollution de mocks Le test site-load-chart.spec.ts echouait de facon intermittente en CI : sans isolation, vitest partage le registre de modules entre fichiers de spec, donc le mock chart.js d'un fichier pouvait ecraser celui d'un autre selon l'ordre d'execution. --- apps/frontend/angular.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 814e4f8..6cb33fa 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -81,6 +81,7 @@ "builder": "@angular/build:unit-test", "options": { "coverage": true, + "isolate": true, "coverageReporters": [ "text-summary", "lcov", From 24bf8bf4b9066374d7a09903ea4e556c6f50b601 Mon Sep 17 00:00:00 2001 From: ValentinDeFaria <123947752+ValentinDeFaria@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:10:04 +0200 Subject: [PATCH 095/205] Update apps/backend/tests/services/test_sensor.py --- apps/backend/tests/services/test_sensor.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/backend/tests/services/test_sensor.py b/apps/backend/tests/services/test_sensor.py index 9a8d62f..85073b3 100644 --- a/apps/backend/tests/services/test_sensor.py +++ b/apps/backend/tests/services/test_sensor.py @@ -195,3 +195,30 @@ async def test_status_ignores_an_unknown_null_reason() -> None: site.sensors.network, ): assert capteur.status == "ok" + + +async def test_status_flags_network_from_null_reasons_only() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture( + "A", + TIMESTAMP, + "partial", + null_reasons=["network_loss"], + ) + ] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "degraded" + assert site.sensors.network.status == "failing" + assert site.sensors.network.since == TIMESTAMP + assert site.sensors.consumption.status == "ok" + assert site.sensors.electrical.status == "ok" + assert site.sensors.temperature.status == "ok" + assert site.sensors.humidity.status == "ok" From fcbfcc8eb2d5c21ece42a3b60e656d1e9f66a20d Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Wed, 16 Sep 2026 12:49:21 +0200 Subject: [PATCH 096/205] feat(data): ajoute l'import historique des donnees --- .gitignore | 3 +- apps/backend/app/etl/__init__.py | 0 apps/backend/app/etl/historical_import.py | 783 ++++++++++++++++++++++ apps/backend/pyproject.toml | 1 + apps/backend/uv.lock | 95 +++ data/raw/.gitkeep | 0 6 files changed, 881 insertions(+), 1 deletion(-) create mode 100644 apps/backend/app/etl/__init__.py create mode 100644 apps/backend/app/etl/historical_import.py create mode 100644 data/raw/.gitkeep diff --git a/.gitignore b/.gitignore index bb3dca3..38ef5cf 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,8 @@ standalone_admin_password.txt secrets/ # Donnees locales -data/ +data/raw/* +!data/raw/.gitkeep *.sqlite3 monitoring/grafana/data/ monitoring/prometheus/data/ diff --git a/apps/backend/app/etl/__init__.py b/apps/backend/app/etl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py new file mode 100644 index 0000000..e876c26 --- /dev/null +++ b/apps/backend/app/etl/historical_import.py @@ -0,0 +1,783 @@ +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +from pathlib import Path +from typing import Any + +import pandas as pd +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings + +REQUIRED_COLUMNS = { + "timestamp", + "site_id", + "site_type", + "site_name", + "consumption_kwh", + "consumption_euros", + "temperature_celsius", + "humidity_percent", + "solar_irradiance_wm2", + "hour", + "day_of_week", + "day_name", + "month", + "is_weekend", + "is_working_hours", +} + +MEASURE_COLUMNS = [ + "consumption_kwh", + "consumption_euros", + "temperature_celsius", + "humidity_percent", + "solar_irradiance_wm2", +] + +SOURCE_NAME = "historical_csv" + + +def compute_sha256(path: Path) -> str: + """Calcule l'empreinte SHA-256 du fichier source.""" + sha256 = hashlib.sha256() + + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + sha256.update(block) + + return sha256.hexdigest() + + +def load_metadata(path: Path) -> dict[str, Any]: + """Charge les métadonnées fournies avec le dataset.""" + with path.open("r", encoding="utf-8") as source: + return json.load(source) + + +def classify_quality( + row: dict[str, Any], +) -> tuple[str, list[str]]: + """ + Déduit une qualité technique à partir des champs manquants. + + Les valeurs NULL sont conservées. On ne cherche pas ici à + déterminer la cause physique exacte de leur absence. + """ + missing = [ + column + for column in MEASURE_COLUMNS + if pd.isna(row.get(column)) + ] + + if not missing: + quality = "good" + elif len(missing) == len(MEASURE_COLUMNS): + quality = "critical" + elif "consumption_kwh" in missing: + quality = "degraded" + else: + quality = "partial" + + reasons = [ + f"missing:{column}" + for column in missing + ] + + return quality, reasons + + +def validate_source( + frame: pd.DataFrame, + metadata: dict[str, Any], +) -> None: + """Valide le dataset avant tout chargement en base.""" + missing_columns = REQUIRED_COLUMNS.difference( + frame.columns + ) + + if missing_columns: + raise ValueError( + "Colonnes obligatoires absentes : " + f"{sorted(missing_columns)}" + ) + + expected_records = int(metadata["total_records"]) + + if len(frame) != expected_records: + raise ValueError( + "Nombre de lignes inattendu : " + f"{len(frame)} au lieu de " + f"{expected_records}" + ) + + expected_sites = set(metadata["sites"].keys()) + actual_sites = set(frame["site_id"].unique()) + + if actual_sites != expected_sites: + raise ValueError( + "Sites incohérents. " + f"Attendus={sorted(expected_sites)}, " + f"trouvés={sorted(actual_sites)}" + ) + + duplicated = frame.duplicated( + subset=["site_id", "timestamp"] + ).sum() + + if duplicated: + raise ValueError( + f"{duplicated} doublons " + "(site_id, timestamp) détectés" + ) + + static_variants = ( + frame.groupby("site_id")[ + ["site_type", "site_name"] + ] + .nunique() + ) + + if (static_variants > 1).any().any(): + raise ValueError( + "Un site possède plusieurs valeurs " + "de site_type ou site_name." + ) + + # Vérifie également que tous les timestamps + # peuvent être interprétés correctement. + pd.to_datetime( + frame["timestamp"], + errors="raise", + ) + + +def normalize_timestamps( + frame: pd.DataFrame, + source_timezone: str, +) -> pd.DataFrame: + """ + Normalise les timestamps et leur associe une timezone. + + Les timestamps originaux sont conservés dans une colonne + temporaire afin de pouvoir les stocker dans raw_data. + """ + normalized = frame.copy() + + normalized["_source_timestamp"] = ( + normalized["timestamp"] + ) + + timestamps = pd.to_datetime( + normalized["timestamp"], + errors="raise", + ) + + if timestamps.dt.tz is None: + timestamps = timestamps.dt.tz_localize( + source_timezone + ) + else: + timestamps = timestamps.dt.tz_convert( + source_timezone + ) + + normalized["timestamp"] = timestamps + + return normalized + + +def to_json_value(value: Any) -> Any: + """ + Convertit une valeur Pandas/Numpy en valeur + compatible JSON. + """ + if value is None: + return None + + try: + if pd.isna(value): + return None + except (TypeError, ValueError): + pass + + if isinstance(value, pd.Timestamp): + return value.isoformat() + + if hasattr(value, "item"): + return value.item() + + return value + + +async def ensure_dataset( + connection: AsyncConnection, + metadata: dict[str, Any], + sha256: str, + source_timezone: str, + storage_uri: str, +) -> int: + """ + Crée l'entrée dataset si elle n'existe pas. + + Le SHA-256 permet de reconnaître un fichier déjà importé + et participe à l'idempotence et à la traçabilité. + """ + result = await connection.execute( + text( + """ + SELECT dataset_id + FROM dataset + WHERE archive_sha256 = :sha256 + LIMIT 1 + """ + ), + { + "sha256": sha256, + }, + ) + + existing = result.scalar_one_or_none() + + if existing is not None: + return int(existing) + + metadata_summary = { + "generator_version": metadata.get( + "generator_version" + ), + "total_sites": metadata.get( + "total_sites" + ), + "total_records": metadata.get( + "total_records" + ), + "date_range": metadata.get( + "date_range" + ), + "frequency": metadata.get( + "frequency" + ), + "null_injection_enabled": metadata.get( + "null_injection_enabled" + ), + "null_strategies": metadata.get( + "null_strategies" + ), + "importer": "historical_import_v1", + } + + result = await connection.execute( + text( + """ + INSERT INTO dataset ( + dataset_name, + archive_sha256, + storage_uri, + source_timezone, + "metadata" + ) + VALUES ( + :dataset_name, + :archive_sha256, + :storage_uri, + :source_timezone, + CAST(:metadata AS jsonb) + ) + RETURNING dataset_id + """ + ), + { + "dataset_name": ( + "EnerVision historical dataset " + "2023-2024" + ), + "archive_sha256": sha256, + "storage_uri": storage_uri, + "source_timezone": source_timezone, + "metadata": json.dumps( + metadata_summary, + ensure_ascii=False, + ), + }, + ) + + return int(result.scalar_one()) + + +async def upsert_sites( + connection: AsyncConnection, + frame: pd.DataFrame, +) -> None: + """Insère ou met à jour les sites du dataset.""" + sites = ( + frame[ + [ + "site_id", + "site_type", + "site_name", + ] + ] + .drop_duplicates( + subset=["site_id"] + ) + .to_dict( + orient="records" + ) + ) + + await connection.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name + ) + VALUES ( + :site_id, + :site_type, + :site_name + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name + """ + ), + sites, + ) + + +def build_reading_batch( + chunk: pd.DataFrame, + dataset_id: int, +) -> list[dict[str, Any]]: + """ + Transforme un chunk Pandas en lignes prêtes + à être chargées dans la table reading. + """ + rows: list[dict[str, Any]] = [] + + for record in chunk.to_dict( + orient="records" + ): + quality, reasons = classify_quality( + record + ) + + raw_data = { + column: to_json_value(value) + for column, value in record.items() + if column != "_source_timestamp" + } + + # Dans raw_data, on conserve le timestamp + # exactement tel qu'il était dans le CSV. + raw_data["timestamp"] = to_json_value( + record["_source_timestamp"] + ) + + rows.append( + { + "site_id": record["site_id"], + "timestamp": record["timestamp"], + "source": SOURCE_NAME, + "dataset_id": dataset_id, + + # Non fourni par le dataset historique. + "consumption_kw": None, + + "consumption_kwh": to_json_value( + record["consumption_kwh"] + ), + "consumption_euros": to_json_value( + record["consumption_euros"] + ), + + # Non fournis par le CSV historique. + "voltage_v": None, + "current_a": None, + "power_factor": None, + + "temperature_celsius": ( + to_json_value( + record[ + "temperature_celsius" + ] + ) + ), + "humidity_percent": ( + to_json_value( + record[ + "humidity_percent" + ] + ) + ), + "solar_irradiance_wm2": ( + to_json_value( + record[ + "solar_irradiance_wm2" + ] + ) + ), + + "is_working_hours": bool( + record[ + "is_working_hours" + ] + ), + + "data_quality": quality, + "null_reasons": reasons, + + # Aucune imputation pendant + # l'ingestion RAW. + "imputed_values": json.dumps( + {} + ), + "imputation_method": None, + + # Conservation de la donnée source + # pour la traçabilité. + "raw_data": json.dumps( + raw_data, + ensure_ascii=False, + ), + } + ) + + return rows + + +READING_INSERT = text( + """ + INSERT INTO reading ( + site_id, + timestamp, + source, + dataset_id, + consumption_kw, + consumption_kwh, + consumption_euros, + voltage_v, + current_a, + power_factor, + temperature_celsius, + humidity_percent, + solar_irradiance_wm2, + is_working_hours, + data_quality, + null_reasons, + imputed_values, + imputation_method, + raw_data + ) + VALUES ( + :site_id, + :timestamp, + :source, + :dataset_id, + :consumption_kw, + :consumption_kwh, + :consumption_euros, + :voltage_v, + :current_a, + :power_factor, + :temperature_celsius, + :humidity_percent, + :solar_irradiance_wm2, + :is_working_hours, + :data_quality, + :null_reasons, + CAST(:imputed_values AS jsonb), + :imputation_method, + CAST(:raw_data AS jsonb) + ) + ON CONFLICT DO NOTHING + """ +) + + +async def import_historical( + csv_path: Path, + metadata_path: Path, + source_timezone: str, + batch_size: int, + dry_run: bool, + storage_uri: str, +) -> None: + """ + Exécute le pipeline ETL historique EnerVision. + + Étapes : + 1. Extract + 2. Validate + 3. Transform + 4. Load + """ + metadata = load_metadata( + metadata_path + ) + + frame = pd.read_csv( + csv_path + ) + + validate_source( + frame, + metadata, + ) + + print( + f"Lignes : {len(frame)}" + ) + print( + "Sites : " + f"{frame['site_id'].nunique()}" + ) + print( + "Période : " + f"{frame['timestamp'].min()} -> " + f"{frame['timestamp'].max()}" + ) + print( + "Doublons : " + f"{frame.duplicated(['site_id', 'timestamp']).sum()}" + ) + + print("\nValeurs NULL :") + print( + frame[ + MEASURE_COLUMNS + ].isna().sum() + ) + + sha256 = compute_sha256( + csv_path + ) + + print( + f"\nSHA-256 : {sha256}" + ) + + if dry_run: + print( + "\nDry-run terminé : " + "aucune donnée écrite." + ) + return + + normalized = normalize_timestamps( + frame, + source_timezone, + ) + + settings = get_settings() + + engine = create_async_engine( + str(settings.database_url), + pool_pre_ping=True, + ) + + try: + async with engine.begin() as connection: + dataset_id = await ensure_dataset( + connection=connection, + metadata=metadata, + sha256=sha256, + source_timezone=source_timezone, + storage_uri=storage_uri, + ) + + await upsert_sites( + connection, + normalized, + ) + + result = await connection.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE dataset_id = :dataset_id + AND source = :source + """ + ), + { + "dataset_id": dataset_id, + "source": SOURCE_NAME, + }, + ) + + before = int( + result.scalar_one() + ) + + for start in range( + 0, + len(normalized), + batch_size, + ): + chunk = normalized.iloc[ + start : start + batch_size + ] + + rows = build_reading_batch( + chunk, + dataset_id, + ) + + await connection.execute( + READING_INSERT, + rows, + ) + + loaded = min( + start + batch_size, + len(normalized), + ) + + print( + "Chargement : " + f"{loaded}/" + f"{len(normalized)}" + ) + + result = await connection.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE dataset_id = :dataset_id + AND source = :source + """ + ), + { + "dataset_id": dataset_id, + "source": SOURCE_NAME, + }, + ) + + after = int( + result.scalar_one() + ) + + print( + "\nImport terminé." + ) + print( + "dataset_id : " + f"{dataset_id}" + ) + print( + "lectures avant : " + f"{before}" + ) + print( + "lectures après : " + f"{after}" + ) + print( + "nouvelles lectures : " + f"{after - before}" + ) + + finally: + await engine.dispose() + + +def parse_args() -> argparse.Namespace: + """Définit les arguments CLI de l'import.""" + parser = argparse.ArgumentParser( + description=( + "Import historique EnerVision" + ) + ) + + parser.add_argument( + "--csv", + type=Path, + required=True, + help="Chemin vers le CSV historique.", + ) + + parser.add_argument( + "--metadata", + type=Path, + required=True, + help=( + "Chemin vers le fichier " + "dataset_metadata.json." + ), + ) + + parser.add_argument( + "--source-timezone", + default="UTC", + help=( + "Timezone associée aux timestamps " + "du dataset. Défaut : UTC." + ), + ) + + parser.add_argument( + "--batch-size", + type=int, + default=1000, + help=( + "Nombre de lignes insérées " + "par batch. Défaut : 1000." + ), + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Valide les données sans " + "écrire en base." + ), + ) + + return parser.parse_args() + + +def main() -> None: + """Point d'entrée CLI du pipeline.""" + args = parse_args() + + if args.batch_size <= 0: + raise ValueError( + "--batch-size doit être " + "strictement supérieur à 0." + ) + + # resolve() est volontairement exécuté ici, + # dans la partie synchrone du programme. + # Cela évite une opération filesystem bloquante + # à l'intérieur d'une fonction async. + storage_uri = ( + args.csv.resolve().as_uri() + ) + + asyncio.run( + import_historical( + csv_path=args.csv, + metadata_path=args.metadata, + source_timezone=( + args.source_timezone + ), + batch_size=args.batch_size, + dry_run=args.dry_run, + storage_uri=storage_uri, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..6cf42a5 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "pandas>=3.0.5", ] [dependency-groups] diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..fb43797 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = "==3.14.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "alembic" @@ -311,6 +316,7 @@ dependencies = [ { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "pandas" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -337,6 +343,7 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, + { name = "pandas", specifier = ">=3.0.5" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, @@ -595,6 +602,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -604,6 +640,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +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" } +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" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -788,6 +853,18 @@ 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.3" @@ -857,6 +934,15 @@ wheels = [ { 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" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +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 = "sqlalchemy" version = "2.0.52" @@ -916,6 +1002,15 @@ 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + [[package]] name = "uvicorn" version = "0.53.0" diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep new file mode 100644 index 0000000..e69de29 From b2d52823bae33df93b2808f33556a36b4baad663 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Wed, 16 Sep 2026 14:16:22 +0200 Subject: [PATCH 097/205] fix(data): aligne l'import historique avec les contraintes BDD --- apps/backend/app/etl/historical_import.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py index e876c26..c3b9a70 100644 --- a/apps/backend/app/etl/historical_import.py +++ b/apps/backend/app/etl/historical_import.py @@ -39,7 +39,7 @@ MEASURE_COLUMNS = [ "solar_irradiance_wm2", ] -SOURCE_NAME = "historical_csv" +SOURCE_NAME = "csv" def compute_sha256(path: Path) -> str: @@ -435,11 +435,10 @@ def build_reading_batch( "data_quality": quality, "null_reasons": reasons, - # Aucune imputation pendant - # l'ingestion RAW. - "imputed_values": json.dumps( - {} - ), + # Aucune imputation pendant l'ingestion RAW. + # Les valeurs manquantes sont conservées telles quelles + # afin de préserver la donnée source. + "imputed_values": None, "imputation_method": None, # Conservation de la donnée source From ebb72fb39996cebaafa53e4c727e6ec25257d054 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Wed, 16 Sep 2026 14:29:22 +0200 Subject: [PATCH 098/205] test(data): couvre l'import historique --- .../tests/etl/test_historical_import.py | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 apps/backend/tests/etl/test_historical_import.py diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py new file mode 100644 index 0000000..f311b2d --- /dev/null +++ b/apps/backend/tests/etl/test_historical_import.py @@ -0,0 +1,244 @@ +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 From 74ac1b45778c27935d936a51d9460d15932874f3 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Thu, 17 Sep 2026 09:55:27 +0200 Subject: [PATCH 099/205] docs(data): documente le pipeline d'import historique --- docs/architecture/40-data.md | 70 +++++++ etl/README.md | 354 ++++++++++++++++++++++++++++++++++- 2 files changed, 417 insertions(+), 7 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 6566753..ffd5e6d 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -231,3 +231,73 @@ et ne sont pas considérées comme des alertes actuelles. - 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. + +## Ingestion des données historiques + +Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet. + +Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024. + +Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. + +### Architecture du flux + +```text +Dataset CSV + métadonnées JSON + | + v + historical_import.py + | + +------+------+ + | | + v v + Validation SHA-256 + | Traçabilité + +------+------+ + | + v + Normalisation + + qualité data + | + v + Chargement par batches + | + v + PostgreSQL / TimescaleDB + | | | + v v v + dataset site reading +``` + +Le pipeline est développé en Python. + +Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB. + +Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité. + +Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape. + +Le chargement des mesures est effectué par batches de 1 000 lignes. + +Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`. + +### Résultats validés + +Le chargement de référence a permis d'obtenir : + +- 1 dataset ; +- 7 sites ; +- 122 647 mesures ; +- 0 doublon détecté dans le dataset source. + +L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647. + +La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`. + +### Évolution prévue + +L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow. + +L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs. + +Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning. diff --git a/etl/README.md b/etl/README.md index cac0f3d..b835311 100644 --- a/etl/README.md +++ b/etl/README.md @@ -1,9 +1,349 @@ -# ETL +# Pipeline ETL — EnerVision -Orchestration Apache Airflow : ingestion des mesures, agregations continues, -controles de qualite. Non initialise, voir le ticket dedie. +## Objectif -- `airflow/dags` : DAGs. -- `airflow/plugins` : operateurs et hooks maison. -- `airflow/include` : requetes SQL et ressources referencees par les DAGs. -- `airflow/tests` : tests d'integrite des DAGs. +Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB. + +Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024. + +Le pipeline assure : + +- l'extraction des données sources ; +- la validation de leur structure et de leur cohérence ; +- la normalisation des données nécessaires au stockage ; +- le suivi de la qualité des données ; +- la traçabilité du dataset importé ; +- le chargement des données dans PostgreSQL/TimescaleDB ; +- l'idempotence du chargement afin d'éviter la création de doublons. + +## Données sources + +Le dataset est fourni par le formateur dans le cadre du projet EnerVision. + +Il contient les deux fichiers suivants : + +```text +all_sites_combined.csv +dataset_metadata.json +``` + +Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement. + +Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans : + +```text +data/raw/ +``` + +Structure locale attendue : + +```text +data/ +└── raw/ + ├── .gitkeep + ├── all_sites_combined.csv + └── dataset_metadata.json +``` + +Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git. + +## Technologies utilisées + +| Technologie | Utilisation | +|---|---| +| Python | Développement du pipeline ETL | +| Pandas | Lecture, validation et transformation des données | +| JSON | Lecture des métadonnées du dataset | +| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset | +| SQLAlchemy Async | Connexion et chargement asynchrone en base | +| PostgreSQL | Stockage relationnel | +| TimescaleDB | Stockage des séries temporelles énergétiques | +| Docker Compose | Exécution de l'environnement local | +| Alembic | Gestion des migrations du schéma | +| uv | Gestion et exécution de l'environnement Python | +| Ruff | Contrôle de la qualité du code | +| Pytest | Tests automatisés | + +## Fonctionnement du pipeline + +Le script principal d'import se trouve dans : + +```text +apps/backend/app/etl/historical_import.py +``` + +Le flux d'import est le suivant : + +```text +CSV + métadonnées JSON + | + v + Extraction + | + v + Validation + | + v + Traçabilité SHA-256 + | + v + Transformation + | + v + Chargement par batches + | + v +PostgreSQL / TimescaleDB +``` + +### 1. Extraction + +Le pipeline charge : + +- `all_sites_combined.csv` avec Pandas ; +- `dataset_metadata.json` avec le module JSON de Python. + +### 2. Validation + +Avant toute écriture en base, le pipeline contrôle notamment : + +- la présence des colonnes obligatoires ; +- le nombre de lignes ; +- la cohérence des identifiants des sites ; +- la cohérence des informations associées aux sites ; +- les doublons sur le couple `(site_id, timestamp)` ; +- les timestamps ; +- les valeurs manquantes. + +Une incohérence détectée pendant cette étape interrompt l'import avant le chargement. + +### 3. Dry-run + +Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL. + +Il permet notamment de vérifier : + +- le nombre de lignes ; +- le nombre de sites ; +- la période couverte ; +- les doublons ; +- les valeurs NULL ; +- l'empreinte SHA-256. + +### 4. Traçabilité + +Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé. + +Empreinte SHA-256 du dataset validé : + +```text +6E3777A97A5660B11855750B9028F70BE72138A11F26795F3A35D9CE74CE0C8D +``` + +Cette empreinte participe à la traçabilité du dataset chargé. + +### 5. Transformation + +Les timestamps sont normalisés avec la timezone : + +```text +UTC +``` + +Le pipeline détermine également la qualité des mesures à partir des données disponibles. + +Les valeurs manquantes sont conservées pendant cette phase afin de préserver la donnée source. + +Aucune imputation n'est réalisée pendant l'ingestion : + +```text +imputed_values = NULL +imputation_method = NULL +``` + +### 6. Chargement + +Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB. + +Les données sont enregistrées dans les tables : + +```text +dataset +site +reading +``` + +Les mesures sont chargées par batches de : + +```text +1000 lignes +``` + +Les mesures provenant du dataset CSV utilisent : + +```text +source = "csv" +dataset_id = identifiant du dataset +``` + +Cette représentation respecte les contraintes définies dans le schéma de la base. + +## Dataset validé + +Le dataset traité contient : + +- 122 647 mesures ; +- 7 sites ; +- une période du 01/01/2023 au 31/12/2024 ; +- 0 doublon détecté dans les données sources. + +Valeurs manquantes identifiées : + +| Variable | Nombre de valeurs NULL | +|---|---:| +| `consumption_kwh` | 2 840 | +| `consumption_euros` | 2 487 | +| `temperature_celsius` | 3 416 | +| `humidity_percent` | 3 423 | +| `solar_irradiance_wm2` | 3 964 | + +## Exécution en dry-run + +Depuis le dossier : + +```text +apps/backend/ +``` + +exécuter : + +```powershell +uv run python -m app.etl.historical_import ` + --csv ..\..\data\raw\all_sites_combined.csv ` + --metadata ..\..\data\raw\dataset_metadata.json ` + --source-timezone UTC ` + --dry-run +``` + +Aucune donnée n'est écrite dans la base pendant cette exécution. + +## Chargement réel + +Depuis `apps/backend/` : + +```powershell +uv run python -m app.etl.historical_import ` + --csv ..\..\data\raw\all_sites_combined.csv ` + --metadata ..\..\data\raw\dataset_metadata.json ` + --source-timezone UTC +``` + +Le chargement est effectué progressivement par batches. + +Exemple : + +```text +Chargement : 1000/122647 +Chargement : 2000/122647 +... +Chargement : 122647/122647 +``` + +## Résultats obtenus + +Après le chargement initial, les contrôles en base ont confirmé : + +```text +datasets = 1 +sites = 7 +readings = 122647 +source = csv +``` + +Le premier import a créé : + +```text +nouvelles lectures : 122647 +``` + +## Idempotence + +Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence. + +Résultat : + +```text +lectures avant : 122647 +lectures après : 122647 +nouvelles lectures : 0 +``` + +Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé. + +## Vérifications SQL + +Depuis la racine du projet, vérifier le nombre d'enregistrements avec : + +```powershell +docker compose exec db psql -U enervision -d enervision -c "SELECT COUNT(*) AS datasets FROM dataset; SELECT COUNT(*) AS sites FROM site; SELECT COUNT(*) AS readings FROM reading;" +``` + +Résultat attendu après l'import initial : + +```text +datasets = 1 +sites = 7 +readings = 122647 +``` + +Vérifier la source des mesures avec : + +```powershell +docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;" +``` + +Résultat attendu : + +```text +csv | 122647 +``` + +## Tests et qualité + +Les tests automatisés du pipeline sont situés dans : + +```text +apps/backend/tests/etl/ +``` + +Ils couvrent notamment : + +- la validation du dataset ; +- les colonnes obligatoires ; +- la détection des doublons ; +- la cohérence des sites ; +- la normalisation des timestamps ; +- la gestion des valeurs manquantes ; +- la classification de la qualité des données ; +- la construction des mesures destinées à la BDD ; +- le respect des contraintes du modèle de données. + +Exécuter les tests ETL : + +```powershell +uv run pytest tests\etl -v +``` + +Contrôler la qualité du code : + +```powershell +uv run ruff check app\etl tests\etl +``` + +## Suite du pipeline Data + +L'import historique constitue la première brique du pipeline Data EnerVision. + +La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning. + +Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL. \ No newline at end of file From f03dce5fe37225a022bd6e3f9077e0e33cdf43d4 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Thu, 17 Sep 2026 10:06:54 +0200 Subject: [PATCH 100/205] style(data): applique le formatage Ruff --- apps/backend/app/etl/historical_import.py | 292 ++++-------------- .../tests/etl/test_historical_import.py | 9 +- 2 files changed, 62 insertions(+), 239 deletions(-) diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py index c3b9a70..b4f2089 100644 --- a/apps/backend/app/etl/historical_import.py +++ b/apps/backend/app/etl/historical_import.py @@ -68,11 +68,7 @@ def classify_quality( Les valeurs NULL sont conservées. On ne cherche pas ici à déterminer la cause physique exacte de leur absence. """ - missing = [ - column - for column in MEASURE_COLUMNS - if pd.isna(row.get(column)) - ] + missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))] if not missing: quality = "good" @@ -83,10 +79,7 @@ def classify_quality( else: quality = "partial" - reasons = [ - f"missing:{column}" - for column in missing - ] + reasons = [f"missing:{column}" for column in missing] return quality, reasons @@ -96,57 +89,33 @@ def validate_source( metadata: dict[str, Any], ) -> None: """Valide le dataset avant tout chargement en base.""" - missing_columns = REQUIRED_COLUMNS.difference( - frame.columns - ) + missing_columns = REQUIRED_COLUMNS.difference(frame.columns) if missing_columns: - raise ValueError( - "Colonnes obligatoires absentes : " - f"{sorted(missing_columns)}" - ) + raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}") expected_records = int(metadata["total_records"]) if len(frame) != expected_records: - raise ValueError( - "Nombre de lignes inattendu : " - f"{len(frame)} au lieu de " - f"{expected_records}" - ) + raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}") expected_sites = set(metadata["sites"].keys()) actual_sites = set(frame["site_id"].unique()) if actual_sites != expected_sites: raise ValueError( - "Sites incohérents. " - f"Attendus={sorted(expected_sites)}, " - f"trouvés={sorted(actual_sites)}" + f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}" ) - duplicated = frame.duplicated( - subset=["site_id", "timestamp"] - ).sum() + duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum() if duplicated: - raise ValueError( - f"{duplicated} doublons " - "(site_id, timestamp) détectés" - ) + raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés") - static_variants = ( - frame.groupby("site_id")[ - ["site_type", "site_name"] - ] - .nunique() - ) + static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique() if (static_variants > 1).any().any(): - raise ValueError( - "Un site possède plusieurs valeurs " - "de site_type ou site_name." - ) + raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.") # Vérifie également que tous les timestamps # peuvent être interprétés correctement. @@ -168,9 +137,7 @@ def normalize_timestamps( """ normalized = frame.copy() - normalized["_source_timestamp"] = ( - normalized["timestamp"] - ) + normalized["_source_timestamp"] = normalized["timestamp"] timestamps = pd.to_datetime( normalized["timestamp"], @@ -178,13 +145,9 @@ def normalize_timestamps( ) if timestamps.dt.tz is None: - timestamps = timestamps.dt.tz_localize( - source_timezone - ) + timestamps = timestamps.dt.tz_localize(source_timezone) else: - timestamps = timestamps.dt.tz_convert( - source_timezone - ) + timestamps = timestamps.dt.tz_convert(source_timezone) normalized["timestamp"] = timestamps @@ -202,7 +165,7 @@ def to_json_value(value: Any) -> Any: try: if pd.isna(value): return None - except (TypeError, ValueError): + except TypeError, ValueError: pass if isinstance(value, pd.Timestamp): @@ -247,27 +210,13 @@ async def ensure_dataset( return int(existing) metadata_summary = { - "generator_version": metadata.get( - "generator_version" - ), - "total_sites": metadata.get( - "total_sites" - ), - "total_records": metadata.get( - "total_records" - ), - "date_range": metadata.get( - "date_range" - ), - "frequency": metadata.get( - "frequency" - ), - "null_injection_enabled": metadata.get( - "null_injection_enabled" - ), - "null_strategies": metadata.get( - "null_strategies" - ), + "generator_version": metadata.get("generator_version"), + "total_sites": metadata.get("total_sites"), + "total_records": metadata.get("total_records"), + "date_range": metadata.get("date_range"), + "frequency": metadata.get("frequency"), + "null_injection_enabled": metadata.get("null_injection_enabled"), + "null_strategies": metadata.get("null_strategies"), "importer": "historical_import_v1", } @@ -292,10 +241,7 @@ async def ensure_dataset( """ ), { - "dataset_name": ( - "EnerVision historical dataset " - "2023-2024" - ), + "dataset_name": ("EnerVision historical dataset 2023-2024"), "archive_sha256": sha256, "storage_uri": storage_uri, "source_timezone": source_timezone, @@ -322,12 +268,8 @@ async def upsert_sites( "site_name", ] ] - .drop_duplicates( - subset=["site_id"] - ) - .to_dict( - orient="records" - ) + .drop_duplicates(subset=["site_id"]) + .to_dict(orient="records") ) await connection.execute( @@ -363,12 +305,8 @@ def build_reading_batch( """ rows: list[dict[str, Any]] = [] - for record in chunk.to_dict( - orient="records" - ): - quality, reasons = classify_quality( - record - ) + for record in chunk.to_dict(orient="records"): + quality, reasons = classify_quality(record) raw_data = { column: to_json_value(value) @@ -378,9 +316,7 @@ def build_reading_batch( # Dans raw_data, on conserve le timestamp # exactement tel qu'il était dans le CSV. - raw_data["timestamp"] = to_json_value( - record["_source_timestamp"] - ) + raw_data["timestamp"] = to_json_value(record["_source_timestamp"]) rows.append( { @@ -388,59 +324,25 @@ def build_reading_batch( "timestamp": record["timestamp"], "source": SOURCE_NAME, "dataset_id": dataset_id, - # Non fourni par le dataset historique. "consumption_kw": None, - - "consumption_kwh": to_json_value( - record["consumption_kwh"] - ), - "consumption_euros": to_json_value( - record["consumption_euros"] - ), - + "consumption_kwh": to_json_value(record["consumption_kwh"]), + "consumption_euros": to_json_value(record["consumption_euros"]), # Non fournis par le CSV historique. "voltage_v": None, "current_a": None, "power_factor": None, - - "temperature_celsius": ( - to_json_value( - record[ - "temperature_celsius" - ] - ) - ), - "humidity_percent": ( - to_json_value( - record[ - "humidity_percent" - ] - ) - ), - "solar_irradiance_wm2": ( - to_json_value( - record[ - "solar_irradiance_wm2" - ] - ) - ), - - "is_working_hours": bool( - record[ - "is_working_hours" - ] - ), - + "temperature_celsius": (to_json_value(record["temperature_celsius"])), + "humidity_percent": (to_json_value(record["humidity_percent"])), + "solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])), + "is_working_hours": bool(record["is_working_hours"]), "data_quality": quality, "null_reasons": reasons, - # Aucune imputation pendant l'ingestion RAW. # Les valeurs manquantes sont conservées telles quelles # afin de préserver la donnée source. "imputed_values": None, "imputation_method": None, - # Conservation de la donnée source # pour la traçabilité. "raw_data": json.dumps( @@ -519,56 +421,29 @@ async def import_historical( 3. Transform 4. Load """ - metadata = load_metadata( - metadata_path - ) + metadata = load_metadata(metadata_path) - frame = pd.read_csv( - csv_path - ) + frame = pd.read_csv(csv_path) validate_source( frame, metadata, ) - print( - f"Lignes : {len(frame)}" - ) - print( - "Sites : " - f"{frame['site_id'].nunique()}" - ) - print( - "Période : " - f"{frame['timestamp'].min()} -> " - f"{frame['timestamp'].max()}" - ) - print( - "Doublons : " - f"{frame.duplicated(['site_id', 'timestamp']).sum()}" - ) + print(f"Lignes : {len(frame)}") + print(f"Sites : {frame['site_id'].nunique()}") + print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}") + print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}") print("\nValeurs NULL :") - print( - frame[ - MEASURE_COLUMNS - ].isna().sum() - ) + print(frame[MEASURE_COLUMNS].isna().sum()) - sha256 = compute_sha256( - csv_path - ) + sha256 = compute_sha256(csv_path) - print( - f"\nSHA-256 : {sha256}" - ) + print(f"\nSHA-256 : {sha256}") if dry_run: - print( - "\nDry-run terminé : " - "aucune donnée écrite." - ) + print("\nDry-run terminé : aucune donnée écrite.") return normalized = normalize_timestamps( @@ -613,18 +488,14 @@ async def import_historical( }, ) - before = int( - result.scalar_one() - ) + before = int(result.scalar_one()) for start in range( 0, len(normalized), batch_size, ): - chunk = normalized.iloc[ - start : start + batch_size - ] + chunk = normalized.iloc[start : start + batch_size] rows = build_reading_batch( chunk, @@ -641,11 +512,7 @@ async def import_historical( len(normalized), ) - print( - "Chargement : " - f"{loaded}/" - f"{len(normalized)}" - ) + print(f"Chargement : {loaded}/{len(normalized)}") result = await connection.execute( text( @@ -662,29 +529,13 @@ async def import_historical( }, ) - after = int( - result.scalar_one() - ) + after = int(result.scalar_one()) - print( - "\nImport terminé." - ) - print( - "dataset_id : " - f"{dataset_id}" - ) - print( - "lectures avant : " - f"{before}" - ) - print( - "lectures après : " - f"{after}" - ) - print( - "nouvelles lectures : " - f"{after - before}" - ) + print("\nImport terminé.") + print(f"dataset_id : {dataset_id}") + print(f"lectures avant : {before}") + print(f"lectures après : {after}") + print(f"nouvelles lectures : {after - before}") finally: await engine.dispose() @@ -692,11 +543,7 @@ async def import_historical( def parse_args() -> argparse.Namespace: """Définit les arguments CLI de l'import.""" - parser = argparse.ArgumentParser( - description=( - "Import historique EnerVision" - ) - ) + parser = argparse.ArgumentParser(description=("Import historique EnerVision")) parser.add_argument( "--csv", @@ -709,38 +556,26 @@ def parse_args() -> argparse.Namespace: "--metadata", type=Path, required=True, - help=( - "Chemin vers le fichier " - "dataset_metadata.json." - ), + help=("Chemin vers le fichier dataset_metadata.json."), ) parser.add_argument( "--source-timezone", default="UTC", - help=( - "Timezone associée aux timestamps " - "du dataset. Défaut : UTC." - ), + help=("Timezone associée aux timestamps du dataset. Défaut : UTC."), ) parser.add_argument( "--batch-size", type=int, default=1000, - help=( - "Nombre de lignes insérées " - "par batch. Défaut : 1000." - ), + help=("Nombre de lignes insérées par batch. Défaut : 1000."), ) parser.add_argument( "--dry-run", action="store_true", - help=( - "Valide les données sans " - "écrire en base." - ), + help=("Valide les données sans écrire en base."), ) return parser.parse_args() @@ -751,26 +586,19 @@ def main() -> None: args = parse_args() if args.batch_size <= 0: - raise ValueError( - "--batch-size doit être " - "strictement supérieur à 0." - ) + raise ValueError("--batch-size doit être strictement supérieur à 0.") # resolve() est volontairement exécuté ici, # dans la partie synchrone du programme. # Cela évite une opération filesystem bloquante # à l'intérieur d'une fonction async. - storage_uri = ( - args.csv.resolve().as_uri() - ) + storage_uri = args.csv.resolve().as_uri() asyncio.run( import_historical( csv_path=args.csv, metadata_path=args.metadata, - source_timezone=( - args.source_timezone - ), + source_timezone=(args.source_timezone), batch_size=args.batch_size, dry_run=args.dry_run, storage_uri=storage_uri, diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py index f311b2d..31f6e2d 100644 --- a/apps/backend/tests/etl/test_historical_import.py +++ b/apps/backend/tests/etl/test_historical_import.py @@ -104,9 +104,7 @@ def test_validate_source_accepts_valid_dataset(): def test_validate_source_rejects_missing_column(): - frame = make_dataframe().drop( - columns=["consumption_kwh"] - ) + frame = make_dataframe().drop(columns=["consumption_kwh"]) with pytest.raises( ValueError, @@ -234,10 +232,7 @@ def test_build_reading_batch_keeps_missing_values(): assert row["temperature_celsius"] is None - assert ( - "missing:temperature_celsius" - in row["null_reasons"] - ) + assert "missing:temperature_celsius" in row["null_reasons"] # RAW ingestion : aucune imputation. assert row["imputed_values"] is None From 6798d355722cf0520888ee08aa648c2bc3941e35 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Thu, 17 Sep 2026 10:41:18 +0200 Subject: [PATCH 101/205] fix(data): corrige le typage de l'import historique --- apps/backend/app/etl/historical_import.py | 21 ++++++++++++++++----- apps/backend/pyproject.toml | 1 + apps/backend/uv.lock | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py index b4f2089..22d9b03 100644 --- a/apps/backend/app/etl/historical_import.py +++ b/apps/backend/app/etl/historical_import.py @@ -5,7 +5,7 @@ import asyncio import hashlib import json from pathlib import Path -from typing import Any +from typing import Any, cast import pandas as pd from sqlalchemy import text @@ -56,7 +56,12 @@ def compute_sha256(path: Path) -> str: def load_metadata(path: Path) -> dict[str, Any]: """Charge les métadonnées fournies avec le dataset.""" with path.open("r", encoding="utf-8") as source: - return json.load(source) + metadata = json.load(source) + + if not isinstance(metadata, dict): + raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.") + + return cast(dict[str, Any], metadata) def classify_quality( @@ -260,7 +265,8 @@ async def upsert_sites( frame: pd.DataFrame, ) -> None: """Insère ou met à jour les sites du dataset.""" - sites = ( + sites = cast( + list[dict[str, Any]], frame[ [ "site_id", @@ -269,7 +275,7 @@ async def upsert_sites( ] ] .drop_duplicates(subset=["site_id"]) - .to_dict(orient="records") + .to_dict(orient="records"), ) await connection.execute( @@ -305,7 +311,12 @@ def build_reading_batch( """ rows: list[dict[str, Any]] = [] - for record in chunk.to_dict(orient="records"): + records = cast( + list[dict[str, Any]], + chunk.to_dict(orient="records"), + ) + + for record in records: quality, reasons = classify_quality(record) raw_data = { diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 6cf42a5..c330c8d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "httpx>=0.28.1", + "pandas-stubs>=3.0.5.260914", ] [build-system] diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index fb43797..6836990 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -330,6 +330,7 @@ dependencies = [ dev = [ { name = "httpx" }, { name = "mypy" }, + { name = "pandas-stubs" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -357,6 +358,7 @@ requires-dist = [ dev = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=2.3.1" }, + { name = "pandas-stubs", specifier = ">=3.0.5.260914" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -669,6 +671,18 @@ wheels = [ { 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" }, ] +[[package]] +name = "pandas-stubs" +version = "3.0.5.260914" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" From 9161b74874a13b0b17df9eca9679c2120362e683 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 10:53:58 +0200 Subject: [PATCH 102/205] feat(auth): politique de complexite du mot de passe et flux de reinitialisation Remplace la regle de longueur seule (12 caracteres) par une exigence de composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere special), non documentee dans les exigences officielles du projet, par une regle explicite partagee entre le backend (validateur Pydantic) et le frontend. Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici : jeton a usage unique hache en base (meme principe que les refresh tokens), expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib, Mailpit en dev), avec limitation de debit dediee et reponse generique pour eviter l'enumeration des comptes. Closes #87 --- apps/backend/.env.example | 10 + apps/backend/README.md | 2 + ...c0adab96238c_jetons_de_reinitialisation.py | 96 ++++++++++ apps/backend/app/api/deps.py | 31 +++- apps/backend/app/api/openapi.py | 13 ++ apps/backend/app/api/v1/endpoints/auth.py | 83 +++++++++ apps/backend/app/cli.py | 31 +++- apps/backend/app/core/config.py | 13 ++ apps/backend/app/core/mailer.py | 48 +++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/audit_log.py | 2 + .../app/models/password_reset_attempt.py | 27 +++ .../app/models/password_reset_token.py | 40 ++++ .../repositories/password_reset_attempt.py | 42 +++++ .../app/repositories/password_reset_token.py | 68 +++++++ apps/backend/app/schemas/auth.py | 45 ++++- apps/backend/app/services/auth.py | 110 +++++++++++ apps/backend/openapi.json | 175 +++++++++++++++++- apps/backend/pyproject.toml | 1 + apps/backend/tests/api/test_auth.py | 100 ++++++++++ .../tests/api/test_route_protection.py | 4 + .../repositories/test_password_reset_token.py | 114 ++++++++++++ apps/backend/tests/schemas/__init__.py | 0 apps/backend/tests/schemas/test_auth.py | 41 ++++ apps/backend/tests/services/test_auth.py | 158 +++++++++++++++- apps/backend/tests/test_cli.py | 19 +- apps/backend/uv.lock | 11 ++ apps/frontend/src/app/app.routes.ts | 2 + .../src/app/core/services/auth.service.ts | 19 +- .../auth/change-password/change-password.html | 2 +- .../change-password/change-password.spec.ts | 17 +- .../auth/change-password/change-password.ts | 6 +- .../auth/forgot-password/forgot-password.html | 37 ++++ .../auth/forgot-password/forgot-password.scss | 104 +++++++++++ .../forgot-password/forgot-password.spec.ts | 75 ++++++++ .../auth/forgot-password/forgot-password.ts | 53 ++++++ .../src/app/features/auth/login/login.html | 2 + .../src/app/features/auth/login/login.scss | 10 + .../src/app/features/auth/login/login.spec.ts | 3 +- .../src/app/features/auth/login/login.ts | 4 +- .../auth/reset-password/reset-password.html | 30 +++ .../auth/reset-password/reset-password.scss | 104 +++++++++++ .../reset-password/reset-password.spec.ts | 74 ++++++++ .../auth/reset-password/reset-password.ts | 52 ++++++ .../src/app/shared/models/auth.model.ts | 9 + .../shared/validators/password.validator.ts | 15 ++ docker-compose.yml | 16 ++ .../31-contrat-authentification.md | 17 +- 48 files changed, 1914 insertions(+), 25 deletions(-) create mode 100644 apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py create mode 100644 apps/backend/app/core/mailer.py create mode 100644 apps/backend/app/models/password_reset_attempt.py create mode 100644 apps/backend/app/models/password_reset_token.py create mode 100644 apps/backend/app/repositories/password_reset_attempt.py create mode 100644 apps/backend/app/repositories/password_reset_token.py create mode 100644 apps/backend/tests/repositories/test_password_reset_token.py create mode 100644 apps/backend/tests/schemas/__init__.py create mode 100644 apps/backend/tests/schemas/test_auth.py create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.html create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.html create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.scss create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.ts create mode 100644 apps/frontend/src/app/shared/validators/password.validator.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f36551e..8dff67f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision + +# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut. +APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password + +# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS. +# À remplacer par un vrai relais en staging/prod. +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USE_TLS=false +APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr diff --git a/apps/backend/README.md b/apps/backend/README.md index 91f9608..6c48b3a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public | +| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public | | `/api/v1/auth/me` | Décrit le compte connecté | jeton | | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | diff --git a/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py new file mode 100644 index 0000000..7f75d21 --- /dev/null +++ b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py @@ -0,0 +1,96 @@ +"""jetons et tentatives de reinitialisation de mot de passe + +Revision ID: c0adab96238c +Revises: e6d2026091501 +Create Date: 2026-09-17 10:37:12.571314 + +Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du +jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade, +aucune session utilisable dans un pg_dump qui fuiterait). + +`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est +pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout +seul, ne devrait jamais purger. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c0adab96238c" +down_revision: str | Sequence[str] | None = "e6d2026091501" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +JETONS_VIVANTS = "consumed_at is null" + + +def upgrade() -> None: + op.create_table( + "password_reset_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"), + ) + op.create_index( + "ix_password_reset_attempt_email_date", + "password_reset_attempt", + ["email_tried", "occurred_at"], + ) + op.create_index( + "ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"] + ) + + op.create_table( + "password_reset_token", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name="fk_password_reset_token_user", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"), + sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"), + ) + op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"]) + op.create_index( + "ix_password_reset_token_vivants", + "password_reset_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_password_reset_token_vivants", + table_name="password_reset_token", + postgresql_where=JETONS_VIVANTS, + ) + op.drop_index("ix_password_reset_token_user", table_name="password_reset_token") + op.drop_table("password_reset_token") + op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt") + op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt") + op.drop_table("password_reset_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 5b39098..f1aa8ae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings from app.core.hashing import Argon2Hasher, build_hasher +from app.core.mailer import Mailer, SmtpConfig from app.core.principal import Principal from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy @@ -24,13 +25,15 @@ from app.db.session import get_session from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.reading import ReadingRepository from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService -from app.services.auth import AuthService, LoginPolicy +from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.recommendation import RecommendationService from app.services.sensor import SensorService from app.services.site import SiteService @@ -97,11 +100,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None: return request.client.host if request.client else None +def get_mailer(settings: SettingsDep) -> Mailer: + return Mailer( + SmtpConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=( + settings.smtp_password.get_secret_value() if settings.smtp_password else None + ), + use_tls=settings.smtp_use_tls, + from_address=settings.smtp_from_address, + ) + ) + + def get_auth_service( session: SessionDep, settings: SettingsDep, hasher: Annotated[Argon2Hasher, Depends(get_hasher)], token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], + mailer: Annotated[Mailer, Depends(get_mailer)], ) -> AuthService: return AuthService( users=UserRepository(session), @@ -118,6 +137,16 @@ def get_auth_service( max_failures_per_identifier=settings.login_max_failures_per_identifier, ), refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), + reset_tokens=PasswordResetTokenRepository(session), + reset_attempts=PasswordResetAttemptRepository(session), + reset_policy=PasswordResetPolicy( + window_seconds=settings.password_reset_window_seconds, + max_requests_per_identifier=settings.password_reset_max_requests_per_identifier, + max_requests_per_ip=settings.password_reset_max_requests_per_ip, + token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds), + frontend_reset_url=settings.frontend_reset_password_url, + ), + mailer=mailer, ) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 85b7775..a649705 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -156,3 +156,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", }, } + +REPONSE_LIMITE: Final[Reponses] = { + 429: { + "model": ErrorResponse, + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 32bf8b2..957775f 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_LIMITE, REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, @@ -21,15 +22,18 @@ from app.api.openapi import ( from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( + ForgotPasswordRequest, LoginRequest, PasswordChangeRequest, PrincipalResponse, + ResetPasswordRequest, TokenResponse, ) from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -39,6 +43,7 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +DETAIL_LIEN_RESET = "Lien invalide ou expiré" REPONSES_LOGIN: Reponses = { **REPONSE_VALIDATION, @@ -85,6 +90,20 @@ REPONSES_MOT_DE_PASSE: Reponses = { }, } +REPONSES_FORGOT_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_LIMITE, +} + +REPONSES_RESET_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, + 400: { + "model": ErrorResponse, + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + }, +} + def repond( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -267,3 +286,67 @@ async def change_password( logger.info("auth.password_changed user_id=%s", principal.id) return repond(response, settings, session) + + +@router.post( + "/forgot-password", + status_code=status.HTTP_202_ACCEPTED, + summary="Demande un lien de réinitialisation par email", + responses=REPONSES_FORGOT_PASSWORD, +) +async def forgot_password( + payload: ForgotPasswordRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> None: + response.headers["Cache-Control"] = "no-store" + + try: + await service.request_password_reset( + email=payload.email, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except RateLimitedError as erreur: + logger.warning("auth.password_reset.rate_limited ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de demandes, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + + +@router.post( + "/reset-password", + response_model=TokenResponse, + summary="Choisit un nouveau mot de passe depuis un lien reçu par email", + dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_RESET_PASSWORD, +) +async def reset_password( + payload: ResetPasswordRequest, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.confirm_password_reset( + token=payload.token, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidOrExpiredResetTokenError as erreur: + logger.warning("auth.password_reset.invalid_token ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET + ) from erreur + + logger.info("auth.password_reset.success user_id=%s", session.principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 37e94fd..fea510e 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -9,6 +9,7 @@ import argparse import asyncio import json import secrets +import string import sys from getpass import getpass from pathlib import Path @@ -22,9 +23,10 @@ from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app from app.repositories.user import UserRepository +from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite LONGUEUR_MOT_DE_PASSE_GENERE = 24 -LONGUEUR_MINIMALE = 12 +CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?" CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -111,15 +113,36 @@ def build_parser() -> argparse.ArgumentParser: return parser +def genere_mot_de_passe() -> str: + tirage = secrets.SystemRandom() + classes = [ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + CARACTERES_SPECIAUX, + ] + reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes) + caracteres = [tirage.choice(classe) for classe in classes] + caracteres += [tirage.choice("".join(classes)) for _ in range(reste)] + tirage.shuffle(caracteres) + return "".join(caracteres) + + def read_password(*, generate: bool) -> str: if generate: - mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + mot_de_passe = genere_mot_de_passe() print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") return mot_de_passe mot_de_passe = getpass("Mot de passe : ") - if len(mot_de_passe) < LONGUEUR_MINIMALE: - raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if len(mot_de_passe) < PASSWORD_MIN_LENGTH: + raise SystemExit( + f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères" + ) + try: + valide_complexite(mot_de_passe) + except ValueError as erreur: + raise SystemExit(str(erreur)) from erreur if mot_de_passe != getpass("Confirmation : "): raise SystemExit("Les deux saisies diffèrent") return mot_de_passe diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 6733b3a..e374709 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -54,6 +54,19 @@ class Settings(BaseSettings): login_max_failures_per_ip: int = Field(default=20, ge=1) login_max_failures_per_identifier: int = Field(default=50, ge=1) + password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600) + password_reset_window_seconds: int = Field(default=900, ge=60) + password_reset_max_requests_per_identifier: int = Field(default=3, ge=1) + password_reset_max_requests_per_ip: int = Field(default=10, ge=1) + + smtp_host: str = "localhost" + smtp_port: int = Field(default=587, ge=1, le=65535) + smtp_username: str | None = None + smtp_password: SecretStr | None = None + smtp_use_tls: bool = False + smtp_from_address: str = "no-reply@enervision.fr" + frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105 + trust_proxy_headers: bool = False expose_api_docs: bool | None = None metrics_token: SecretStr | None = None diff --git a/apps/backend/app/core/mailer.py b/apps/backend/app/core/mailer.py new file mode 100644 index 0000000..5c09008 --- /dev/null +++ b/apps/backend/app/core/mailer.py @@ -0,0 +1,48 @@ +# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser : +# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`. + +from dataclasses import dataclass +from email.message import EmailMessage + +import aiosmtplib + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class SmtpConfig: + host: str + port: int + username: str | None + password: str | None + use_tls: bool + from_address: str + + +class Mailer: + def __init__(self, config: SmtpConfig) -> None: + self._config = config + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + message = EmailMessage() + message["From"] = self._config.from_address + message["To"] = to + message["Subject"] = "Réinitialisation de votre mot de passe EnerVision" + message.set_content( + "Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n" + f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : " + f"{reset_url}\n\n" + "Si vous n'êtes pas à l'origine de cette demande, ignorez cet email." + ) + + _, message_recu = await aiosmtplib.send( + message, + hostname=self._config.host, + port=self._config.port, + username=self._config.username, + password=self._config.password, + use_tls=self._config.use_tls, + ) + logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 10a5ecb..167d7ce 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -4,6 +4,8 @@ from app.models.audit_log import AuditLog from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.login_attempt import LoginAttempt +from app.models.password_reset_attempt import PasswordResetAttempt +from app.models.password_reset_token import PasswordResetToken from app.models.refresh_token import RefreshToken from app.models.user import AppUser @@ -13,6 +15,8 @@ __all__ = [ "AuditLog", "Dataset", "LoginAttempt", + "PasswordResetAttempt", + "PasswordResetToken", "Prediction", "Reading", "Recommendation", diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py index 5775f5e..d389880 100644 --- a/apps/backend/app/models/audit_log.py +++ b/apps/backend/app/models/audit_log.py @@ -29,6 +29,8 @@ class AuditAction(StrEnum): COMPTE_ACTIVE = "user.enabled" COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested" + MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service" REFRESH_REUTILISE = "auth.refresh_reuse_detected" SESSIONS_REVOQUEES = "auth.all_sessions_revoked" LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" diff --git a/apps/backend/app/models/password_reset_attempt.py b/apps/backend/app/models/password_reset_attempt.py new file mode 100644 index 0000000..6d2a607 --- /dev/null +++ b/apps/backend/app/models/password_reset_attempt.py @@ -0,0 +1,27 @@ +# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est +# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait +# qu'un compte existe. + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetAttempt(Base): + __tablename__ = "password_reset_attempt" + __table_args__ = ( + Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) diff --git a/apps/backend/app/models/password_reset_token.py b/apps/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d67d310 --- /dev/null +++ b/apps/backend/app/models/password_reset_token.py @@ -0,0 +1,40 @@ +# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même +# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure +# dans aucune ligne à invalider. + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_token" + __table_args__ = ( + Index("ix_password_reset_token_user", "user_id"), + Index( + "ix_password_reset_token_vivants", + "user_id", + postgresql_where="consumed_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/password_reset_attempt.py b/apps/backend/app/repositories/password_reset_attempt.py new file mode 100644 index 0000000..ddc2f91 --- /dev/null +++ b/apps/backend/app/repositories/password_reset_attempt.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_attempt import PasswordResetAttempt + + +@dataclass(frozen=True, slots=True) +class ResetRequestCounts: + per_identifier: int + per_ip: int + + +class PasswordResetAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record(self, *, email: str, client_ip: str | None) -> None: + self._session.add( + PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip) + ) + + async def count_recent( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> ResetRequestCounts: + identifiant = email.strip().lower() + meme_email = PasswordResetAttempt.email_tried == identifiant + meme_ip = PasswordResetAttempt.client_ip == client_ip + + requete = select( + func.count().filter(meme_email), + func.count().filter(meme_ip), + ).where( + PasswordResetAttempt.occurred_at + > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant, par_ip = (await self._session.execute(requete)).one() + return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip) diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py new file mode 100644 index 0000000..13a660e --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -0,0 +1,68 @@ +# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du +# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux +# soumissions concurrentes du même lien réussiraient toutes les deux. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_token import PasswordResetToken + + +@dataclass(frozen=True, slots=True) +class ConsumedResetToken: + id: UUID + user_id: UUID + + +class PasswordResetTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> PasswordResetToken: + jeton = PasswordResetToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + requete = ( + update(PasswordResetToken) + .where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.consumed_at.is_(None), + PasswordResetToken.expires_at > func.clock_timestamp(), + ) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id, PasswordResetToken.user_id) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id) + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + resultat = await self._session.execute( + update(PasswordResetToken) + .where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None)) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 522b4c5..e6785be 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -1,17 +1,39 @@ # Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix # mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. +import re from typing import Literal, Self from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from app.core.principal import Principal from app.core.roles import AccountKind, Role -PASSWORD_MIN_LENGTH = 12 +PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = 128 +_MAJUSCULE = re.compile(r"[A-ZÀ-Ý]") +_MINUSCULE = re.compile(r"[a-zà-ÿ]") +_CHIFFRE = re.compile(r"\d") +_SPECIAL = re.compile(r"[^\w\s]") + + +def valide_complexite(mot_de_passe: str) -> str: + manquants = [ + nom + for nom, motif in ( + ("une majuscule", _MAJUSCULE), + ("une minuscule", _MINUSCULE), + ("un chiffre", _CHIFFRE), + ("un caractère spécial", _SPECIAL), + ) + if not motif.search(mot_de_passe) + ] + if manquants: + raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}") + return mot_de_passe + class LoginRequest(BaseModel): email: EmailStr @@ -22,6 +44,25 @@ class PasswordChangeRequest(BaseModel): current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str = Field(min_length=1) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + class PrincipalResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 8baf857..02ff04b 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -15,6 +15,7 @@ from typing import NoReturn, Protocol from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher +from app.core.mailer import Mailer from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.core.security import ( @@ -28,6 +29,8 @@ from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -54,6 +57,10 @@ class RateLimitedError(AuthError): self.retry_after = retry_after +class InvalidOrExpiredResetTokenError(AuthError): + pass + + @dataclass(frozen=True, slots=True) class LoginPolicy: window_seconds: int @@ -62,6 +69,15 @@ class LoginPolicy: max_failures_per_identifier: int +@dataclass(frozen=True, slots=True) +class PasswordResetPolicy: + window_seconds: int + max_requests_per_identifier: int + max_requests_per_ip: int + token_ttl: timedelta + frontend_reset_url: str + + @dataclass(frozen=True, slots=True) class AuthenticatedSession: principal: Principal @@ -83,6 +99,10 @@ class AuthService: token_policy: TokenPolicy, login_policy: LoginPolicy, refresh_ttl: timedelta, + reset_tokens: PasswordResetTokenRepository, + reset_attempts: PasswordResetAttemptRepository, + reset_policy: PasswordResetPolicy, + mailer: Mailer, ) -> None: self._users = users self._attempts = attempts @@ -93,6 +113,10 @@ class AuthService: self._token_policy = token_policy self._login_policy = login_policy self._refresh_ttl = refresh_ttl + self._reset_tokens = reset_tokens + self._reset_attempts = reset_attempts + self._reset_policy = reset_policy + self._mailer = mailer async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -200,6 +224,75 @@ class AuthService: rafraichi = await self._users.get_by_id(principal.id) return self._session(self._en_principal(rafraichi or compte), secret) + async def request_password_reset( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + await self._refuse_si_limite_reset(email=email, client_ip=client_ip) + + compte = await self._users.get_by_email(email) + # Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme + # `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte + # inconnu, compte inactif, ou email envoyé avec succès. + if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._hasher.verify_dummy() + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + return + + await self._reset_tokens.invalidate_all_for_user(compte.id) + secret = generate_refresh_secret() + await self._reset_tokens.create( + user_id=compte.id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._reset_policy.token_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE, + actor_label=compte.email, + target_type="app_user", + target_id=str(compte.id), + client_ip=client_ip, + user_agent=user_agent, + ) + await self._transaction.commit() + + lien = f"{self._reset_policy.frontend_reset_url}?token={secret}" + await self._mailer.send_password_reset_email(to=compte.email, reset_url=lien) + + async def confirm_password_reset( + self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + revendique = await self._reset_tokens.consume(fingerprint_refresh(token)) + if revendique is None: + raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + await self._users.update_password( + revendique.user_id, await self._hasher.hash(new_password), must_change_password=False + ) + revoquees = await self._refresh.revoke_all_for_user( + revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI, + target_type="app_user", + target_id=str(revendique.user_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None: + raise SessionRejectedError("Compte introuvable") + return self._session(self._en_principal(compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION @@ -307,6 +400,23 @@ class AuthService: await self._transaction.commit() raise RateLimitedError(politique.window_seconds) + async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None: + politique = self._reset_policy + compteurs = await self._reset_attempts.count_recent( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier >= politique.max_requests_per_identifier + or compteurs.per_ip >= politique.max_requests_per_ip + ) + if not depasse: + return + + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + async def _echoue( self, email: str, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 84f9c08..f142efd 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -424,6 +424,144 @@ ] } }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Demande un lien de réinitialisation par email", + "operationId": "forgot_password_api_v1_auth_forgot_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "429": { + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/reset-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Choisit un nouveau mot de passe depuis un lien reçu par email", + "operationId": "reset_password_api_v1_auth_reset_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/users": { "get": { "tags": [ @@ -1432,6 +1570,20 @@ ], "title": "FieldError" }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, "InternalErrorResponse": { "properties": { "detail": { @@ -1511,7 +1663,7 @@ "new_password": { "type": "string", "maxLength": 128, - "minLength": 12, + "minLength": 8, "title": "New Password" } }, @@ -1619,6 +1771,27 @@ ], "title": "RecommendationResponse" }, + "ResetPasswordRequest": { + "properties": { + "token": { + "type": "string", + "minLength": 1, + "title": "Token" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "token", + "new_password" + ], + "title": "ResetPasswordRequest" + }, "Role": { "type": "string", "enum": [ diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..2bfdef3 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "aiosmtplib>=5.1.3", ] [dependency-groups] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 1d734da..44c25e6 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -36,6 +37,14 @@ class FauxService: async def logout(self, **_: object) -> None: return None + async def request_password_reset(self, **_: object) -> None: + if self._erreur is not None: + raise self._erreur + return None + + async def confirm_password_reset(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur @@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin( response = await client.post("/api/v1/auth/logout") assert response.status_code != 403 + + +async def test_forgot_password_answers_202_when_the_account_exists( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 202 + assert response.headers["cache-control"] == "no-store" + + +async def test_forgot_password_answers_202_identically_when_the_account_is_unknown( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"} + ) + + assert response.status_code == 202 + + +async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +async def test_forgot_password_rejects_a_malformed_email( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"}) + + assert response.status_code == 422 + + +async def test_reset_password_returns_the_token_and_the_cookie_on_success( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 200 + assert response.cookies.get("ev_refresh") is not None + assert "refresh_secret" not in response.text + + +async def test_reset_password_rejects_an_invalid_or_expired_token( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 400 + + +async def test_reset_password_rejects_a_weak_password( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "trop-simple"}, + ) + + assert response.status_code == 422 + + +async def test_reset_password_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + headers={"Origin": "https://malveillant.example"}, + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 9a04338..1080dce 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset( ("POST", "/api/v1/auth/login"), # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..e25518c --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -0,0 +1,114 @@ +# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions +# concurrentes du même lien réussiraient toutes les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(minutes=15) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE +) -> str: + secret = generate_refresh_secret() + await depot.create( + user_id=user_id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret + + +async def test_consume_only_succeeds_once(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + + premier = await depot.consume(fingerprint_refresh(secret)) + second = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_consume_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await PasswordResetTokenRepository(session).consume( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_invalidate_all_for_user_only_touches_living_tokens( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + invalides = await depot.invalidate_all_for_user(compte) + second_passage = await depot.invalidate_all_for_user(compte) + await session.rollback() + + assert invalides == 2 + assert second_passage == 0 + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/schemas/__init__.py b/apps/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py new file mode 100644 index 0000000..7982f56 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from app.schemas.auth import PasswordChangeRequest, valide_complexite + +MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!" + + +def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None: + requete = PasswordChangeRequest( + current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE + ) + + assert requete.new_password == MOT_DE_PASSE_VALIDE + + +@pytest.mark.parametrize( + "new_password", + [ + "un-mot-de-passe1!", + "UN-MOT-DE-PASSE1!", + "Un-mot-de-passe!", + "Un mot de passe 1", + ], + ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"], +) +def test_password_change_request_rejects_a_password_missing_a_character_class( + new_password: str, +) -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password=new_password) + + +def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!") + + +def test_valide_complexite_names_every_missing_class_in_the_error() -> None: + with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"): + valide_complexite("minuscules-seulement") diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 9b8c42c..52cde71 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -16,11 +16,15 @@ from app.core.security import ( from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.password_reset_attempt import ResetRequestCounts +from app.repositories.password_reset_token import ConsumedResetToken from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, LoginPolicy, + PasswordResetPolicy, RateLimitedError, SessionRejectedError, ) @@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy( max_failures_per_ip=20, max_failures_per_identifier=50, ) +POLITIQUE_RESET = PasswordResetPolicy( + window_seconds=900, + max_requests_per_identifier=3, + max_requests_per_ip=10, + token_ttl=timedelta(minutes=15), + frontend_reset_url="http://localhost:4200/reset-password", +) @dataclass @@ -168,6 +179,43 @@ class FausseTransaction: self.validations += 1 +class FauxDepotJetonsReset: + def __init__(self, revendique: ConsumedResetToken | None = None) -> None: + self.revendique = revendique + self.crees: list[UUID] = [] + self.invalidations: list[UUID] = [] + + async def create(self, *, user_id: UUID, **_: object) -> None: + self.crees.append(user_id) + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + return self.revendique + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + self.invalidations.append(user_id) + return len(self.invalidations) + + +class FauxDepotTentativesReset: + def __init__(self, compteurs: ResetRequestCounts | None = None) -> None: + self.compteurs = compteurs or ResetRequestCounts(0, 0) + self.enregistrees: list[str] = [] + + async def count_recent(self, **_: object) -> ResetRequestCounts: + return self.compteurs + + async def record(self, *, email: str, **_: object) -> None: + self.enregistrees.append(email) + + +class FauxMailer: + def __init__(self) -> None: + self.envois: list[tuple[str, str]] = [] + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + self.envois.append((to, reset_url)) + + @dataclass class Attirail: service: AuthService @@ -176,6 +224,9 @@ class Attirail: jetons: FauxDepotJetons audit: FauxDepotAudit hacheur: FauxHacheur + jetons_reset: FauxDepotJetonsReset + tentatives_reset: FauxDepotTentativesReset + mailer: FauxMailer def fabrique_service( @@ -184,12 +235,17 @@ def fabrique_service( compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, jetons: FauxDepotJetons | None = None, + jetons_reset: FauxDepotJetonsReset | None = None, + compteurs_reset: ResetRequestCounts | None = None, ) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() + depot_jetons_reset = jetons_reset or FauxDepotJetonsReset() + tentatives_reset = FauxDepotTentativesReset(compteurs_reset) + mailer = FauxMailer() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] @@ -200,8 +256,22 @@ def fabrique_service( token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, refresh_ttl=timedelta(days=7), + reset_tokens=depot_jetons_reset, # type: ignore[arg-type] + reset_attempts=tentatives_reset, # type: ignore[arg-type] + reset_policy=POLITIQUE_RESET, + mailer=mailer, # type: ignore[arg-type] + ) + return Attirail( + service, + comptes, + tentatives, + depot_jetons, + audit, + hacheur, + depot_jetons_reset, + tentatives_reset, + mailer, ) - return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None: assert attirail.jetons.revocations_par_compte == [] assert attirail.jetons.crees == [] + + +async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.invalidations == [compte.id] + assert attirail.jetons_reset.crees == [compte.id] + assert len(attirail.mailer.envois) == 1 + assert attirail.mailer.envois[0][0] == compte.email + assert "auth.password_reset_requested" in attirail.audit.lignes[0][0] + + +async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None: + attirail = fabrique_service(compte=None) + + await attirail.service.request_password_reset( + email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner" + + +async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None: + compte = FauxCompte(is_active=False) + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + + +async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None: + attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0)) + + with pytest.raises(RateLimitedError): + await attirail.service.request_password_reset( + email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.mailer.envois == [] + + +async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + jetons_reset = FauxDepotJetonsReset( + revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id) + ) + attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset) + + session = await attirail.service.confirm_password_reset( + token="un-secret-opaque", + new_password="Un-nouveau-mot-de-passe1!", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret + assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0] + + +async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None: + attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None)) + + with pytest.raises(InvalidOrExpiredResetTokenError): + await attirail.service.confirm_password_reset( + token="un-secret-invalide", + new_password="Un-nouveau-mot-de-passe1!", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 40b8317..7344bf7 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from app import cli +from app.schemas.auth import valide_complexite def test_build_parser_reads_the_create_admin_arguments() -> None: @@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked( assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE assert mot_de_passe in capsys.readouterr().out + valide_complexite(mot_de_passe) def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) - assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1" def test_read_password_refuses_a_password_below_the_minimum_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(cli, "getpass", lambda _: "court") + monkeypatch.setattr(cli, "getpass", lambda _: "Court1!") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_a_password_missing_a_character_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre") with pytest.raises(SystemExit): cli.read_password(generate=False) def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) with pytest.raises(SystemExit): diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..39ec7ca 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[[package]] +name = "aiosmtplib" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" } +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 = "alembic" version = "1.20.0" @@ -306,6 +315,7 @@ name = "enervision-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosmtplib" }, { name = "alembic" }, { name = "anyio" }, { name = "argon2-cffi" }, @@ -332,6 +342,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosmtplib", specifier = ">=5.1.3" }, { name = "alembic", specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index b3e97d8..72e20f5 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -5,6 +5,8 @@ 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: 'dashboard', canActivate: [authGuard], diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index d27c1db..9aa477a 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,7 +1,14 @@ import { Service, signal, computed, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, tap, finalize, shareReplay } from 'rxjs'; -import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; +import { + ForgotPasswordRequest, + LoginRequest, + PasswordChangeRequest, + Principal, + ResetPasswordRequest, + TokenResponse, +} from '../../shared/models/auth.model'; import { environment } from '../../../environments/environment'; @Service() @@ -66,4 +73,14 @@ export class AuthService { me(): Observable { return this.http.get(`${environment.apiUrl}/auth/me`); } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post(`${environment.apiUrl}/auth/forgot-password`, payload); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index edf2146..d7b5039 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -18,7 +18,7 @@ formControlName="new_password" autocomplete="new-password" /> - 12 à 128 caractères + {{ passwordHint }} @if (errorMessage()) {

{{ errorMessage() }}

diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..0e72843 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -32,10 +32,19 @@ describe('ChangePassword', () => { expect(authMock.changePassword).not.toHaveBeenCalled(); }); + it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' }); + + component.onSubmit(); + expect(authMock.changePassword).not.toHaveBeenCalled(); + }); + it('redirige vers /dashboard après un changement réussi', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -46,7 +55,7 @@ describe('ChangePassword', () => { it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); @@ -70,7 +79,7 @@ describe('ChangePassword', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); fixture.detectChanges(); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -81,7 +90,7 @@ describe('ChangePassword', () => { expect(authMock.changePassword).toHaveBeenCalledWith({ current_password: 'ancien-mot-de-passe', - new_password: 'un-nouveau-mot-de-passe-valide', + new_password: 'Un-nouveau-mot-de-passe1!', }); }); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..528aea0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,6 +2,7 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', @@ -17,10 +18,11 @@ export class ChangePassword { errorMessage = signal(null); isLoading = signal(false); + passwordHint = PASSWORD_HINT; form = this.fb.nonNullable.group({ current_password: ['', Validators.required], - new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + new_password: ['', passwordValidators], }); onSubmit(): void { @@ -34,7 +36,7 @@ export class ChangePassword { }, error: () => { this.isLoading.set(false); - this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); + this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`); }, }); } diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..2bd7ef9 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html @@ -0,0 +1,37 @@ +
+
+

Mot de passe oublié

+

Recevez un lien de réinitialisation par email

+ + @if (submitted()) { +

+ Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. + Il expire dans 15 minutes. +

+ } @else { + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000..56f7764 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ForgotPassword } from './forgot-password'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('ForgotPassword', () => { + let authMock: { forgotPassword: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { forgotPassword: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ForgotPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide', () => { + const fixture = TestBed.createComponent(ForgotPassword); + fixture.componentInstance.onSubmit(); + expect(authMock.forgotPassword).not.toHaveBeenCalled(); + }); + + it('affiche le message générique après une soumission réussie', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(of(undefined)); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'inconnu@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le délai à respecter quand le taux limite est atteint', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 429, + headers: new HttpHeaders({ 'Retry-After': '900' }), + }) + ) + ); + + component.onSubmit(); + + expect(component.submitted()).toBe(false); + expect(component.retryAfterSeconds()).toBe(900); + }); +}); diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..6ceef5c --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts @@ -0,0 +1,53 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './forgot-password.html', + styleUrl: './forgot-password.scss', +}) +export class ForgotPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + + errorMessage = signal(null); + retryAfterSeconds = signal(null); + submitted = signal(false); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + }); + + onSubmit(): void { + if (this.form.invalid) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + this.retryAfterSeconds.set(null); + + this.auth.forgotPassword(this.form.getRawValue()).subscribe({ + // Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse + // du serveur est déjà générique, l'écran doit l'être aussi. + next: () => { + this.isLoading.set(false); + this.submitted.set(true); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 429) { + const retryAfter = error.headers.get('Retry-After'); + this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null); + this.errorMessage.set('Trop de demandes, réessayez plus tard.'); + return; + } + this.submitted.set(true); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..3ee100b 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -32,5 +32,7 @@ + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..45b28c0 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -79,3 +79,13 @@ color: #dc2626; font-size: 0.85rem; } + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..d39298d 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -20,6 +20,7 @@ describe('Login', () => { providers: [ { provide: AuthService, useValue: authMock }, { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, ], }).compileComponents(); }); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..871e7cc 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,13 +1,13 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, RouterLink], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..eed77a8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,30 @@ +
+
+

Nouveau mot de passe

+ + @if (!hasToken) { +

Ce lien est incomplet. Redemandez un lien de réinitialisation.

+ } @else { +

Choisissez votre nouveau mot de passe

+ + + + {{ passwordHint }} + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.scss b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..7e212cd --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ResetPassword } from './reset-password'; +import { AuthService } from '../../../core/services/auth.service'; + +function configure(token: string | null) { + return TestBed.configureTestingModule({ + imports: [ResetPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: { resetPassword: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } }, + }, + ], + }).compileComponents(); +} + +describe('ResetPassword', () => { + it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + await configure(null); + const fixture = TestBed.createComponent(ResetPassword); + + expect(fixture.componentInstance.hasToken).toBe(false); + }); + + it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'trop-simple' }); + + component.onSubmit(); + + expect(auth.resetPassword).not.toHaveBeenCalled(); + }); + + it('redirige vers /dashboard après une réinitialisation réussie', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } })); + + component.onSubmit(); + + expect(auth.resetPassword).toHaveBeenCalledWith({ + token: 'un-secret-opaque', + new_password: 'Un-nouveau-mot-de-passe1!', + }); + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('affiche un message dédié quand le lien est invalide ou expiré', async () => { + await configure('un-secret-perime'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + + component.onSubmit(); + + expect(component.errorMessage()).toContain('invalide'); + }); +}); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..6754fa7 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,52 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './reset-password.html', + styleUrl: './reset-password.scss', +}) +export class ResetPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + private token = this.route.snapshot.queryParamMap.get('token') ?? ''; + + errorMessage = signal(null); + isLoading = signal(false); + passwordHint = PASSWORD_HINT; + hasToken = this.token.length > 0; + + form = this.fb.nonNullable.group({ + new_password: ['', passwordValidators], + }); + + onSubmit(): void { + if (this.form.invalid || !this.hasToken) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + + this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({ + next: () => { + this.router.navigate(['/dashboard']); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 400) { + this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + return; + } + this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts index 932572f..ebed0d5 100644 --- a/apps/frontend/src/app/shared/models/auth.model.ts +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -10,6 +10,15 @@ export interface PasswordChangeRequest { new_password: string; } +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + new_password: string; +} + export interface Principal { id: string; email: string; diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts new file mode 100644 index 0000000..fac1359 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -0,0 +1,15 @@ +import { Validators } from '@angular/forms'; + +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 128; +export const PASSWORD_HINT = + '8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial'; + +const PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/; + +export const passwordValidators = [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.maxLength(PASSWORD_MAX_LENGTH), + Validators.pattern(PASSWORD_PATTERN), +]; diff --git a/docker-compose.yml b/docker-compose.yml index 3d0ea63..3f7f9ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,22 @@ services: start_period: 40s restart: unless-stopped + # Piege : Mailpit ne relaie rien vers l'exterieur, il capture tout email envoye par le + # backend. Aucun acces reseau sortant n'est requis ; l'UI web (8025) sert a lire les emails. + mailpit: + image: axllent/mailpit + ports: + - "${MAILPIT_SMTP_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + restart: unless-stopped + backend: build: ./apps/backend depends_on: db: condition: service_healthy + mailpit: + condition: service_started environment: APP_ENV: ${APP_ENV:-local} APP_DEBUG: ${APP_DEBUG:-false} @@ -39,6 +50,11 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} + APP_SMTP_HOST: mailpit + APP_SMTP_PORT: "1025" + APP_SMTP_USE_TLS: "false" + APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr} ports: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index 9c9fe66..981cd85 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -20,6 +20,8 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | POST | `/api/v1/auth/logout` | cookie | `204` | | POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | | POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| POST | `/api/v1/auth/forgot-password` | aucune | `202` (toujours, que le compte existe ou non) | +| POST | `/api/v1/auth/reset-password` | aucune (jeton dans le corps) | `200` `TokenResponse` | | GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | | GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | | POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | @@ -51,7 +53,17 @@ codes d'erreur ci-dessous reste la référence de comportement, le schéma celle } // POST /auth/password -{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +{ "current_password": "...", "new_password": "..." } // 8 à 128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre, 1 caractère spécial + +// POST /auth/forgot-password +{ "email": "operateur@enervision.fr" } +// Répond toujours 202, sans corps, que le compte existe, soit inactif, ou soit inconnu. + +// POST /auth/reset-password +{ "token": "...", "new_password": "..." } // même règle de complexité que /auth/password +// Le jeton vient du lien reçu par email, valable 15 minutes, à usage unique. Répond +// TokenResponse au succès (l'appareil qui pose le nouveau mot de passe reste connecté), ou 400 +// si le jeton est invalide, déjà utilisé, ou expiré. ``` Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. @@ -70,6 +82,9 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | | `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | +| `429` sur `/auth/forgot-password` | trop de demandes | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `400` sur `/auth/reset-password` | lien invalide, déjà utilisé, ou expiré | inviter à redemander un lien depuis `/forgot-password` | +| `403` sur `/auth/reset-password` | origine hors liste autorisée | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | ## Les quatre règles qui comptent From 9c78c6dc3882d12c3ce8958a80a8aad1b30ca7b2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 11:14:02 +0200 Subject: [PATCH 103/205] =?UTF-8?q?feat(frontend):=20design=20syst=C3=A8me?= =?UTF-8?q?=20-=20tokens,=20composants=20ui=20et=20restylage=20des=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page (login, change-password, dashboard) en tokens CSS partagés, ajoute un petit set de composants standalone réutilisables (ev-button, ev-card, ev-alert, ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans Swagger/ReDoc côté backend. Refs #91 --- apps/backend/app/main.py | 50 ++++++++- apps/backend/app/static/logo-icon.png | Bin 0 -> 36468 bytes apps/frontend/README.md | 7 ++ apps/frontend/public/logo.png | Bin 0 -> 7152 bytes .../auth/change-password/change-password.html | 53 +++++---- .../auth/change-password/change-password.scss | 104 +++++------------- .../change-password/change-password.spec.ts | 4 +- .../auth/change-password/change-password.ts | 5 +- .../src/app/features/auth/login/login.html | 63 ++++++----- .../src/app/features/auth/login/login.scss | 100 ++++++----------- .../src/app/features/auth/login/login.spec.ts | 6 +- .../src/app/features/auth/login/login.ts | 5 +- .../src/app/features/dashboard/dashboard.html | 27 +++-- .../src/app/features/dashboard/dashboard.scss | 96 +++++----------- .../src/app/features/dashboard/dashboard.ts | 18 ++- .../app/shared/components/ui/alert/alert.html | 1 + .../app/shared/components/ui/alert/alert.scss | 27 +++++ .../shared/components/ui/alert/alert.spec.ts | 30 +++++ .../app/shared/components/ui/alert/alert.ts | 21 ++++ .../app/shared/components/ui/badge/badge.html | 3 + .../app/shared/components/ui/badge/badge.scss | 27 +++++ .../shared/components/ui/badge/badge.spec.ts | 30 +++++ .../app/shared/components/ui/badge/badge.ts | 13 +++ .../shared/components/ui/button/button.html | 3 + .../shared/components/ui/button/button.scss | 51 +++++++++ .../components/ui/button/button.spec.ts | 50 +++++++++ .../app/shared/components/ui/button/button.ts | 15 +++ .../app/shared/components/ui/card/card.html | 1 + .../app/shared/components/ui/card/card.scss | 10 ++ .../shared/components/ui/card/card.spec.ts | 22 ++++ .../src/app/shared/components/ui/card/card.ts | 9 ++ apps/frontend/src/styles.scss | 9 +- apps/frontend/src/styles/_forms.scss | 37 +++++++ apps/frontend/src/styles/_tokens.scss | 39 +++++++ .../32-design-systeme-frontend.md | 76 +++++++++++++ 35 files changed, 724 insertions(+), 288 deletions(-) create mode 100644 apps/backend/app/static/logo-icon.png create mode 100644 apps/frontend/public/logo.png create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.html create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.scss create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.html create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.scss create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.html create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.scss create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.html create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.scss create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.ts create mode 100644 apps/frontend/src/styles/_forms.scss create mode 100644 apps/frontend/src/styles/_tokens.scss create mode 100644 docs/architecture/32-design-systeme-frontend.md diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..5a8ec3e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,9 +1,15 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator +from starlette.requests import Request +from starlette.responses import HTMLResponse from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware @@ -18,6 +24,8 @@ logger = get_logger(__name__) METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"] +STATIC_DIR = Path(__file__).parent / "static" +LOGO_URL = "/static/logo-icon.png" @asynccontextmanager @@ -43,11 +51,49 @@ def create_app(settings: Settings | None = None) -> FastAPI: openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, - docs_url="/docs" if documentee else None, - redoc_url="/redoc" if documentee else None, + docs_url=None, + redoc_url=None, openapi_url="/openapi.json" if documentee else None, ) + if documentee: + application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + # ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo + # en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon. + def openapi_avec_logo() -> dict[str, object]: + if application.openapi_schema: + return application.openapi_schema + schema = get_openapi( + title=application.title, + version=application.version, + summary=application.summary, + description=application.description, + routes=application.routes, + tags=application.openapi_tags, + ) + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + application.openapi_schema = schema + return application.openapi_schema + + application.openapi = openapi_avec_logo # type: ignore[method-assign] + + @application.get("/docs", include_in_schema=False) + async def docs_swagger(_: Request) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url="/openapi.json", + title=f"{application.title} · Swagger UI", + swagger_favicon_url=LOGO_URL, + ) + + @application.get("/redoc", include_in_schema=False) + async def docs_redoc(_: Request) -> HTMLResponse: + return get_redoc_html( + openapi_url="/openapi.json", + title=f"{application.title} · ReDoc", + redoc_favicon_url=LOGO_URL, + ) + application.add_middleware(SecurityHeadersMiddleware) if resolved.allowed_origins: diff --git a/apps/backend/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bdc53f943206607cea054067c3888aa9056d83 GIT binary patch literal 36468 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 kzNePZU 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 literal 0 HcmV?d00001 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index aeaf788..c5b7484 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -76,6 +76,13 @@ Points à vérifier après toute regénération : côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). +## Design système + +Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`, +`ev-alert`, `ev-badge`) sont documentés dans +[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md). +Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs. + ## Additional Resources For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/apps/frontend/public/logo.png b/apps/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e41b24ad30754b294062a27d7f16819fc51210 GIT binary patch literal 7152 zcmcIp=QrHn*Zmm16P+*!qW9>Xh!!oPm(fLyUWbXO(M9jQ*Dphe8oh_8BZ%l-Mj74X z_eVTy?Q`#&v(CCN&OUeTeWJBB-w@)_;sF3asH&o*`*im_1q=u4=`;(oa{>UmR#hc= zJwL?Jia$(mE|>505)bQRv0AL>L+<}y*y)C?dfJaOas#$maz+3Ru*Azx36qDQRB}=Ilu=&z zYa%|$FF%IbDgFhV8NWU*tI>l z(~5Eh3C1urok7qR1jSBDfr>J5hj2{Tb_?mw*P?29E0HFe5smU^NmE7b(4GjEqZfbQ zVmM-Nw60USsrowkeq>MF?)^4&lyimlv${cKMfy;1JKO(R2`Y(n4!aEmtV-Rv&C$oE$P4V1`G#O@G&2_NvFIMRrYD!IaDTnKQSB z&I#r(TKSHw8oy6VU8D(DWh7V%ayeE#JI~Ho*s?Tr{M%1)o(qaK=UP_Y!H-SMp&%X- z2h8s&!{Y0p0{yEuYtYF7!TtO3m;l8Lnid<22sp}5B>5;~?S6lm-u{mM1-6c^xg|tK zU9Cg~FGJg8#<1adkg&A~^G6)D_)9%t7B6JOOz}at|3!C7m#)Z91nn()>Hg!tHZAE^ zVmVu391?*4+ukXtqZmKM3A8YiyW9%+<^`IcL0{z2qsDZk z9v*b0eAZ9%!4hS3oZj)(X=*MqH^+r!Ss{W&+8lR%zQQX)&d-4o#GlTMPz#>By_dhL zE~ngO=@4LG@cyI2VSjK5&sv)5MQaU@vp`PwgHSzp(DDz9Bm7O{ z!oWwdIiE=y+fqTLzg(P^5fOo(34P?6KWH1YPQVosx;xj=B~+bKPv)|9zfEH#{acaz zj$bGH2lQt}3u@d;c1-J|NkVF7{yQd8dL8`T72&5^s~9wXpC#An=uF#rdZ^6Wp~~1E z+nYXGAu)gjMCyNLEV3DbuQmL;9vp}ZMs*w)T<2Mee#z)gB$r2pg`n>4;#%iJ|5bd- z8X2aS^m%?Jv*jUakly?D7`ZC>`tSqrxAJ59^sMt)@Rl;t)e(tCBiIlzA%Q+CR@Fi-;bC}V^5}yr% zn@=c2I#)L$M-nxVIoD1r*)AtcEVb_X5KnmMre{jXxpZm_$ZGPz#@8FtB7u*b($?|G zjyMFuQPe=}JO(oxGY4#PPD}ct9C3Lv|Mt18x1(`lRZ|VI%y-B4#}3QFA|f|}4Ea1^ zRF?C6*XD;EDpJTeejG4e0HWZ;rfI3w_$4c_cvh~VT4{JE;&e@+!x`j0k@X6j_~P#> zcE9`ZJth!#edkP*r05KZUVH!%sQJ<_G0)*W`lARs5%c~>g8ODe%2#DWVImE*80h&y zb`L_)qF_8SAvA5>t2rO+Hhl+vmEng3L{EoYaz^y_`yU&p!eynU;Q;K_J+xoz&Ta3y z8+o_;IS&lwr`3Akq>Yo!P?=wI<9WaXX7-wcM2#1{+66~nEBsEb(2^;VdQ2cIdrOD7 z^`D<%;LFTOsyJw6F|v3xA(>#|=pu_|E#sOpX|Oz)XPQ&sj`!)R)T{E49(^=sHj;gr zrxK)l```@ZjM((!3S}V8@nQ6H3Uw^Jlmcib|G6m{%AkRED9&f zK4A`|3cakaIDp@UmJTWnyH~IA}nkOK*mrM_}5hU($_&4rQNlr8EwM)sf<7S3yFT&pk11ZP{>tYZeRb8m8a4ACPhWYpA2lgr3WSMM_? zLMq@a@cIpevT09VP$)((<#@+MtMl;XF|4(@Ick1hKRZj{VA=+{@>`*6Cka000e;Xj z;}p_)bMKx>$-KrBbVXNRnIIP^Q=^o%q9D2y;e6yfR( z^MU4C2LqrKx}6p9CBCm!`8=5dz<+W#+Uh#iy?VF2xti66+-xxeMI$?Bv(rBzNfUyA zqz|HbEV;2CI}X<%4gT;>>AF^kx5MvhR-EcsNW$;q5poMPE3pILk^OK}qot;0r!s6g z-Vu`t-m#jZcbcye2Y|0Hwdxi|AK7q|$o1Fkjm9sTSKrjY1LvhuZx{D|**{Q@=f5bl zi_ez0sF|OiciHnYej1U`0__ZPRhBnSwIdieYiK|F_nNjjrt(W`S6ePCOCME~sKtHj zLe#YW;j;{+vh+ioj@U$QESw8JhP-+6M$C04woFBD#in-d)7+X#tH)M6f<8~obDL1@ z2AtWg)wabVgsj8vK zCjJON>pMKzf92V&+zg-BWzUlL&l`pus!WN|8%k7+`}l|#rGxNK_X~aTtE%O7y0^~h z1E&y|5~};yMV6hvN?v#>fmATsOf`#r-?SB@SL%%(?Cv&Tl)PwMr1~PgZB4qByZ@%R z>lM})oV=3>&50bS+7Pn5EBlF*L~?4`A@OX_763VIUPXU~65S^CdGlPgQr@H8-9@-8 z!Pk51Xg9yE1O=^NeQr|l!8PgmCb1!agUO&LaaVKVf1!_^j((+jk?BYiZN7C)s>4mW z%5L0QGUNEgDEem8K9GpP&$c%da}B-c9vOaXJ+k=*E`T#y+LNI4<~n-wH~RPn5R@6t zKoN(!RiMUVKb}~_hU`@A6(?UHVdZFgFtZbnkudF$y>6?Y}ajah&Z-JneFdC*_ZPPft%@ z_Kdm6kT)HkxUXS-Sf%jLQ&V7?W5Sy{Z!)H=_AgcSD;weXmWPDxxWV7(cBdbV>{Li; zQFI?aCW~(!rraI0>MU&9Eb|@}m{#A19BPXjuNDhTkH^7_C~5jEH#<*KIPTLw@=U>8Q)xUcy`#bB=y*RIOwgw-)KCwH&&B78xpbV|f(b(zxwith z3k}7DxG7|Gxi>?~Gf&U~5%vg)Sww~vI|lln0RJ18M!&mc4-R(v#cot~yI#iZFVE8~ z-NI83VOr_8iGB@Csr%SKzZgkG;bl;WUS;`5gG$q!-(O|@plznBbkR!II%|)dfO)%D zrQc&K0Do4ouBad=#Jwc-@oIlkgBARV=mJ84`UHbVC6}Y$x3gQjF6@ZbXW!l?CDkrp zVMAsh4mkXuDL%;&d>F&h_yz_%p!)Ii#>`ga(KyD-)~ncKB!kp`c?%gTEZt*}2HPuA zaG=kTDd>;roKi*!BL!9fI5lRWx!&v+3y94XWL>|0P*j@jKJ>RoU*u9*A!r~BojIDS zF?4skFz6Jtyg9EA4Biaht0XS{wa2$}b`5AKa8`!uRzeRV^3D(HeaV4>uB?5H9DxH; zpjh>1&GyLG$mW(7SLqOB>`1VbpmHSBrwPSQ304pc)PxP3@*9%$8jhva993Y9x)GU? z0i{v6+7Na5Lha|Wp4*JfH(~HWTC(5QhgI(%wO5*rg%2 zD&4}LOI=piJJri|xx${81WNAGNe&>nx%rDt9GGsKgC4gPu7X00-^p}g->{m3VsaRv zw*d#(wDX_(vbmaUrv9=y;znzgqiSiBM@fO5q>r2+qtI4?-Hagruy>OY2nJGGYUa?}?V_PQdv zp!g>EOeLq%#Fa)(nq;Aq^Tzm0bK}uMcD88j%z})^_O+=5wC(k#5=V@QvhQKh7_rl` z{`VL03KF^TL<~}5HKlPP&BWO9r>~|G&V`gMh#;io(EbD^rN;6G);<4_!K+0fhpEv*n#&9x8!}t_hL1Vh)Jfb$5LFtPUBjSFeXLZP?F zt$aVbfiGJ1P{%#*+-iMzsV>L+wj@vmL6{*BG(cgH;Vj}r1g1xGre;$~w6Qiy%wR9+ zFjny|O+-4%O$tazLE@Gya7jkybSPoFa&Xx0Q+|~?&2Hu^`#Si%RDFsS$0kK8lbt&a zX`}5~h)0{LOs=NEwnA71xh*p~aG+oJn>1{TIDM?tcD&_zwDQo<-;t@?q^p@|A^S%~ z+jK=Y^I~>&eE>a-gkTJF{om?SeMt$4@jtzvX&gu)9m+~d8cC~7wiy2besU!Ldu>Td zXw?fXxqX&QY(wofute;M9i%%E6uO!CqNO@08J$_UfUaTLu<4aX$nvbn-JG<&%Li~% z!+LZwZnwyH`~Hmsx1cSmgB`akChTn)WWoE~b7zO{)`xd=`nK^RO{Qk%(wDd8tQi70 zl#o+8;A3<2H}k?jwt_U7BQ|&Y2Dox8UsbUH4kg>DYNXP1K(kksb9j`}e%F$hZ3FDolve&Mv$F}>VV;SEj`y)H>1A!yNJx_Fpao; z!7Yzx|LH_B(Xflf8v#7ZoJ}jkj8Pu5HY&w&ry&K5;oX_*&@=e>9*t zn7@QZ=cc&EVB}-SmE3|8-J^Ydf|58wh1{7XK%}SKs4uZZWY#VwLCRhpj?u1rpzpZm zNa)P<3Jj@%X18QBm0n1Cw3*E#cq_@d_8w8Dyy+HgHY4%x6Qo;qPw1{ZE zWPp}=-TKC}7eumR>D70~{CD2kymUvH(<4BvDdoy^~`u;FRE~AjnP%Ckw5rL|boyKz1PKN(;A>GU; z_3UHr`%@CTW)d$3=c+|lKE!lzli%b&+b=lv9p8ULHC4?~k^UxQZ)F)IjQeLjXx?3{ z=bc8Horwlh5ZJh%r>|9?5fVgyU}k&K;*g=(Y#{NstvJ*OT_;&XVRdhDMOf! zTg8ECc

JUK7YqOs!HPi$8Sx=)F5jGNRV7t6zK3H*I)6quMkxW_bjb6V~RuHi))a z>A4R?#e+Vwny3>966`)HvsdqhfeO!y{fn=5rTPD$k3r0iBnpmpcP+)m#oD>yTaNjW z*AteedBQ?a&kkOH-$rdeD6-}peI7|kBD3q?#ZHO;-8&d1lDWG8gt8^ZUv+gm&Q<;G z&ur=*m~VM)-AMd4E7zPzIUYk9udwbiC_f;8NalB&$2D21MKla2YwvVaMSm*%`l>6& z{dbyzoM${b8`41ub$rLdU(upj;p;Rl5V8?GGu!~b9ia-XCCohZ4!Qap;ZfzH*A!JL zi(}!1M1xu`yrt`&@qO&_SV`?+EUhC~gTYr6wibne#EIuF)a3W2R>K~JLZqbPkmr*C{z4zdH3S%n*ZCws0Qfb76-U8#(NK4dH!(+Nr8?Xa|V8;0G zQ?=DN?2fTv&AfaZ6afrp(kHQgW-A6{-rlV*lSkD1_!n(KP^ho3FJpAFt{Q7e6c&6v zZ%d1Zn<}uYq7M?notKfU!q&}@@bXasM}9zIs+&CQ+wso;rlJ6jR8R#GFuEm(2DALa zQA$pYu31G{ATqCM%q@VKYjCfpOLyx|k~jFb`0yu)wDMg&#_M;_RQJnK{}vw4GbeI0 z2NL;Cgm5jQG=_#OjNh3^hWYakIqP$caXC9$_KAP$8K@AROsKy-@Roi`H411Fsp8>) zoUWzDgbT$A7}I)J@EhTau&b+f-~+MS*nxQ0RJFK12E^J+7M1Bg_&X*Y6b1g{o1gD} zayVXZlxf~~zPj;X76&!F=C3#Y?!06j89dbU={9q3V@sEVjg<$Ts59hr`+j_j0LTb> zC}-uc_)5~yuwFT`Dfs8-cMu4`h{=!Knh^qdPyt?b#&EPA+e#XtZ9jSbC52gBOA8L{ z-s0S9gAOd9q(OumJAc-g?y|$BbMkR=P;Y;&8J?~Np~kp0amshKXmQ2_ybMChG5B!Q zN}DKlSpl)z!=}>Mz-6jZ8VoMM)gAe0^Jkn;NBXZ3I)JE4Ksbf~D6Dc08W!MxTT> z=+y^^_6g>PIjRML?bUT+jjM&TZ1X0y|3R5&fp#ot#6z%aeXw{v5@p}oqQhCmoD-AAUGG}$-I_}Z!2Hd zd0)Ssl}seoAG;&l)Y#crI_>7TFCzJqdO4fV`%4(@hs(&!3sIMw3zN>StF-W9Ou;8l z&Tc(w=)H!S4Z4$b%m;eWu4cBcpT_N0V*b4>-soh==I%FmJDpNmF2#Pw3^lO+?>k&_ zc=Gd7Q|pzP8^p%uo533Vq3mt9W~GW?Nkzl+N%th~H`sEpIdX+CzcGl%#Dob4@X_9$re0I^vg>5$T=#@!M zhDM9#C27Mt>MU?W-8zW0A_=WEhyEfyTMS+}U7 zcIOHEyrppEpOP|V8^*9lx1EM>|3O^FJNvq^ErN^!W||N+Etod#GPlSy#qf6I-}c(z z_^IqZyUqvanfSU*U8x~$~; z&tjR&JF17f&|6`#9VL8NpK=5MQCN;5HpxFucTXiaL%_s`Vk5nAx%U6KJp8G1x(xn) zu}lf@J_LJOnbhnMOzqfF{$*zCQc&8`W=138ZpIB^SW&guzsiogHCy-9{0&t2#QJ)D zLfYpouLWHGir8ynW}Bpx>5U-B6&>%F9G=nSc%8STT)U3ONXu=_%yu{0lg2}1=(3Pp zYO=8MZ9ScOe0I<-<*#w%sTsLzm9W z(P>$}Zrl4+MQnGDgtNTC0x$g8X_@Bz3`)iJZi>SWKHY+@+|5yK`z(I!o|*ljjB! -

-

Nouveau mot de passe

-

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + +

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

- - + + - - - 12 à 128 caractères + + + 12 à 128 caractères - @if (errorMessage()) { -

{{ errorMessage() }}

- } + @if (errorMessage()) { + {{ errorMessage() }} + } - + + {{ isLoading() ? 'Modification...' : 'Valider' }} + +
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss index f44fcb8..48051a5 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.scss +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -3,86 +3,42 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { width: 100%; max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - line-height: 1.4; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } } -.auth-hint { - font-size: 0.75rem; - color: #9ca3af; - margin-top: 0.25rem; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + line-height: 1.4; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..6fe09d0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -54,7 +54,7 @@ describe('ChangePassword', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) expect(component.errorMessage()).toContain('incorrect'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('incorrect'); }); @@ -64,7 +64,7 @@ describe('ChangePassword', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..1acf568 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,11 +2,14 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..89e67fe 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,36 +1,41 @@
-
-

Connexion

-

Accédez à votre espace EnerVision

+ + + +

Connexion

+

Accédez à votre espace EnerVision

- - + + - - + + - @if (errorMessage()) { -

- {{ errorMessage() }} - @if (retryAfterSeconds(); as seconds) { - (réessayez dans {{ seconds }}s) - } -

- } + @if (errorMessage()) { + + {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } + + } - + + {{ isLoading() ? 'Connexion...' : 'Se connecter' }} + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..46cc393 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -3,79 +3,41 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { width: 100%; max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; +} + +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..46afce2 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -63,7 +63,7 @@ describe('Login', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); }); @@ -80,7 +80,7 @@ describe('Login', () => { fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) expect(component.retryAfterSeconds()).toBe(30); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('30s'); }); @@ -90,7 +90,7 @@ describe('Login', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..ea8f62f 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -3,11 +3,14 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 684b444..533f0e3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,22 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+ +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
@if (error(); as message) { - + } @if (stats(); as s) {
-
+ Consommation vs capacité {{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_capacity_kw | number }} kW -
+ -
+ Charge moyenne du parc {{ s.average_load_percent }} %
-
+ -
+ Sites suivis {{ s.total_sites }} -
+
@@ -50,8 +53,8 @@

Alertes actives

    @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} +
  • + {{ alert.severity }} {{ alert.message }}
  • } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 75976e1..53c5364 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -1,23 +1,22 @@ :host { - --color-good: #2e7d32; - --color-partial: #f9a825; - --color-degraded: #ef6c00; - --color-critical: #c62828; - --color-bg-card: #ffffff; - --color-border: #e5e7eb; - --color-text-muted: #6b7280; - --radius: 10px; - display: block; - font-family: 'Segoe UI', system-ui, sans-serif; - color: #1f2937; + color: var(--color-text); padding: 2rem; max-width: 1100px; margin: 0 auto; } .dashboard__header { + display: flex; + align-items: flex-start; + justify-content: space-between; margin-bottom: 2rem; +} + +.dashboard__brand { + display: flex; + align-items: center; + gap: 0.85rem; h1 { margin: 0; @@ -26,6 +25,11 @@ } } +.dashboard__logo { + height: 40px; + width: auto; +} + .dashboard__subtitle { margin: 0.25rem 0 0; color: var(--color-text-muted); @@ -38,13 +42,8 @@ h2 { } .banner-error { + display: block; margin: 0 0 1.5rem; - padding: 0.75rem 1rem; - border: 1px solid var(--color-critical); - border-left-width: 4px; - border-radius: var(--radius); - background: #fdecea; - color: var(--color-critical); } .overview { @@ -55,14 +54,8 @@ h2 { } .card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius); padding: 1.25rem; - display: flex; - flex-direction: column; gap: 0.35rem; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .card--gauge { @@ -84,16 +77,16 @@ h2 { .progress-bar { height: 6px; - background: #e5e7eb; - border-radius: 999px; + background: var(--color-border-light); + border-radius: var(--radius-pill); overflow: hidden; margin-top: 0.25rem; } .progress-bar__fill { height: 100%; - background: #3b82f6; - border-radius: 999px; + background: var(--color-primary); + border-radius: var(--radius-pill); transition: width 0.3s ease; } @@ -115,59 +108,26 @@ h2 { align-items: center; gap: 0.75rem; padding: 0.7rem 1rem; - border-radius: var(--radius); - background: #fef2f2; - border: 1px solid #fecaca; -} - -.alert-item__badge { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - padding: 0.2rem 0.55rem; - border-radius: 999px; - color: #fff; - background: var(--color-critical); - flex-shrink: 0; -} - -.alert-item--high .alert-item__badge { - background: var(--color-degraded); -} -.alert-item--medium .alert-item__badge { - background: var(--color-partial); -} -.alert-item--low .alert-item__badge { - background: var(--color-good); + border-radius: var(--radius-md); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); } .alert-item__message { font-size: 0.9rem; } -.dashboard__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 2rem; - - h1 { - margin: 0; - font-size: 1.75rem; - font-weight: 700; - } -} .logout-button { padding: 0.5rem 1rem; - background: #ffffff; - border: 1px solid #d1d5db; - border-radius: 8px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; - color: #374151; + color: var(--color-label); cursor: pointer; &:hover { - background: #f3f4f6; + background: var(--color-bg); } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index c6a6a56..28bc37e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -9,16 +9,26 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load import { AlertsService } from '../../core/services/alerts.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; -import { Alert } from '../../shared/models/alert.model'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { Card } from '../../shared/components/ui/card/card'; +import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; 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: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, - imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge], templateUrl: './dashboard.html', styleUrl: './dashboard.scss', }) @@ -54,6 +64,10 @@ export class Dashboard implements OnInit { }); } + badgeToneForSeverity(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.html b/apps/frontend/src/app/shared/components/ui/alert/alert.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.scss b/apps/frontend/src/app/shared/components/ui/alert/alert.scss new file mode 100644 index 0000000..667f779 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.scss @@ -0,0 +1,27 @@ +:host { + display: block; + margin: 0; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 0.85rem; + line-height: 1.4; +} + +:host.ev-alert--success { + background: var(--color-success-bg); + border-color: var(--color-success); + color: var(--color-success); +} + +:host.ev-alert--warning { + background: var(--color-warning-bg); + border-color: var(--color-warning); + color: #92400e; +} + +:host.ev-alert--danger { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-danger); +} diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts new file mode 100644 index 0000000..cea6bd7 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Alert } from './alert'; + +@Component({ + standalone: true, + imports: [Alert], + template: `C'est fait`, +}) +class AlertHost {} + +describe('Alert', () => { + it('applique la classe danger par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents(); + const fixture = TestBed.createComponent(Alert); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain('ev-alert--danger'); + }); + + it('applique la sévérité demandée et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents(); + const fixture = TestBed.createComponent(AlertHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-alert'); + expect(el.classList).toContain('ev-alert--success'); + expect(el.textContent).toContain("C'est fait"); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.ts new file mode 100644 index 0000000..45e030f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, input } from '@angular/core'; + +export type AlertSeverity = 'success' | 'warning' | 'danger'; + +@Component({ + selector: 'ev-alert', + standalone: true, + templateUrl: './alert.html', + styleUrl: './alert.scss', +}) +export class Alert { + severity = input('danger'); + + @HostBinding('class') + get hostClass(): string { + return `ev-alert ev-alert--${this.severity()}`; + } + + @HostBinding('attr.role') + readonly role = 'alert'; +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.html b/apps/frontend/src/app/shared/components/ui/badge/badge.html new file mode 100644 index 0000000..9dd0cd5 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.scss b/apps/frontend/src/app/shared/components/ui/badge/badge.scss new file mode 100644 index 0000000..212281f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.scss @@ -0,0 +1,27 @@ +.ev-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-pill); + color: #fff; + flex-shrink: 0; +} + +.ev-badge--success { + background: var(--color-success); +} + +.ev-badge--warning { + background: var(--color-warning); +} + +.ev-badge--danger { + background: var(--color-danger); +} + +.ev-badge--neutral { + background: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts new file mode 100644 index 0000000..09e1a25 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Badge } from './badge'; + +@Component({ + standalone: true, + imports: [Badge], + template: `critique`, +}) +class BadgeHost {} + +describe('Badge', () => { + it('applique le ton neutral par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents(); + const fixture = TestBed.createComponent(Badge); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral'); + }); + + it('applique le ton demandé et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents(); + const fixture = TestBed.createComponent(BadgeHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-badge'); + expect(el.classList).toContain('ev-badge--danger'); + expect(el.textContent).toContain('critique'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.ts new file mode 100644 index 0000000..e9cc0ca --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'neutral'; + +@Component({ + selector: 'ev-badge', + standalone: true, + templateUrl: './badge.html', + styleUrl: './badge.scss', +}) +export class Badge { + tone = input('neutral'); +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.html b/apps/frontend/src/app/shared/components/ui/button/button.html new file mode 100644 index 0000000..08ab099 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.html @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/button/button.scss b/apps/frontend/src/app/shared/components/ui/button/button.scss new file mode 100644 index 0000000..0f33524 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.scss @@ -0,0 +1,51 @@ +.ev-button { + width: 100%; + padding: 0.7rem; + border: none; + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font-family); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } +} + +.ev-button--primary { + background: var(--color-primary); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: var(--color-primary-hover); + } +} + +.ev-button--secondary { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-label); + + &:not(:disabled):hover { + background: var(--color-bg); + } +} + +.ev-button--danger { + background: var(--color-danger); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: #b91c1c; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.spec.ts b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts new file mode 100644 index 0000000..8362cdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Button } from './button'; + +@Component({ + standalone: true, + imports: [Button], + template: `Valider`, +}) +class ButtonHost {} + +describe('Button', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [Button] }).compileComponents(); + }); + + it('applique la classe de la variante primary par défaut', () => { + const fixture = TestBed.createComponent(Button); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--primary'); + }); + + it('applique la classe de la variante demandée', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('variant', 'danger'); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--danger'); + }); + + it('désactive le bouton natif quand disabled est vrai', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents(); + const fixture = TestBed.createComponent(ButtonHost); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/button/button.ts b/apps/frontend/src/app/shared/components/ui/button/button.ts new file mode 100644 index 0000000..c9d6b11 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.ts @@ -0,0 +1,15 @@ +import { Component, input } from '@angular/core'; + +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; + +@Component({ + selector: 'ev-button', + standalone: true, + templateUrl: './button.html', + styleUrl: './button.scss', +}) +export class Button { + variant = input('primary'); + type = input<'button' | 'submit'>('button'); + disabled = input(false); +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.html b/apps/frontend/src/app/shared/components/ui/card/card.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/card/card.scss b/apps/frontend/src/app/shared/components/ui/card/card.scss new file mode 100644 index 0000000..09307fb --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.scss @@ -0,0 +1,10 @@ +:host { + background: var(--color-surface); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: var(--space-5); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + box-sizing: border-box; +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.spec.ts b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts new file mode 100644 index 0000000..cea71d9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Card } from './card'; + +@Component({ + standalone: true, + imports: [Card], + template: `

    Contenu

    `, +}) +class CardHost {} + +describe('Card', () => { + it('projette son contenu', async () => { + await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents(); + const fixture = TestBed.createComponent(CardHost); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('ev-card'); + expect(card).toBeTruthy(); + expect(card.textContent).toContain('Contenu'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/card/card.ts b/apps/frontend/src/app/shared/components/ui/card/card.ts new file mode 100644 index 0000000..50c496c --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ev-card', + standalone: true, + templateUrl: './card.html', + styleUrl: './card.scss', +}) +export class Card {} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 90d4ee0..0d45801 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -1 +1,8 @@ -/* You can add global styles to this file, and also import other style files */ +@use 'styles/tokens'; +@use 'styles/forms'; + +body { + margin: 0; + font-family: var(--font-family); + color: var(--color-text); +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..fc35ad4 --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -0,0 +1,37 @@ +.form-label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-label); + margin-bottom: var(--space-1); + margin-top: var(--space-3); +} + +.form-input { + width: 100%; + padding: var(--space-2) 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-family: var(--font-family); + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15); + } +} + +.form-hint { + display: block; + font-size: 0.75rem; + color: var(--color-disabled); + margin-top: 0.25rem; +} + +.form-error { + margin: var(--space-2) 0 0; + color: var(--color-danger); + font-size: 0.85rem; +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..4b85e61 --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -0,0 +1,39 @@ +:root { + // Marque (dérivé du logo : vert feuille/éclair, halo) + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-light: #dcfce7; + + // Neutres (texte, bordures, fonds) + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-label: #374151; + --color-border: #d1d5db; + --color-border-light: #e5e7eb; + --color-bg: #f3f4f6; + --color-surface: #ffffff; + --color-disabled: #9ca3af; + + // Sémantique (statuts, alertes) + --color-success: #16a34a; + --color-success-bg: #dcfce7; + --color-warning: #f9a825; + --color-warning-bg: #fef9e7; + --color-danger: #dc2626; + --color-danger-bg: #fef2f2; + --color-danger-border: #fecaca; + + // Typo, rayons, ombre + --font-family: 'Segoe UI', system-ui, sans-serif; + --radius-sm: 8px; + --radius-md: 12px; + --radius-pill: 999px; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + + // Espacements + --space-1: 0.35rem; + --space-2: 0.6rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2.5rem; +} diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md new file mode 100644 index 0000000..d2324e9 --- /dev/null +++ b/docs/architecture/32-design-systeme-frontend.md @@ -0,0 +1,76 @@ +# Design système frontend + +Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que +redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue +[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence +visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier). + +## Tokens + +Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une +seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire. + +| Variable | Rôle | +|---|---| +| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales | +| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) | +| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes | +| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux | +| `--color-disabled` | Éléments désactivés | +| `--color-success` / `-bg`, `--color-warning` / `-bg`, `--color-danger` / `-bg` / `-border` | États sémantiques (alertes, badges) | +| `--font-family` | Police unique de l'application | +| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | +| `--shadow-card` | Ombre portée des cartes | +| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | + +Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`, `.form-error`) +sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles +s'appliquent directement à des `
} + + @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: '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 06adf01..8112b51 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -121,3 +121,40 @@ h2 { .alert-item__message { font-size: 0.9rem; } + +.predictions-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prediction-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); +} + +.prediction-item__site { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__value { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__target { + margin-left: 0.35rem; + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 1f285b3..120ff25 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import {AuthService} from '../../core/services/auth.service'; import {Router, provideRouter} from '@angular/router'; @@ -17,18 +18,24 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +function predictionsMock(sites: unknown[] = []) { + return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; +} + describe('Dashboard', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats et les alertes au démarrage', async () => { + 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([]), ], }); @@ -42,7 +49,9 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); + expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.error()).toBeNull(); }); @@ -61,6 +70,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -88,6 +98,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -98,6 +109,30 @@ describe('Dashboard', () => { expect(fixture.componentInstance.alerts().length).toBe(0); }); + it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictions().length).toBe(0); + expect(fixture.componentInstance.error()).not.toBeNull(); + }); + it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; @@ -108,6 +143,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -137,6 +173,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -164,6 +201,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -179,4 +217,26 @@ describe('Dashboard', () => { dashboard.badgeToneForSeverity('critical'), ); }); + + it('distingue le ton des statuts de prévision', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + const dashboard = fixture.componentInstance; + + expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); + expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning'); + expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger'); + }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..d41d258 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; -import { DecimalPipe } from '@angular/common'; +import { DecimalPipe, DatePipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { Card } from '../../shared/components/ui/card/card'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; @@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record = { critical: 'critical', }; +// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le +// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que +// tomber sur `undefined` si ce statut apparaît un jour. +const TON_PAR_STATUT_PREDICTION: Record = { + available: 'success', + insufficient_data: 'warning', + error: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, imports: [ DecimalPipe, + DatePipe, RouterLink, ConsumptionGauge, SiteLoadChart, @@ -47,12 +59,14 @@ const TON_PAR_SEVERITE: Record = { export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); + private predictionsService = inject(PredictionsService); private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); alerts = signal([]); + predictions = signal([]); error = signal(null); ngOnInit(): void { @@ -61,6 +75,13 @@ export class Dashboard implements OnInit { .pipe(catchError(() => this.reportUnavailable())) .subscribe((alerts) => this.alerts.set(alerts)); + // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul + // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. + this.predictionsService + .getPredictions() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((summary) => this.predictions.set(summary.sites)); + // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) @@ -80,6 +101,10 @@ export class Dashboard implements OnInit { return TON_PAR_SEVERITE[severity]; } + badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { + return TON_PAR_STATUT_PREDICTION[status]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/models/prediction.model.ts b/apps/frontend/src/app/shared/models/prediction.model.ts new file mode 100644 index 0000000..50d726a --- /dev/null +++ b/apps/frontend/src/app/shared/models/prediction.model.ts @@ -0,0 +1,24 @@ +export type PredictionStatus = 'available' | 'insufficient_data' | 'error'; +export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw'; + +export interface SitePrediction { + target_at: string; + target_metric: PredictionTargetMetric; + period_minutes: number | null; + predicted_value: number | null; + status: PredictionStatus; + failure_reason: string | null; + model_reference: string; + created_at: string; +} + +export interface SitePredictionSummary { + site_id: string; + site_name: string; + prediction: SitePrediction | null; +} + +export interface PredictionSummary { + timestamp: string; + sites: SitePredictionSummary[]; +} diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index de73599..b7f6d10 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -13,24 +13,28 @@ Ce qui est en place : - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et `provideHttpClient(withInterceptors([mockApiInterceptor]))`. - Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le +- `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. +- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, + changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit réutiliser plutôt que redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. +- 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 : -- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. -- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que - les routes métier de l'API en exigent un. Voir - [31-contrat-authentification.md](31-contrat-authentification.md). +- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, + `GET /api/v1/alerts` et `GET /api/v1/predictions` 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. - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -81,22 +85,20 @@ sequenceDiagram S-->>C: modèle typé ``` -`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. +`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement +si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en +production, suivent le chemin réel. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. -En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue -le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. - -**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, -pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : -telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier -déploiement, en même temps que sera tranchée la question de l'ingress dans -[10-infra.md](10-infra.md). +En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif +(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur +`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...` +sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le +backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md). ## Exécution @@ -124,9 +126,10 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun - intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui - que les fixtures. +- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et + `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le + rafraîchissement sur 401. Détail complet dans + [31-contrat-authentification.md](31-contrat-authentification.md). ## Tests From 12fb8860d1cd82a6925f0cb7217eb3e3fa7089f2 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 11:26:45 +0200 Subject: [PATCH 158/205] chore: fichier de configuration pour chaque module (front et back) --- apps/backend/sonar-project.properties | 15 +++++++++++++++ .../frontend/sonar-project.properties | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 apps/backend/sonar-project.properties rename sonar-project.properties => apps/frontend/sonar-project.properties (65%) diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties new file mode 100644 index 0000000..f828cf3 --- /dev/null +++ b/apps/backend/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=ProjetPiscine_EnerVision +sonar.organization=groupe3-ener-vision +sonar.sourceEncoding=UTF-8 + +# Dossier contenant le code source +sonar.sources=app +# Dossier contenant les tests +sonar.tests=tests + +# Liste des fichiers et dossiers à exclure de l'analyse +sonar.exclusions=.pytest_cache,.venv,alembic,tests + +# Chemin vers le rapport de couverture de code +# Fichier généré par Pytest +sonar.python.coverage.reportPaths=cov.info diff --git a/sonar-project.properties b/apps/frontend/sonar-project.properties similarity index 65% rename from sonar-project.properties rename to apps/frontend/sonar-project.properties index 7155d42..22f1522 100644 --- a/sonar-project.properties +++ b/apps/frontend/sonar-project.properties @@ -2,8 +2,12 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -sonar.sources=apps/frontend/src,apps/backend/app +# Dossier contenant le code source +sonar.sources=src +# Liste des fichiers et dossiers à exclure de l'analyse sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json +# Chemin vers le rapport de couverture de code +# Fichier généré par Vitest sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 7f710c90845f8c07528a90c6d2a87985381d9816 Mon Sep 17 00:00:00 2001 From: Valentin Date: Fri, 18 Sep 2026 12:02:48 +0200 Subject: [PATCH 159/205] feat(frontend): supervision des capteurs par site (admin) --- apps/frontend/src/app/app.routes.ts | 7 ++ .../app/core/services/sensors.service.spec.ts | 48 +++++++++ .../src/app/core/services/sensors.service.ts | 13 +++ .../src/app/features/dashboard/dashboard.html | 3 + .../src/app/features/dashboard/dashboard.scss | 9 ++ .../app/features/dashboard/dashboard.spec.ts | 10 +- .../src/app/features/dashboard/dashboard.ts | 2 +- .../sensor-status/sensor-status.html | 49 +++++++++ .../sensor-status/sensor-status.scss | 90 +++++++++++++++++ .../sensor-status/sensor-status.spec.ts | 99 +++++++++++++++++++ .../monitoring/sensor-status/sensor-status.ts | 65 ++++++++++++ .../app/shared/models/sensor-status.model.ts | 28 ++++++ 12 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 apps/frontend/src/app/core/services/sensors.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/sensors.service.ts create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts create mode 100644 apps/frontend/src/app/shared/models/sensor-status.model.ts diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index e619268..d8fe1d6 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -25,4 +25,11 @@ export const routes: Routes = [ (m) => m.SiteDetailPlaceholder, ), }, + { + path: 'monitoring/sensors', + canActivate: [authGuard], + data: { role: 'admin' }, + loadComponent: () => + import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), +}, ]; diff --git a/apps/frontend/src/app/core/services/sensors.service.spec.ts b/apps/frontend/src/app/core/services/sensors.service.spec.ts new file mode 100644 index 0000000..d9cac1d --- /dev/null +++ b/apps/frontend/src/app/core/services/sensors.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { SensorsService } from './sensors.service'; +import { environment } from '../../../environments/environment'; + +describe('SensorsService', () => { + let service: SensorsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SensorsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("appelle l'endpoint /sensors/status et retourne la réponse", () => { + let result: unknown; + service.getStatus().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-18T08:00:00', + sites: [ + { + site_id: 'SITE001', + site_name: 'Test', + overall: 'ok', + sensors: { + consumption: { status: 'ok', since: null }, + electrical: { status: 'ok', since: null }, + temperature: { status: 'ok', since: null }, + humidity: { status: 'ok', since: null }, + network: { status: 'ok', since: null }, + }, + }, + ], + }); + + expect((result as { sites: unknown[] }).sites.length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/sensors.service.ts b/apps/frontend/src/app/core/services/sensors.service.ts new file mode 100644 index 0000000..4976505 --- /dev/null +++ b/apps/frontend/src/app/core/services/sensors.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import {SensorStatusResponse} from '../../shared/models/sensor-status.model'; + +@Service() +export class SensorsService { + private http = inject(HttpClient); + + getStatus() { + return this.http.get(`${environment.apiUrl}/sensors/status`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 08b015e..43751bf 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -10,6 +10,9 @@
+ @if (auth.principal()?.role === 'admin') { + Supervision des capteurs + } Voir les sites { 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() }; - + const authMock = { + logout: vi.fn().mockReturnValue(of(undefined)), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; TestBed.configureTestingModule({ imports: [Dashboard], providers: [ @@ -128,9 +131,10 @@ describe('Dashboard', () => { 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 = { + 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], diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..efd0ffa 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -47,7 +47,7 @@ const TON_PAR_SEVERITE: Record = { export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); - private auth = inject(AuthService); + public auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html new file mode 100644 index 0000000..e918c90 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html @@ -0,0 +1,49 @@ +
+ + +
+ + +
+

Supervision des capteurs

+

État de santé par capteur et par site

+
+
+ + @if (error(); as message) { + + } + + @if (data(); as d) { +
+ @for (site of d.sites; track site.site_id) { + +
+ {{ site.site_name }} + {{ site.overall }} +
+ +
    + @for (entry of sensorEntries; track entry[0]) { +
  • + + {{ entry[1] }} + @if (sensorOf(site.sensors, entry[0]).status === 'failing') { + + depuis {{ sensorOf(site.sensors, entry[0]).since | date: 'short' }} + + } +
  • + } +
+
+ } +
+ } +
diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss new file mode 100644 index 0000000..7af9d32 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss @@ -0,0 +1,90 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.sensor-status__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.sensor-status__logo { + font-size: 1.3rem; +} + +.sensor-status__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.sites-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.site-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.site-card__name { + font-weight: 600; +} + +.sensor-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sensor-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.sensor-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--ok { + background: var(--color-success); + } + &--failing { + background: var(--color-danger); + } +} + +.sensor-item__label { + flex: 1; +} + +.sensor-item__since { + color: var(--color-text-muted); + font-size: 0.75rem; +} diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts new file mode 100644 index 0000000..189e241 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { SensorStatusView } from './sensor-status'; +import { SensorsService } from '../../../core/services/sensors.service'; +import { SiteSensors } from '../../../shared/models/sensor-status.model'; +import {provideRouter} from '@angular/router'; + +const OK_SENSORS: SiteSensors = { + consumption: { status: 'ok', since: null }, + electrical: { status: 'ok', since: null }, + temperature: { status: 'ok', since: null }, + humidity: { status: 'ok', since: null }, + network: { status: 'ok', since: null }, +}; + +describe('SensorStatusView', () => { + let sensorsMock: { getStatus: ReturnType }; + + beforeEach(() => { + sensorsMock = { getStatus: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [SensorStatusView], + providers: [ + { provide: SensorsService, useValue: sensorsMock }, + provideRouter([]), + ], + }); + }); + + it('charge et affiche les données au démarrage', () => { + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [ + { site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS }, + ], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.componentInstance.data()?.sites.length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Bureau Test'); + }); + + it("affiche un message d'erreur si l'appel échoue", () => { + sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom'))); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).toBe( + 'État des capteurs indisponible, réessayez plus tard.' + ); + expect(fixture.componentInstance.data()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible'); + }); + + it('associe le bon ton de badge à chaque statut global', () => { + sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] })); + const fixture = TestBed.createComponent(SensorStatusView); + const component = fixture.componentInstance; + + expect(component.badgeToneForOverall('ok')).toBe('success'); + expect(component.badgeToneForOverall('degraded')).toBe('warning'); + expect(component.badgeToneForOverall('critical')).toBe('critical'); + expect(component.badgeToneForOverall('inconnu')).toBe('neutral'); + }); + + it('retourne le bon diagnostic via sensorOf', () => { + sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] })); + const fixture = TestBed.createComponent(SensorStatusView); + const component = fixture.componentInstance; + + expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null }); + }); + + it('affiche la date depuis quand un capteur est en panne', () => { + const sensors: SiteSensors = { + ...OK_SENSORS, + temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('depuis'); + }); +}); diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts new file mode 100644 index 0000000..13abcc2 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts @@ -0,0 +1,65 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { catchError, EMPTY, Observable } from 'rxjs'; +import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge'; +import {Card} from '../../../shared/components/ui/card/card'; +import {Alert} from '../../../shared/components/ui/alert/alert'; +import {Brand} from '../../../shared/components/ui/brand/brand'; +import {SensorsService} from '../../../core/services/sensors.service'; +import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model'; +import { DatePipe } from '@angular/common'; + +const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.'; + +const SENSOR_LABELS: Record = { + consumption: 'Consommation', + electrical: 'Électrique', + temperature: 'Température', + humidity: 'Humidité', + network: 'Réseau', +}; + +const TON_PAR_OVERALL: Record = { + ok: 'success', + degraded: 'warning', + critical: 'critical', +}; + +@Component({ + selector: 'app-sensor-status', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe], + templateUrl: './sensor-status.html', + styleUrl: './sensor-status.scss', +}) +export class SensorStatusView implements OnInit { + private sensorsService = inject(SensorsService); + + data = signal(null); + error = signal(null); + + readonly sensorEntries = Object.entries(SENSOR_LABELS); + + ngOnInit(): void { + this.sensorsService + .getStatus() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((response) => { + this.error.set(null); + this.data.set(response); + }); + } + + sensorOf(sensors: Record, key: string): SensorDiagnostic { + return sensors[key]; + } + + badgeToneForOverall(overall: string): BadgeTone { + return TON_PAR_OVERALL[overall] ?? 'neutral'; + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/models/sensor-status.model.ts b/apps/frontend/src/app/shared/models/sensor-status.model.ts new file mode 100644 index 0000000..c10b743 --- /dev/null +++ b/apps/frontend/src/app/shared/models/sensor-status.model.ts @@ -0,0 +1,28 @@ +export type SensorStatus = 'ok' | 'failing'; +export type OverallStatus = 'ok' | 'degraded' | 'critical'; + +export interface SensorDiagnostic { + status: SensorStatus; + since: string | null; +} + +export interface SiteSensors { + consumption: SensorDiagnostic; + electrical: SensorDiagnostic; + temperature: SensorDiagnostic; + humidity: SensorDiagnostic; + network: SensorDiagnostic; + [key: string]: SensorDiagnostic; +} + +export interface SiteSensorStatus { + site_id: string; + site_name: string; + sensors: SiteSensors; + overall: OverallStatus; +} + +export interface SensorStatusResponse { + timestamp: string; + sites: SiteSensorStatus[]; +} From 7eef960a303ddef423a504f363fb6d1d14ce1b42 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:08:59 +0200 Subject: [PATCH 160/205] =?UTF-8?q?test(backend):=20classe=20les=20routes?= =?UTF-8?q?=20du=20contrat=20et=20d=C3=A9rive=20les=20listes=20d'autorisat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ROUTES_A_ROLE` était recopiée dans `test_openapi.py`, et deux de ses entrées portaient `{id}` là où le contrat expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et `test_every_role_guarded_route_documents_the_role_refusal` passait au vert sans rien vérifier sur `PATCH /users/{user_id}` ni sur sa réinitialisation de mot de passe : 11 des 13 routes gardées étaient réellement couvertes. `tests/api/acces.py` porte désormais la classification des 24 routes du contrat en quatre ensembles, dont la table `ROLE_MINIMUM`, et `test_every_declared_route_is_classified` refuse aussi bien une route non classée qu'une entrée qui ne correspond plus à rien. C'est ce que `docs/architecture/20-backend.md` annonçait comme impossible : « ces deux listes sont maintenues à la main, pas dérivées ». Au passage, `chemin_concret()` substitue les trois gabarits du contrat et non plus le seul `{user_id}`, ce qui est sans effet sur le refus anonyme mais nécessaire à un appel qui doit aboutir. --- apps/backend/tests/api/acces.py | 86 +++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 21 ++--- .../tests/api/test_route_protection.py | 67 ++++++++------- 3 files changed, 129 insertions(+), 45 deletions(-) create mode 100644 apps/backend/tests/api/acces.py diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py new file mode 100644 index 0000000..0b2864f --- /dev/null +++ b/apps/backend/tests/api/acces.py @@ -0,0 +1,86 @@ +# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py, +# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune +# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que +# rien ne le signale. +# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est +# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture. + +from typing import Final + +from app.core.roles import Role + +Route = tuple[str, str] + +ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset( + { + ("GET", "/api/v1/health/live"), + ("GET", "/api/v1/health/ready"), + ("POST", "/api/v1/auth/login"), + # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), + # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un + # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. + ("GET", "/api/v1/auth/reset-password/validate"), + ("GET", "/metrics"), + } +) + +# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal` +# n'est construit et `require_role` n'entre jamais en jeu. +ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")}) + +# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du +# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état +# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py. +ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset( + { + ("GET", "/api/v1/auth/me"), + ("POST", "/api/v1/auth/password"), + ("POST", "/api/v1/auth/logout-all"), + } +) + +ROLE_MINIMUM: Final[dict[Route, Role]] = { + ("GET", "/api/v1/sites"): Role.LECTEUR, + ("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR, + ("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR, + ("GET", "/api/v1/alerts"): Role.LECTEUR, + ("GET", "/api/v1/recommendations"): Role.LECTEUR, + ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, + ("GET", "/api/v1/stats/summary"): Role.LECTEUR, + ("GET", "/api/v1/readings"): Role.LECTEUR, + ("GET", "/api/v1/sensors/status"): Role.ADMIN, + ("GET", "/api/v1/users"): Role.ADMIN, + ("POST", "/api/v1/users"): Role.ADMIN, + ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, + ("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN, +} + +# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution +# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test +# passerait en prouvant autre chose que ce qu'il annonce. +SUBSTITUTIONS: Final[dict[str, str]] = { + "{user_id}": "00000000-0000-0000-0000-000000000000", + "{site_id}": "site-absent-du-jeu-de-donnees", + "{recommendation_id}": "999999999", +} + + +def chemin_concret(chemin: str) -> str: + for gabarit, valeur in SUBSTITUTIONS.items(): + chemin = chemin.replace(gabarit, valeur) + return chemin + + +def routes_du_schema(schema: dict[str, object]) -> list[Route]: + chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment] + return [ + (methode.upper(), chemin) + for chemin, operations in chemins.items() + for methode in operations + if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} + ] diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 85432c4..50e3c3a 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -8,6 +8,7 @@ from typing import Any import pytest from app import cli +from tests.api.acces import ROLE_MINIMUM METHODES = {"get", "post", "patch", "put", "delete"} @@ -24,21 +25,11 @@ ORIGINE_VERIFIEE = { # Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour # `password_change_required`, pas seulement les routes `admin`. -ROUTES_A_ROLE = { - ("GET", "/api/v1/users"), - ("POST", "/api/v1/users"), - ("PATCH", "/api/v1/users/{id}"), - ("POST", "/api/v1/users/{id}/password-reset"), - ("GET", "/api/v1/sites"), - ("GET", "/api/v1/sites/{site_id}"), - ("GET", "/api/v1/sites/{site_id}/current"), - ("GET", "/api/v1/alerts"), - ("GET", "/api/v1/recommendations"), - ("GET", "/api/v1/recommendations/{recommendation_id}"), - ("GET", "/api/v1/stats/summary"), - ("GET", "/api/v1/readings"), - ("GET", "/api/v1/sensors/status"), -} +# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat +# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous +# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et +# `test_every_declared_route_is_classified` interdit l'entrée morte. +ROUTES_A_ROLE = frozenset(ROLE_MINIMUM) @pytest.fixture(scope="module") diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 25e4760..ebaa8ff 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -1,6 +1,6 @@ # Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier -# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et -# demande une justification au relecteur. +# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull +# request et demande une justification au relecteur. # Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de # dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route # peut porter la bonne dépendance tout en répondant quand même. @@ -11,58 +11,64 @@ import pytest from fastapi import FastAPI from httpx import AsyncClient -ROUTES_PUBLIQUES = frozenset( - { - ("GET", "/api/v1/health/live"), - ("GET", "/api/v1/health/ready"), - ("POST", "/api/v1/auth/login"), - # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. - ("POST", "/api/v1/auth/logout"), - ("POST", "/api/v1/auth/forgot-password"), - # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune - # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. - ("POST", "/api/v1/auth/reset-password"), - # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un - # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. - ("GET", "/api/v1/auth/reset-password/validate"), - ("GET", "/metrics"), - } +from tests.api.acces import ( + ROLE_MINIMUM, + ROUTE_COOKIE, + ROUTES_PUBLIQUES, + ROUTES_SANS_ROLE, + Route, + chemin_concret, + routes_du_schema, ) -VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000" STATUTS_DE_REFUS = {401, 403} +HORS_SCHEMA = {("GET", "/metrics")} -def routes_declarees(app: FastAPI) -> list[tuple[str, str]]: +def routes_declarees(app: FastAPI) -> list[Route]: schema: dict[str, Any] = app.openapi() - return [ - (methode.upper(), chemin) - for chemin, operations in schema["paths"].items() - for methode in operations - if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} - ] + return routes_du_schema(schema) -def routes_protegees(app: FastAPI) -> list[tuple[str, str]]: +def routes_protegees(app: FastAPI) -> list[Route]: return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES] def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None: - declarees = set(routes_declarees(app)) | {("GET", "/metrics")} + declarees = set(routes_declarees(app)) | HORS_SCHEMA inconnues = ROUTES_PUBLIQUES - declarees assert inconnues == set() +# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite +# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue. +def test_every_declared_route_is_classified(app: FastAPI) -> None: + classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM) + + non_classees = set(routes_declarees(app)) - classees + fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA + + assert non_classees == set(), "classer la route dans tests/api/acces.py" + assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin" + + +def test_the_four_classes_of_routes_stay_disjoint() -> None: + classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)] + + for rang, classe in enumerate(classes): + for autre in classes[rang + 1 :]: + assert classe & autre == frozenset() + + async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public( app: FastAPI, client: AsyncClient ) -> None: ouvertes: list[tuple[str, str, int]] = [] for methode, chemin in routes_protegees(app): - concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION) - response = await client.request(methode, concret, json={}) + response = await client.request(methode, chemin_concret(chemin), json={}) if response.status_code not in STATUTS_DE_REFUS: ouvertes.append((methode, chemin, response.status_code)) @@ -95,4 +101,5 @@ async def test_the_documentation_routes_are_public_by_design( app: FastAPI, client: AsyncClient, chemin: str ) -> None: response = await client.get(chemin) + assert response.status_code == 200 From 1cd3688256484bfabf73b4dbf1d4c8f9c710592c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:08:59 +0200 Subject: [PATCH 161/205] =?UTF-8?q?test(backend):=20croise=20chaque=20rout?= =?UTF-8?q?e=20gard=C3=A9e=20avec=20les=20trois=20r=C3=B4les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dépôt vérifiait le refus d'un lecteur sur les cinq routes `admin`, et rien de plus. Les huit routes `lecteur` n'étaient jouées qu'avec un lecteur : une garde posée trop haut, par exemple `AdminDep` sur `/sites`, n'aurait fait échouer aucun test. La matrice couvre les deux sens. Un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le reçoit jamais. L'assertion porte sur le refus de la garde et pas sur un 200, sans quoi elle dépendrait du contenu de la base : un 404 ou un 422 est une réponse acceptable, un 403 non. Sous le marqueur `integration`, la même matrice est rejouée avec de vrais jetons obtenus par `/auth/login`, donc en traversant le décodage du JWT et la relecture du compte en base que `dependency_overrides` court-circuite. Deux invariants y sont figés : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture métier dans l'API, et `/auth/logout-all` échappe au refus `password_change_required` parce qu'elle prend un `CurrentPrincipalDep` nu. Le second est signalé, pas corrigé. Closes #61 --- apps/backend/tests/api/test_matrice_acces.py | 279 +++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 apps/backend/tests/api/test_matrice_acces.py diff --git a/apps/backend/tests/api/test_matrice_acces.py b/apps/backend/tests/api/test_matrice_acces.py new file mode 100644 index 0000000..5def3a7 --- /dev/null +++ b/apps/backend/tests/api/test_matrice_acces.py @@ -0,0 +1,279 @@ +# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà, +# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement +# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce +# fichier est ce qui la confronte au comportement observé. +# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut +# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le +# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base. +# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en +# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite. + +import uuid +from collections.abc import AsyncIterator, Callable, Iterator + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient, Response +from sqlalchemy import text + +from app.api.deps import get_current_principal +from app.core.hashing import build_hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role, has_at_least +from app.db.session import get_session, get_session_factory +from app.repositories.user import UserRepository +from tests.api.acces import ROLE_MINIMUM, chemin_concret + +ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN] +IDS_DE_ROLE = ["lecteur", "operateur", "admin"] +REFUS_DE_DROITS = "Droits insuffisants" +REFUS_DE_MOT_DE_PASSE = "password_change_required" +MOT_DE_PASSE = "un-mot-de-passe-de-recette" + + +# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui +# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base +# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la +# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté. +class ResultatVide: + def scalars(self) -> ResultatVide: + return self + + def all(self) -> list[object]: + return [] + + def first(self) -> None: + return None + + def one_or_none(self) -> None: + return None + + def scalar_one_or_none(self) -> None: + return None + + def mappings(self) -> ResultatVide: + return self + + def __iter__(self) -> Iterator[object]: + return iter(()) + + +class SessionMuette: + async def scalar(self, *_: object, **__: object) -> None: + return None + + async def execute(self, *_: object, **__: object) -> ResultatVide: + return ResultatVide() + + async def scalars(self, *_: object, **__: object) -> ResultatVide: + return ResultatVide() + + async def get(self, *_: object, **__: object) -> None: + return None + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + def add(self, *_: object, **__: object) -> None: + return None + + +@pytest.fixture +def base_muette(app: FastAPI) -> None: + async def override() -> AsyncIterator[SessionMuette]: + yield SessionMuette() + + app.dependency_overrides[get_session] = override + + +def principal(role: Role, *, must_change_password: bool = False) -> Principal: + return Principal( + id=uuid.uuid4(), + email=f"matrice-{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=must_change_password, + ) + + +@pytest.fixture +def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]: + def installe(acteur: Principal) -> None: + app.dependency_overrides[get_current_principal] = lambda: acteur + + yield installe + app.dependency_overrides.pop(get_current_principal, None) + + +async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response: + return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type] + + +def motif_du_refus(response: Response) -> str | None: + if response.status_code != 403: + return None + detail = response.json().get("detail") + return detail if isinstance(detail, str) else None + + +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, + role: Role, +) -> None: + connecte(principal(role)) + laissees_passer: list[tuple[str, str, int]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + if has_at_least(role, minimum): + continue + response = await appelle(client, methode, chemin) + if motif_du_refus(response) != REFUS_DE_DROITS: + laissees_passer.append((methode, chemin, response.status_code)) + + assert laissees_passer == [] + + +# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur +# `/sites`, ne ferait échouer aucun test du dépôt. +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, + role: Role, +) -> None: + connecte(principal(role)) + refusees: list[tuple[str, str]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + if not has_at_least(role, minimum): + continue + response = await appelle(client, methode, chemin) + if motif_du_refus(response) == REFUS_DE_DROITS: + refusees.append((methode, chemin)) + + assert refusees == [] + + +async def test_a_pending_password_change_is_refused_on_every_guarded_route( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, +) -> None: + connecte(principal(Role.ADMIN, must_change_password=True)) + laissees_passer: list[tuple[str, str, int]] = [] + + for methode, chemin in ROLE_MINIMUM: + response = await appelle(client, methode, chemin) + if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE: + laissees_passer.append((methode, chemin, response.status_code)) + + assert laissees_passer == [] + + +@pytest.fixture +async def comptes_par_role() -> AsyncIterator[dict[Role, str]]: + marque = uuid.uuid4().hex[:12] + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES} + + async with get_session_factory()() as session: + depot = UserRepository(session) + for role, email in adresses.items(): + await depot.create(email=email, password_hash=empreinte, role=role) + await session.commit() + + yield adresses + + async with get_session_factory()() as session: + await session.execute( + text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"} + ) + await session.commit() + + +async def authentifie(client: AsyncClient, email: str) -> dict[str, str]: + reponse = await client.post( + "/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE} + ) + assert reponse.status_code == 200, reponse.text + return {"Authorization": f"Bearer {reponse.json()['access_token']}"} + + +@pytest.mark.integration +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_real_token_reaches_exactly_the_routes_of_its_rank( + comptes_par_role: dict[Role, str], client: AsyncClient, role: Role +) -> None: + entetes = await authentifie(client, comptes_par_role[role]) + ecarts: list[tuple[str, str, int, str]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + response = await appelle(client, methode, chemin, headers=entetes) + refuse = motif_du_refus(response) == REFUS_DE_DROITS + if refuse is has_at_least(role, minimum): + ecarts.append((methode, chemin, response.status_code, response.text[:120])) + + assert ecarts == [] + + +# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture +# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur +# arrive sans que `ROLE_MINIMUM` soit mis à jour. +@pytest.mark.integration +async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank( + comptes_par_role: dict[Role, str], client: AsyncClient +) -> None: + lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR]) + operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR]) + divergences: list[tuple[str, str]] = [] + + for methode, chemin in ROLE_MINIMUM: + cote_lecteur = await appelle(client, methode, chemin, headers=lecteur) + cote_operateur = await appelle(client, methode, chemin, headers=operateur) + if cote_lecteur.status_code != cote_operateur.status_code: + divergences.append((methode, chemin)) + + assert divergences == [] + + +# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate +# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé. +@pytest.mark.integration +async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all( + client: AsyncClient, +) -> None: + marque = uuid.uuid4().hex[:12] + email = f"matrice-{marque}-provisoire@enervision.fr" + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + + async with get_session_factory()() as session: + await UserRepository(session).create( + email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True + ) + await session.commit() + + try: + entetes = await authentifie(client, email) + sites = await client.get("/api/v1/sites", headers=entetes) + identite = await client.get("/api/v1/auth/me", headers=entetes) + fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes) + + assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE + assert identite.status_code == 200 + assert fermeture.status_code == 204 + finally: + async with get_session_factory()() as session: + await session.execute(text("delete from app_user where email = :e"), {"e": email}) + await session.commit() From 173f91f26f39a7604311fd9710eb2fa154c7878d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:09:12 +0200 Subject: [PATCH 162/205] =?UTF-8?q?ci(backend):=20joue=20les=20tests=20d'i?= =?UTF-8?q?nt=C3=A9gration=20sur=20un=20service=20TimescaleDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pyproject.toml` écarte le marqueur `integration` par défaut, et aucun workflow ne montait de base : 97 tests, dont les neuf fichiers de dépôts et le schéma de données, n'avaient jamais été joués ailleurs que sur un poste. La condition avait été déléguée à #20, fermée le 17/09 sans l'avoir livrée. Le job monte l'image de `docker-compose.yml` et non une image `postgres` nue : la première migration refuse de s'appliquer sans l'extension TimescaleDB, et un écart d'image rendrait ce job vert sur une base qui n'est pas la nôtre. `db/init/110-test-database.sql` n'étant pas monté ici, l'extension est créée en une étape avant `alembic upgrade head`. Le job `verification` est inchangé : il reste jouable sans Docker, avec son seuil de couverture de 85 %. --- .github/workflows/backend.yml | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index b146eb5..eb0eca7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -56,3 +56,63 @@ jobs: # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. - name: Tests et couverture run: uv run pytest --cov-fail-under=85 + + # Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première + # migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un + # écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre. + integration: + name: Tests exigeant une base + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + services: + db: + image: timescale/timescaledb-ha:pg17 + env: + POSTGRES_USER: enervision + POSTGRES_PASSWORD: change_me + POSTGRES_DB: enervision_test + ports: + - "5433:5432" + options: >- + --health-cmd "pg_isready -U enervision -d enervision_test" + --health-interval 10s + --health-timeout 5s + --health-retries 12 + --health-start-period 40s + + env: + DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test + APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur + PGPASSWORD: change_me + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + # Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est + # pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1. + - name: Active TimescaleDB sur la base de test + run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb" + + - name: Applique les migrations + run: uv run alembic upgrade head + + # `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job + # ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %. + - name: Tests d'intégration + run: uv run pytest -m integration --no-cov From feee6c3ffc4ec65257f3de8c993bd62e24b3fd0f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:09:12 +0200 Subject: [PATCH 163/205] =?UTF-8?q?docs(backend):=20documente=20la=20class?= =?UTF-8?q?ification=20des=20routes=20et=20la=20CI=20d'int=C3=A9gration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La checklist « ajouter une route métier » demandait de maintenir deux listes à la main en prévenant qu'une route oubliée n'y serait pas détectée. Elle pointe désormais vers `tests/api/acces.py`, où l'oubli échoue. Corrige au passage « quatre routes seulement sont publiques » : il y en a sept dans le contrat, les deux sondes, `/auth/login`, `/auth/logout`, `/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans le jeton à usage unique plutôt que dans un `Principal`. `TESTING.md` précise que les tests `integration` ne sont plus facultatifs : ils cassent la CI comme les autres. --- apps/backend/TESTING.md | 24 +++++++++++++++++++++--- docs/architecture/20-backend.md | 28 ++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index e0daf47..30fcc5a 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -123,6 +123,11 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus. +Ces tests ne sont pas pour autant facultatifs : le job `integration` de +`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et +les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local, +`make db-up` puis `make test-integration`. + ## Couverture Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique @@ -142,14 +147,27 @@ uv run pytest tests/api/test_health.py # un seul fichier uv run pytest -k readiness # par motif de nom ``` -## Trois fichiers à connaître avant de toucher à l'authentification +## Quatre fichiers à connaître avant de toucher à l'authentification + +`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles : +`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas +un fichier de test, c'est la référence que les trois autres confrontent au comportement observé. +**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue +sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route. `tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose -donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la -diff d'une pull request. +donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff +d'une pull request. + +`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans +les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le +reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple +`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais +jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que +`dependency_overrides` court-circuite. `tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index ac14fb1..a224918 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -155,10 +155,12 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier `openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. -**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. +**Sept routes du contrat sont publiques** : les deux sondes, `/auth/login`, `/auth/logout`, +`/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans +le jeton à usage unique plutôt que dans un `Principal`. `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose -donc de modifier la liste dans ce fichier de test. +donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`. `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour `GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches @@ -273,11 +275,16 @@ Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendat au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux (404, 409, ...) directement sur l'endpoint qui les rend. 2. Décrire son tag dans `TAGS`. -3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à - `ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, - l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : - une route oubliée n'y est pas détectée automatiquement. -4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. +3. **La classer dans `tests/api/acces.py`** : `ROLE_MINIMUM` avec son rôle minimum si elle passe + par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), `ROUTES_SANS_ROLE` si elle se + contente de `CurrentPrincipalDep`, `ROUTES_PUBLIQUES` si elle est ouverte. L'oubli n'est plus + silencieux : `test_every_declared_route_is_classified` échoue sur une route non classée comme + sur une entrée qui ne correspond plus à aucune route. `ROUTES_A_ROLE` de `test_openapi.py` en + est dérivée, et `test_matrice_acces.py` vérifie le niveau réellement monté. +4. Si elle passe par `require_trusted_origin`, l'ajouter à `ORIGINE_VERIFIEE` dans + `tests/api/test_openapi.py`. **Cette liste-là reste maintenue à la main.** +5. `make openapi`, puis `uv run pytest tests/api/test_openapi.py tests/api/test_route_protection.py + tests/api/test_matrice_acces.py`. ## Sécurité @@ -328,9 +335,14 @@ Le reste, par ordre de surface : Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). -Trois fichiers méritent d'être connus avant de toucher à l'authentification : +Quatre fichiers méritent d'être connus avant de toucher à l'authentification : +- `tests/api/acces.py` : la classification des routes, `ROUTES_PUBLIQUES` et `ROLE_MINIMUM` en + tête. Ce n'est pas un test, c'est la référence que les deux suivants confrontent au + comportement observé. - `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. +- `tests/api/test_matrice_acces.py` : chaque route gardée croisée avec chacun des trois rôles, + dans les deux sens, puis rejouée sous `integration` avec de vrais jetons. - `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro appel quand la limite est atteinte. From 18a4be6e38d5b2fedcafe705b28e80d1c60bd39c Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 12:17:43 +0200 Subject: [PATCH 164/205] feat+rollback: job de test sur le backend dans le workflow du front pour sonar, properties de sonar dans la racine du projet --- .github/workflows/frontend.yml | 42 ++++++++++++++++--- apps/backend/sonar-project.properties | 15 ------- ...ect.properties => sonar-project.properties | 4 +- 3 files changed, 39 insertions(+), 22 deletions(-) delete mode 100644 apps/backend/sonar-project.properties rename apps/frontend/sonar-project.properties => sonar-project.properties (80%) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index aff8d71..074365d 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -29,7 +29,7 @@ jobs: - run: npm run build working-directory: apps/frontend - test: + test-front: needs: build runs-on: ubuntu-latest steps: @@ -39,29 +39,59 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci + - name : Installation des dépendances (Front) + run: npm ci working-directory: apps/frontend - - run: npm test --watch=false --code-coverage --coverageReporters=lcov + - name : Lancement des tests et génénration du rapport de couverture (Front) + run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - name: Upload coverage uses: actions/upload-artifact@v4 with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - + + test-back: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + - name : Lancement des tests et génénration du rapport de couverture (Back) + run: uv run pytest --cov-fail-under=85 --cov-report=lcov + working-directory: apps/backend + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: apps/backend/cov.info + sonarqube: - needs: [build, test] + needs: [build, test-front, test-back] name: SonarQube runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Download coverage + - name: Téléchargement du rapport de couverture (Front) uses: actions/download-artifact@v4 with: name: frontend-coverage path: apps/frontend/coverage/frontend + - name: Téléchargement du rapport de couverture (Back) + uses: actions/download-artifact@v4 + with: + name: backend-coverage + path: apps/backend/coverage/backend - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v8 env: diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties deleted file mode 100644 index f828cf3..0000000 --- a/apps/backend/sonar-project.properties +++ /dev/null @@ -1,15 +0,0 @@ -sonar.projectKey=ProjetPiscine_EnerVision -sonar.organization=groupe3-ener-vision -sonar.sourceEncoding=UTF-8 - -# Dossier contenant le code source -sonar.sources=app -# Dossier contenant les tests -sonar.tests=tests - -# Liste des fichiers et dossiers à exclure de l'analyse -sonar.exclusions=.pytest_cache,.venv,alembic,tests - -# Chemin vers le rapport de couverture de code -# Fichier généré par Pytest -sonar.python.coverage.reportPaths=cov.info diff --git a/apps/frontend/sonar-project.properties b/sonar-project.properties similarity index 80% rename from apps/frontend/sonar-project.properties rename to sonar-project.properties index 22f1522..5601347 100644 --- a/apps/frontend/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,8 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Dossier contenant le code source -sonar.sources=src +sonar.sources=apps/frontend/src,apps/backend/app +sonar.tests=apps/backend/tests # Liste des fichiers et dossiers à exclure de l'analyse sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json @@ -11,3 +12,4 @@ sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github, # Chemin vers le rapport de couverture de code # Fichier généré par Vitest sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info +sonar.python.coverage.reportPaths=apps/backend/cov.info \ No newline at end of file From fb06bf0062ceb1d01d86e4e6ce1db74bf57cb1ee Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:23:50 +0200 Subject: [PATCH 165/205] =?UTF-8?q?chore(ci):=20isole=20l'audit=20de=20s?= =?UTF-8?q?=C3=A9curit=C3=A9=20et=20le=20fait=20porter=20sur=20le=20verrou?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'audit backend était la dernière étape du job de vérification : un lint ou un test en échec suffisait à le sauter, et `pip-audit` sans argument auditait l'environnement courant, donc aussi les 28 paquets injectés par son propre `--with`. Il audite maintenant l'export du verrou, dans un job dédié, en symétrie avec le frontend. Côté frontend, `npm audit` lit le verrou et n'a besoin ni de `npm ci` ni du job `build`. Le workflow déclare enfin ses permissions, comme backend.yml et ml.yml. --- .github/workflows/backend.yml | 25 +++++++++++++++++++++++-- .github/workflows/frontend.yml | 17 +++++++---------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index dbe77f6..4fe2016 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -57,5 +57,26 @@ jobs: - name: Tests et couverture run: uv run pytest --cov-fail-under=85 - - name: Audit de sécurité des dépendances - run: uv run --with pip-audit pip-audit + security-audit: + name: Audit des dépendances + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + # L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait + # aussi les paquets que son propre `--with` injecte, hors dépendances du projet. + - name: Audite les dépendances livrées + # Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe. + shell: bash + run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 55db548..a5ae2e8 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,5 +1,4 @@ name: Frontend -# Pipeline à choix multiple on: push: @@ -10,8 +9,9 @@ on: paths: - "apps/frontend/**" - ".github/workflows/frontend.yml" -# Ordre de lancement des jobs -# build -> test -> sonarqube -> deploy + +permissions: + contents: read jobs: build: @@ -30,18 +30,15 @@ jobs: working-directory: apps/frontend security-audit: - needs: build + name: Audit des dépendances runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 - cache: npm - cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci - working-directory: apps/frontend - - run: npm audit --audit-level=high + # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison. + - run: npm audit --audit-level=high --package-lock-only working-directory: apps/frontend test: @@ -63,7 +60,7 @@ jobs: with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - + sonarqube: needs: [build, test] name: SonarQube From f427f8a8f39b19c34302242f999796f09fbeb2f7 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 12:32:18 +0200 Subject: [PATCH 166/205] =?UTF-8?q?chore+feat:=20all=C3=A8gement=20du=20wo?= =?UTF-8?q?rkflow=20front,=20workflow=20pour=20sonarqube?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend.yml | 48 +------------- .github/workflows/sonarqube.yml | 113 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/sonarqube.yml diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 074365d..c8ad03f 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -29,7 +29,7 @@ jobs: - run: npm run build working-directory: apps/frontend - test-front: + test: needs: build runs-on: ubuntu-latest steps: @@ -50,49 +50,3 @@ jobs: with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - - test-back: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Installe uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: apps/backend/uv.lock - - name: Installe l'interpréteur déclaré par .python-version - run: uv python install - - name: Synchronise les dépendances sans dévier du verrou - run: uv sync --all-groups --frozen - - name : Lancement des tests et génénration du rapport de couverture (Back) - run: uv run pytest --cov-fail-under=85 --cov-report=lcov - working-directory: apps/backend - - name: Upload coverage - uses: actions/upload-artifact@v4 - with: - name: backend-coverage - path: apps/backend/cov.info - - sonarqube: - needs: [build, test-front, test-back] - name: SonarQube - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Téléchargement du rapport de couverture (Front) - uses: actions/download-artifact@v4 - with: - name: frontend-coverage - path: apps/frontend/coverage/frontend - - name: Téléchargement du rapport de couverture (Back) - uses: actions/download-artifact@v4 - with: - name: backend-coverage - path: apps/backend/coverage/backend - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v8 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..6200f6b --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,113 @@ +name: SonarQube + +on: + push: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" + pull_request: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" + + +# Build l'ensemble du projet, puis lance les tests +# Génère les rapports de couverture, puis lance l'analyse SonarQube + +jobs: + build-front: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: apps/frontend/package-lock.json + + - run: npm ci + working-directory: apps/frontend + - run: npm run build + working-directory: apps/frontend + + test-front: + needs: build-front + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: apps/frontend/package-lock.json + - name : Installation des dépendances (Front) + run: npm ci + working-directory: apps/frontend + - name : Lancement des tests et génénration du rapport de couverture (Front) + run: npm test --watch=false --code-coverage --coverageReporters=lcov + working-directory: apps/frontend + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend/lcov.info + + build-back: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + test-back: + needs: build-back + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + - name : Lancement des tests et génénration du rapport de couverture (Back) + run: uv run pytest --cov-fail-under=85 --cov-report=lcov + working-directory: apps/backend + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: apps/backend/cov.info + + sonarqube: + needs: [build-front, build-back, test-front, test-back] + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Téléchargement du rapport de couverture (Front) + uses: actions/download-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend + - name: Téléchargement du rapport de couverture (Back) + uses: actions/download-artifact@v4 + with: + name: backend-coverage + path: apps/backend + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v8 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From e3d436ea53707369cf4f956965252d9edf4cfaed Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:13:23 +0200 Subject: [PATCH 167/205] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 6200f6b..08dd8aa 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -40,12 +40,15 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json + - name : Installation des dépendances (Front) run: npm ci working-directory: apps/frontend + - name : Lancement des tests et génénration du rapport de couverture (Front) run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend + - name: Upload coverage uses: actions/upload-artifact@v4 with: @@ -63,8 +66,23 @@ jobs: cache-dependency-glob: apps/backend/uv.lock - name: Installe l'interpréteur déclaré par .python-version run: uv python install + working-directory: apps/backend + - name: Synchronise les dépendances sans dévier du verrou run: uv sync --all-groups --frozen + working-directory: apps/backend + + - name: Vérifie le formatage + run: uv run ruff format --check . + working-directory: apps/backend + + - name: Analyse statique + run: uv run ruff check --output-format=github . + working-directory: apps/backend + + - name: Typage + run: uv run mypy app + test-back: needs: build-back @@ -76,13 +94,11 @@ jobs: with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock - - name: Installe l'interpréteur déclaré par .python-version - run: uv python install - - name: Synchronise les dépendances sans dévier du verrou - run: uv sync --all-groups --frozen + - name : Lancement des tests et génénration du rapport de couverture (Back) run: uv run pytest --cov-fail-under=85 --cov-report=lcov working-directory: apps/backend + - name: Upload coverage uses: actions/upload-artifact@v4 with: From 0b415803103f7a2aeef2b4009165f27c698cb8dd Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:14:50 +0200 Subject: [PATCH 168/205] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 08dd8aa..62ff9fa 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -4,11 +4,13 @@ on: push: paths: - "apps/frontend/**" - - ".github/workflows/frontend.yml" + - "apps/backend/**" + - ".github/workflows/sonarqube.yml" pull_request: paths: - "apps/frontend/**" - - ".github/workflows/frontend.yml" + - "apps/backend/**" + - ".github/workflows/sonarqube.yml" # Build l'ensemble du projet, puis lance les tests From 91d435748cbabbb0a40e1ad85c92eb47b8f3530b Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:17:31 +0200 Subject: [PATCH 169/205] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 62ff9fa..3cd2019 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -84,6 +84,7 @@ jobs: - name: Typage run: uv run mypy app + working-directory: apps/backend test-back: From 56e8f9572976e1d333dcf1d71956aca0be89749f Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:22:31 +0200 Subject: [PATCH 170/205] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 2 +- apps/backend/coverage.lcov | 3070 +++++++++++++++++++++++++++++++ 2 files changed, 3071 insertions(+), 1 deletion(-) create mode 100644 apps/backend/coverage.lcov diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 3cd2019..3062a8b 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -106,7 +106,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: backend-coverage - path: apps/backend/cov.info + path: apps/backend/coverage.lcov sonarqube: needs: [build-front, build-back, test-front, test-back] diff --git a/apps/backend/coverage.lcov b/apps/backend/coverage.lcov new file mode 100644 index 0000000..3ce3aa4 --- /dev/null +++ b/apps/backend/coverage.lcov @@ -0,0 +1,3070 @@ +SF:app\__init__.py +end_of_record +SF:app\api\__init__.py +end_of_record +SF:app\api\deps.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:44,1 +DA:45,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:53,1 +DA:54,1 +DA:61,1 +DA:62,1 +DA:72,1 +DA:73,1 +DA:76,1 +DA:84,1 +DA:85,1 +DA:93,1 +DA:97,1 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,1 +DA:104,1 +DA:105,1 +DA:119,1 +DA:126,1 +DA:154,1 +DA:157,1 +DA:161,0 +DA:170,1 +DA:173,1 +DA:174,1 +DA:177,1 +DA:180,1 +DA:181,1 +DA:184,1 +DA:187,1 +DA:188,1 +DA:191,1 +DA:194,1 +DA:195,0 +DA:198,1 +DA:201,1 +DA:202,1 +DA:205,1 +DA:208,1 +DA:209,0 +DA:212,1 +DA:215,1 +DA:220,1 +DA:221,1 +DA:223,1 +DA:224,1 +DA:225,1 +DA:226,0 +DA:227,1 +DA:228,1 +DA:230,0 +DA:231,0 +DA:232,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:241,0 +DA:250,1 +DA:253,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:259,1 +DA:260,1 +DA:261,1 +DA:263,1 +DA:266,1 +DA:267,1 +DA:268,1 +DA:271,1 +DA:274,1 +DA:275,1 +DA:276,1 +DA:277,1 +DA:278,1 +LF:114 +LH:99 +FN:53,58,_non_authentifie +FNDA:1,_non_authentifie +FN:61,67,get_token_policy +FNDA:1,get_token_policy +FN:73,81,_hasher_cache +FNDA:1,_hasher_cache +FN:84,90,get_hasher +FNDA:1,get_hasher +FN:93,101,get_client_ip +FNDA:1,get_client_ip +FN:104,116,get_mailer +FNDA:1,get_mailer +FN:119,151,get_auth_service +FNDA:1,get_auth_service +FN:157,167,get_user_service +FNDA:0,get_user_service +FN:173,174,get_site_service +FNDA:1,get_site_service +FN:180,181,get_alert_service +FNDA:1,get_alert_service +FN:187,188,get_recommendation_service +FNDA:1,get_recommendation_service +FN:194,195,get_stats_service +FNDA:0,get_stats_service +FN:201,202,get_reading_service +FNDA:1,get_reading_service +FN:208,209,get_sensor_service +FNDA:0,get_sensor_service +FN:215,247,get_current_principal +FNDA:1,get_current_principal +FN:253,263,require_role +FNDA:1,require_role +FN:254,261,require_role.garde +FNDA:1,require_role.garde +FN:271,278,require_trusted_origin +FNDA:1,require_trusted_origin +FNF:18 +FNH:15 +BRDA:97,0,jump to line 98,0 +BRDA:97,0,jump to line 101,1 +BRDA:99,0,jump to line 100,- +BRDA:99,0,jump to line 101,- +BRDA:220,0,jump to line 221,1 +BRDA:220,0,jump to line 223,1 +BRDA:231,0,jump to line 232,- +BRDA:231,0,jump to line 236,- +BRDA:236,0,jump to line 237,- +BRDA:236,0,jump to line 238,- +BRDA:238,0,jump to line 239,- +BRDA:238,0,jump to line 241,- +BRDA:255,0,jump to line 256,1 +BRDA:255,0,jump to line 259,1 +BRDA:259,0,jump to line 260,1 +BRDA:259,0,jump to line 261,1 +BRDA:275,0,jump to line 276,1 +BRDA:275,0,jump to line 277,1 +BRDA:277,0,jump to line 278,1 +BRDA:277,0,return from function 'require_trusted_origin',0 +BRF:20 +BRH:10 +end_of_record +SF:app\api\errors.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:15,1 +DA:18,1 +DA:19,1 +DA:26,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:39,1 +DA:45,1 +DA:46,1 +DA:47,1 +LF:17 +LH:17 +FN:18,28,validation_error_handler +FNDA:1,validation_error_handler +FN:31,42,unhandled_error_handler +FNDA:1,unhandled_error_handler +FN:45,47,register_error_handlers +FNDA:1,register_error_handlers +FNF:3 +FNH:3 +end_of_record +SF:app\api\middleware.py +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:15,1 +DA:21,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:33,1 +DA:34,1 +DA:35,1 +LF:15 +LH:15 +FN:25,35,SecurityHeadersMiddleware.dispatch +FNDA:1,SecurityHeadersMiddleware.dispatch +FNF:1 +FNH:1 +BRDA:29,0,jump to line 30,1 +BRDA:29,0,jump to line 33,1 +BRDA:33,0,jump to line 34,1 +BRDA:33,0,jump to line 35,1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\openapi.py +DA:5,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:14,1 +DA:16,1 +DA:35,1 +DA:88,1 +DA:101,1 +DA:111,1 +DA:121,1 +DA:128,1 +DA:138,1 +DA:151,1 +DA:161,1 +DA:168,1 +LF:17 +LH:17 +end_of_record +SF:app\api\security.py +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +LF:11 +LH:11 +FN:13,23,require_metrics_token +FNDA:1,require_metrics_token +FNF:1 +FNH:1 +BRDA:15,0,jump to line 16,1 +BRDA:15,0,jump to line 18,1 +BRDA:20,0,jump to line 23,1 +BRDA:20,0,return from function 'require_metrics_token',1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\v1\__init__.py +end_of_record +SF:app\api\v1\endpoints\__init__.py +end_of_record +SF:app\api\v1\endpoints\alerts.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:16,1 +DA:22,1 +DA:23,1 +LF:9 +LH:9 +FN:16,23,list_alerts +FNDA:1,list_alerts +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\auth.py +DA:5,1 +DA:7,1 +DA:14,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:33,1 +DA:34,1 +DA:42,1 +DA:43,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:70,1 +DA:81,1 +DA:83,1 +DA:85,1 +DA:94,1 +DA:99,1 +DA:109,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:137,1 +DA:143,1 +DA:151,1 +DA:152,1 +DA:154,1 +DA:155,1 +DA:158,1 +DA:159,1 +DA:160,1 +DA:165,1 +DA:166,1 +DA:167,1 +DA:171,1 +DA:172,1 +DA:175,1 +DA:182,1 +DA:189,1 +DA:191,1 +DA:192,1 +DA:197,1 +DA:198,1 +DA:199,1 +DA:208,1 +DA:211,1 +DA:218,1 +DA:221,1 +DA:222,1 +DA:223,1 +DA:224,1 +DA:225,1 +DA:228,1 +DA:235,1 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:247,1 +DA:253,1 +DA:254,1 +DA:257,1 +DA:264,1 +DA:273,0 +DA:275,0 +DA:276,0 +DA:283,0 +DA:284,0 +DA:288,0 +DA:289,0 +DA:292,1 +DA:298,1 +DA:306,1 +DA:308,1 +DA:309,1 +DA:315,1 +DA:316,1 +DA:317,1 +DA:324,1 +DA:330,1 +DA:331,1 +DA:334,1 +DA:341,1 +DA:349,1 +DA:351,1 +DA:352,1 +DA:358,1 +DA:359,1 +DA:360,1 +DA:364,1 +DA:365,1 +LF:102 +LH:91 +FN:109,118,repond +FNDA:1,repond +FN:124,127,entete_de_suppression +FNDA:1,entete_de_suppression +FN:130,134,lit_le_cookie +FNDA:1,lit_le_cookie +FN:143,172,login +FNDA:1,login +FN:182,208,refresh +FNDA:1,refresh +FN:218,225,logout +FNDA:1,logout +FN:235,244,logout_all +FNDA:0,logout_all +FN:253,254,me +FNDA:1,me +FN:264,289,change_password +FNDA:0,change_password +FN:298,321,forgot_password +FNDA:1,forgot_password +FN:330,331,validate_reset_token +FNDA:1,validate_reset_token +FN:341,365,reset_password +FNDA:1,reset_password +FNF:12 +FNH:10 +BRDA:132,0,jump to line 133,1 +BRDA:132,0,jump to line 134,1 +BRDA:223,0,jump to line 224,1 +BRDA:223,0,jump to line 225,1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\v1\endpoints\health.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:46,1 +DA:47,1 +LF:25 +LH:25 +FN:17,23,liveness +FNDA:1,liveness +FN:27,47,readiness +FNDA:1,readiness +FNF:2 +FNH:2 +BRDA:39,0,jump to line 40,1 +BRDA:39,0,jump to line 46,1 +BRF:2 +BRH:2 +end_of_record +SF:app\api\v1\endpoints\readings.py +DA:1,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:13,1 +DA:25,1 +DA:31,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +DA:49,1 +DA:50,1 +DA:54,1 +LF:18 +LH:18 +FN:31,54,list_readings +FNDA:1,list_readings +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\recommendations.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:17,1 +DA:18,1 +DA:21,1 +DA:22,1 +DA:25,1 +DA:31,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:40,1 +LF:19 +LH:19 +FN:18,22,list_recommendations +FNDA:1,list_recommendations +FN:31,40,get_recommendation +FNDA:1,get_recommendation +FNF:2 +FNH:2 +end_of_record +SF:app\api\v1\endpoints\sensors.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:14,1 +DA:15,1 +DA:16,1 +LF:8 +LH:8 +FN:14,16,get_status +FNDA:1,get_status +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\sites.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:36,1 +DA:39,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:52,1 +LF:26 +LH:26 +FN:18,20,list_sites +FNDA:1,list_sites +FN:29,36,get_site +FNDA:1,get_site +FN:45,52,get_current +FNDA:1,get_current +FNF:3 +FNH:3 +end_of_record +SF:app\api\v1\endpoints\stats.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:14,1 +DA:15,1 +DA:16,1 +LF:8 +LH:8 +FN:14,16,get_summary +FNDA:1,get_summary +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\users.py +DA:1,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:25,1 +DA:30,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:49,1 +DA:56,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:71,1 +DA:72,1 +DA:76,1 +DA:77,1 +DA:83,1 +DA:89,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:103,1 +DA:104,1 +DA:107,1 +DA:108,1 +DA:113,1 +DA:114,1 +DA:117,0 +DA:118,0 +DA:121,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,0 +DA:134,0 +DA:138,1 +DA:139,1 +LF:51 +LH:47 +FN:44,46,list_users +FNDA:1,list_users +FN:56,80,create_user +FNDA:1,create_user +FN:89,118,update_user +FNDA:1,update_user +FN:127,142,reset_password +FNDA:1,reset_password +FNF:4 +FNH:4 +BRDA:97,0,jump to line 98,1 +BRDA:97,0,jump to line 99,1 +BRDA:99,0,jump to line 100,1 +BRDA:99,0,jump to line 113,1 +BRDA:113,0,jump to line 114,1 +BRDA:113,0,jump to line 117,0 +BRF:6 +BRH:5 +end_of_record +SF:app\api\v1\router.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:24,1 +DA:30,1 +DA:31,1 +DA:34,1 +LF:13 +LH:13 +end_of_record +SF:app\cli.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:18,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:35,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:52,0 +DA:58,0 +DA:60,0 +DA:69,1 +DA:70,1 +DA:81,1 +DA:82,1 +DA:83,1 +DA:86,1 +DA:87,1 +DA:90,1 +DA:91,1 +DA:92,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:104,1 +DA:108,1 +DA:111,1 +DA:112,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:123,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:137,1 +DA:138,1 +DA:139,1 +DA:140,1 +DA:141,1 +DA:142,1 +DA:143,1 +DA:144,1 +DA:145,1 +DA:148,1 +DA:149,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:155,0 +DA:157,0 +DA:165,0 +DA:166,0 +LF:83 +LH:68 +FN:32,63,create_admin +FNDA:0,create_admin +FN:69,78,settings_du_contrat +FNDA:1,settings_du_contrat +FN:81,83,schema_du_contrat +FNDA:1,schema_du_contrat +FN:86,87,rend_le_contrat +FNDA:1,rend_le_contrat +FN:90,92,export_openapi +FNDA:1,export_openapi +FN:95,112,build_parser +FNDA:1,build_parser +FN:115,127,genere_mot_de_passe +FNDA:1,genere_mot_de_passe +FN:130,145,read_password +FNDA:1,read_password +FN:148,166,main +FNDA:1,main +FNF:9 +FNH:8 +BRDA:46,0,jump to line 47,- +BRDA:46,0,jump to line 49,- +BRDA:49,0,jump to line 50,- +BRDA:49,0,jump to line 52,- +BRDA:131,0,jump to line 132,1 +BRDA:131,0,jump to line 136,1 +BRDA:137,0,jump to line 138,1 +BRDA:137,0,jump to line 139,1 +BRDA:143,0,jump to line 144,1 +BRDA:143,0,jump to line 145,1 +BRDA:151,0,jump to line 152,1 +BRDA:151,0,jump to line 155,0 +BRF:12 +BRH:7 +end_of_record +SF:app\core\__init__.py +end_of_record +SF:app\core\config.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:17,1 +DA:18,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:50,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:71,1 +DA:72,1 +DA:74,1 +DA:75,1 +DA:76,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:86,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:99,1 +DA:100,0 +DA:104,1 +DA:105,1 +DA:107,1 +DA:108,1 +DA:112,1 +DA:113,1 +DA:115,1 +DA:116,1 +DA:118,1 +DA:121,1 +DA:122,1 +DA:123,1 +LF:84 +LH:83 +FN:75,76,Settings.allowed_origins +FNDA:1,Settings.allowed_origins +FN:79,80,Settings.is_production +FNDA:1,Settings.is_production +FN:83,84,Settings.cookies_are_secure +FNDA:1,Settings.cookies_are_secure +FN:87,90,Settings.api_docs_are_exposed +FNDA:1,Settings.api_docs_are_exposed +FN:93,118,Settings._refuse_les_configurations_dangereuses +FNDA:1,Settings._refuse_les_configurations_dangereuses +FN:122,123,get_settings +FNDA:1,get_settings +FNF:6 +FNH:6 +BRDA:88,0,jump to line 89,1 +BRDA:88,0,jump to line 90,1 +BRDA:95,0,jump to line 96,1 +BRDA:95,0,jump to line 99,1 +BRDA:99,0,jump to line 100,0 +BRDA:99,0,jump to line 104,1 +BRDA:104,0,jump to line 105,1 +BRDA:104,0,jump to line 107,1 +BRDA:107,0,jump to line 108,1 +BRDA:107,0,jump to line 112,1 +BRDA:112,0,jump to line 113,1 +BRDA:112,0,jump to line 115,1 +BRDA:115,0,jump to line 116,1 +BRDA:115,0,jump to line 118,1 +BRF:14 +BRH:13 +end_of_record +SF:app\core\cookies.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:47,1 +DA:48,1 +DA:50,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +LF:22 +LH:22 +FN:24,33,RefreshCookie.build +FNDA:1,RefreshCookie.build +FN:36,45,RefreshCookie.expired +FNDA:1,RefreshCookie.expired +FN:47,48,RefreshCookie.as_kwargs +FNDA:1,RefreshCookie.as_kwargs +FN:50,55,RefreshCookie.as_deletion_kwargs +FNDA:1,RefreshCookie.as_deletion_kwargs +FN:58,61,cookie_name +FNDA:1,cookie_name +FNF:5 +FNH:5 +BRDA:59,0,jump to line 60,1 +BRDA:59,0,jump to line 61,1 +BRF:2 +BRH:2 +end_of_record +SF:app\core\hashing.py +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:31,1 +DA:32,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:47,1 +DA:54,1 +LF:29 +LH:29 +FN:20,23,Argon2Hasher.__init__ +FNDA:1,Argon2Hasher.__init__ +FN:25,26,Argon2Hasher.hash +FNDA:1,Argon2Hasher.hash +FN:28,29,Argon2Hasher.verify +FNDA:1,Argon2Hasher.verify +FN:31,32,Argon2Hasher.verify_dummy +FNDA:1,Argon2Hasher.verify_dummy +FN:34,38,Argon2Hasher.needs_rehash +FNDA:1,Argon2Hasher.needs_rehash +FN:40,44,Argon2Hasher._verify +FNDA:1,Argon2Hasher._verify +FN:47,63,build_hasher +FNDA:1,build_hasher +FNF:7 +FNH:7 +end_of_record +SF:app\core\logging.py +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:13,1 +DA:15,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:91,1 +DA:92,1 +LF:24 +LH:24 +FN:31,34,redact +FNDA:1,redact +FN:38,44,RedactingFilter.filter +FNDA:1,RedactingFilter.filter +FN:47,88,configure_logging +FNDA:1,configure_logging +FN:91,92,get_logger +FNDA:1,get_logger +FNF:4 +FNH:4 +BRDA:32,0,jump to line 33,1 +BRDA:32,0,jump to line 34,1 +BRDA:41,0,jump to line 42,1 +BRDA:41,0,jump to line 44,1 +BRF:4 +BRH:4 +end_of_record +SF:app\core\mailer.py +DA:4,1 +DA:5,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:40,0 +DA:48,0 +LF:18 +LH:11 +FN:25,26,Mailer.__init__ +FNDA:1,Mailer.__init__ +FN:28,48,Mailer.send_password_reset_email +FNDA:0,Mailer.send_password_reset_email +FNF:2 +FNH:1 +end_of_record +SF:app\core\principal.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:12,1 +DA:13,1 +LF:5 +LH:5 +end_of_record +SF:app\core\roles.py +DA:1,1 +DA:2,1 +DA:5,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:18,1 +DA:25,1 +DA:26,1 +LF:12 +LH:12 +FN:25,26,has_at_least +FNDA:1,has_at_least +FNF:1 +FNH:1 +end_of_record +SF:app\core\security.py +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:34,1 +DA:35,1 +DA:42,1 +DA:43,1 +DA:51,1 +DA:59,1 +DA:60,1 +DA:77,1 +DA:78,1 +DA:79,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,1 +DA:92,1 +DA:93,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:101,1 +DA:110,1 +DA:111,1 +DA:116,1 +DA:117,1 +LF:41 +LH:41 +FN:51,74,encode_access_token +FNDA:1,encode_access_token +FN:77,107,decode_access_token +FNDA:1,decode_access_token +FN:110,111,generate_refresh_secret +FNDA:1,generate_refresh_secret +FN:116,117,fingerprint_refresh +FNDA:1,fingerprint_refresh +FNF:4 +FNH:4 +BRDA:92,0,jump to line 93,1 +BRDA:92,0,jump to line 95,1 +BRF:2 +BRH:2 +end_of_record +SF:app\db\__init__.py +end_of_record +SF:app\db\base.py +DA:1,1 +DA:4,1 +LF:2 +LH:2 +end_of_record +SF:app\db\session.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:33,1 +LF:14 +LH:14 +FN:15,23,get_engine +FNDA:1,get_engine +FN:27,28,get_session_factory +FNDA:1,get_session_factory +FN:31,33,get_session +FNDA:1,get_session +FNF:3 +FNH:3 +end_of_record +SF:app\etl\__init__.py +end_of_record +SF:app\etl\historical_import.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:14,1 +DA:16,1 +DA:34,1 +DA:42,1 +DA:45,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:56,1 +DA:58,1 +DA:59,1 +DA:61,1 +DA:62,0 +DA:64,1 +DA:67,1 +DA:76,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:81,0 +DA:82,1 +DA:83,1 +DA:85,1 +DA:87,1 +DA:89,1 +DA:92,1 +DA:97,1 +DA:99,1 +DA:100,1 +DA:102,1 +DA:104,1 +DA:105,0 +DA:107,1 +DA:108,1 +DA:110,1 +DA:111,1 +DA:115,1 +DA:117,1 +DA:118,1 +DA:120,1 +DA:122,1 +DA:123,0 +DA:127,1 +DA:133,1 +DA:143,1 +DA:145,1 +DA:147,1 +DA:152,1 +DA:153,1 +DA:155,0 +DA:157,1 +DA:159,1 +DA:162,1 +DA:167,1 +DA:168,0 +DA:170,1 +DA:171,1 +DA:172,1 +DA:173,0 +DA:174,0 +DA:176,1 +DA:177,1 +DA:179,1 +DA:180,0 +DA:182,1 +DA:185,1 +DA:198,0 +DA:212,0 +DA:214,0 +DA:215,0 +DA:217,0 +DA:228,0 +DA:260,0 +DA:263,1 +DA:268,0 +DA:281,0 +DA:304,1 +DA:312,1 +DA:314,1 +DA:319,1 +DA:320,1 +DA:322,1 +DA:330,1 +DA:332,1 +DA:366,1 +DA:369,1 +DA:418,1 +DA:435,0 +DA:437,0 +DA:439,0 +DA:444,0 +DA:445,0 +DA:446,0 +DA:447,0 +DA:449,0 +DA:450,0 +DA:452,0 +DA:454,0 +DA:456,0 +DA:457,0 +DA:458,0 +DA:460,0 +DA:465,0 +DA:467,0 +DA:472,0 +DA:473,0 +DA:474,0 +DA:482,0 +DA:487,0 +DA:502,0 +DA:504,0 +DA:509,0 +DA:511,0 +DA:516,0 +DA:521,0 +DA:526,0 +DA:528,0 +DA:543,0 +DA:545,0 +DA:546,0 +DA:547,0 +DA:548,0 +DA:549,0 +DA:552,0 +DA:555,1 +DA:557,0 +DA:559,0 +DA:566,0 +DA:573,0 +DA:579,0 +DA:586,0 +DA:592,0 +DA:595,1 +DA:597,0 +DA:599,0 +DA:600,0 +DA:606,0 +DA:608,0 +DA:620,1 +DA:621,0 +LF:152 +LH:84 +FN:45,53,compute_sha256 +FNDA:1,compute_sha256 +FN:56,64,load_metadata +FNDA:1,load_metadata +FN:67,89,classify_quality +FNDA:1,classify_quality +FN:92,130,validate_source +FNDA:1,validate_source +FN:133,159,normalize_timestamps +FNDA:1,normalize_timestamps +FN:162,182,to_json_value +FNDA:1,to_json_value +FN:185,260,ensure_dataset +FNDA:0,ensure_dataset +FN:263,301,upsert_sites +FNDA:0,upsert_sites +FN:304,366,build_reading_batch +FNDA:1,build_reading_batch +FN:418,552,import_historical +FNDA:0,import_historical +FN:555,592,parse_args +FNDA:0,parse_args +FN:595,617,main +FNDA:0,main +FNF:12 +FNH:7 +BRDA:50,0,jump to line 51,1 +BRDA:50,0,jump to line 53,1 +BRDA:61,0,jump to line 62,0 +BRDA:61,0,jump to line 64,1 +BRDA:78,0,jump to line 79,1 +BRDA:78,0,jump to line 80,1 +BRDA:80,0,jump to line 81,0 +BRDA:80,0,jump to line 82,1 +BRDA:82,0,jump to line 83,1 +BRDA:82,0,jump to line 85,1 +BRDA:99,0,jump to line 100,1 +BRDA:99,0,jump to line 102,1 +BRDA:104,0,jump to line 105,0 +BRDA:104,0,jump to line 107,1 +BRDA:110,0,jump to line 111,1 +BRDA:110,0,jump to line 115,1 +BRDA:117,0,jump to line 118,1 +BRDA:117,0,jump to line 120,1 +BRDA:122,0,jump to line 123,0 +BRDA:122,0,jump to line 127,1 +BRDA:152,0,jump to line 153,1 +BRDA:152,0,jump to line 155,0 +BRDA:167,0,jump to line 168,0 +BRDA:167,0,jump to line 170,1 +BRDA:171,0,jump to line 172,1 +BRDA:171,0,jump to line 176,1 +BRDA:176,0,jump to line 177,1 +BRDA:176,0,jump to line 179,1 +BRDA:179,0,jump to line 180,0 +BRDA:179,0,jump to line 182,1 +BRDA:214,0,jump to line 215,- +BRDA:214,0,jump to line 217,- +BRDA:319,0,jump to line 320,1 +BRDA:319,0,jump to line 366,1 +BRDA:456,0,jump to line 457,- +BRDA:456,0,jump to line 460,- +BRDA:504,0,jump to line 509,- +BRDA:504,0,jump to line 528,- +BRDA:599,0,jump to line 600,- +BRDA:599,0,jump to line 606,- +BRDA:620,0,jump to line 621,0 +BRDA:620,0,exit the module,1 +BRF:42 +BRH:26 +end_of_record +SF:app\main.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,0 +DA:33,0 +DA:36,0 +DA:37,0 +DA:40,1 +DA:41,1 +DA:42,1 +DA:44,1 +DA:45,1 +DA:58,1 +DA:59,1 +DA:63,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:72,1 +DA:73,1 +DA:74,1 +DA:80,1 +DA:81,1 +DA:82,1 +DA:88,1 +DA:90,1 +DA:93,1 +DA:103,1 +DA:105,1 +DA:111,1 +DA:116,1 +DA:117,1 +DA:119,1 +LF:57 +LH:53 +FN:31,37,lifespan +FNDA:0,lifespan +FN:40,119,create_app +FNDA:1,create_app +FN:65,68,create_app.openapi_avec_logo +FNDA:1,create_app.openapi_avec_logo +FN:73,78,create_app.docs_swagger +FNDA:1,create_app.docs_swagger +FN:81,86,create_app.docs_redoc +FNDA:1,create_app.docs_redoc +FNF:5 +FNH:4 +BRDA:58,0,jump to line 59,1 +BRDA:58,0,jump to line 88,1 +BRDA:90,0,jump to line 93,1 +BRDA:90,0,jump to line 103,1 +BRDA:116,0,jump to line 117,1 +BRDA:116,0,jump to line 119,1 +BRF:6 +BRH:6 +end_of_record +SF:app\models\__init__.py +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +LF:8 +LH:8 +end_of_record +SF:app\models\audit_log.py +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:40,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:51,1 +DA:52,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +LF:41 +LH:41 +end_of_record +SF:app\models\energy.py +DA:3,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:24,1 +DA:25,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:81,1 +DA:82,1 +DA:85,1 +DA:86,1 +DA:87,1 +DA:91,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:103,1 +DA:104,1 +DA:105,1 +DA:108,1 +DA:111,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:145,1 +DA:146,1 +DA:149,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:155,1 +DA:156,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:179,1 +DA:180,1 +DA:181,1 +DA:184,1 +DA:185,1 +DA:186,1 +DA:187,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,1 +DA:193,1 +DA:196,1 +DA:197,1 +DA:198,1 +DA:202,1 +DA:203,1 +DA:207,1 +DA:208,1 +DA:209,1 +DA:210,1 +LF:87 +LH:87 +end_of_record +SF:app\models\login_attempt.py +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:37,1 +DA:38,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +LF:23 +LH:23 +end_of_record +SF:app\models\password_reset_attempt.py +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,1 +LF:12 +LH:12 +end_of_record +SF:app\models\password_reset_token.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:27,1 +DA:30,1 +DA:33,1 +DA:34,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +LF:18 +LH:18 +end_of_record +SF:app\models\refresh_token.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:47,1 +DA:50,1 +DA:51,1 +DA:54,1 +DA:55,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +LF:30 +LH:30 +end_of_record +SF:app\models\user.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:15,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:40,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:48,1 +LF:24 +LH:24 +end_of_record +SF:app\repositories\__init__.py +end_of_record +SF:app\repositories\alert.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,0 +DA:19,1 +DA:20,0 +DA:21,1 +LF:14 +LH:12 +FN:10,11,AlertRepository.__init__ +FNDA:1,AlertRepository.__init__ +FN:13,21,AlertRepository.list_all +FNDA:1,AlertRepository.list_all +FNF:2 +FNH:2 +BRDA:17,0,jump to line 18,0 +BRDA:17,0,jump to line 19,1 +BRDA:19,0,jump to line 20,0 +BRDA:19,0,jump to line 21,1 +BRF:4 +BRH:2 +end_of_record +SF:app\repositories\audit_log.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:26,1 +DA:27,0 +DA:28,0 +DA:29,0 +DA:32,1 +DA:33,1 +DA:34,1 +DA:36,1 +DA:49,0 +LF:15 +LH:11 +FN:26,29,assemble_detail +FNDA:0,assemble_detail +FN:33,34,AuditLogRepository.__init__ +FNDA:1,AuditLogRepository.__init__ +FN:36,62,AuditLogRepository.record +FNDA:0,AuditLogRepository.record +FNF:3 +FNH:1 +BRDA:27,0,jump to line 28,- +BRDA:27,0,jump to line 29,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\login_attempt.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:26,1 +DA:34,0 +DA:43,1 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:60,0 +DA:63,0 +LF:20 +LH:13 +FN:23,24,LoginAttemptRepository.__init__ +FNDA:1,LoginAttemptRepository.__init__ +FN:26,41,LoginAttemptRepository.record +FNDA:0,LoginAttemptRepository.record +FN:43,67,LoginAttemptRepository.count_recent_failures +FNDA:0,LoginAttemptRepository.count_recent_failures +FNF:3 +FNH:1 +end_of_record +SF:app\repositories\password_reset_attempt.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:21,0 +DA:25,1 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:41,0 +DA:42,0 +LF:19 +LH:12 +FN:17,18,PasswordResetAttemptRepository.__init__ +FNDA:1,PasswordResetAttemptRepository.__init__ +FN:20,23,PasswordResetAttemptRepository.record +FNDA:0,PasswordResetAttemptRepository.record +FN:25,42,PasswordResetAttemptRepository.count_recent +FNDA:0,PasswordResetAttemptRepository.count_recent +FNF:3 +FNH:1 +end_of_record +SF:app\repositories\password_reset_token.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:25,1 +DA:34,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,1 +DA:46,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:63,1 +DA:64,0 +DA:69,0 +DA:71,1 +DA:72,0 +DA:78,0 +LF:28 +LH:15 +FN:22,23,PasswordResetTokenRepository.__init__ +FNDA:1,PasswordResetTokenRepository.__init__ +FN:25,43,PasswordResetTokenRepository.create +FNDA:0,PasswordResetTokenRepository.create +FN:45,59,PasswordResetTokenRepository.consume +FNDA:0,PasswordResetTokenRepository.consume +FN:63,69,PasswordResetTokenRepository.exists_valid +FNDA:0,PasswordResetTokenRepository.exists_valid +FN:71,78,PasswordResetTokenRepository.invalidate_all_for_user +FNDA:0,PasswordResetTokenRepository.invalidate_all_for_user +FNF:5 +FNH:1 +BRDA:57,0,jump to line 58,- +BRDA:57,0,jump to line 59,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\reading.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:14,1 +DA:18,0 +DA:23,0 +DA:25,1 +DA:28,0 +DA:34,0 +DA:35,0 +DA:37,1 +DA:46,1 +DA:53,1 +DA:54,0 +DA:55,1 +LF:20 +LH:14 +FN:11,12,ReadingRepository.__init__ +FNDA:1,ReadingRepository.__init__ +FN:14,23,ReadingRepository.latest_by_site +FNDA:0,ReadingRepository.latest_by_site +FN:25,35,ReadingRepository.latest_for_site +FNDA:0,ReadingRepository.latest_for_site +FN:37,55,ReadingRepository.list_history +FNDA:1,ReadingRepository.list_history +FNF:4 +FNH:2 +BRDA:53,0,jump to line 54,0 +BRDA:53,0,jump to line 55,1 +BRF:2 +BRH:1 +end_of_record +SF:app\repositories\recommendation.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:21,1 +DA:22,1 +LF:14 +LH:14 +FN:10,11,RecommendationRepository.__init__ +FNDA:1,RecommendationRepository.__init__ +FN:13,15,RecommendationRepository.list_all +FNDA:1,RecommendationRepository.list_all +FN:17,22,RecommendationRepository.get_by_id +FNDA:1,RecommendationRepository.get_by_id +FNF:3 +FNH:3 +end_of_record +SF:app\repositories\refresh_token.py +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:38,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,1 +DA:51,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:81,1 +DA:82,0 +DA:83,0 +DA:85,1 +DA:86,0 +DA:90,1 +DA:91,0 +DA:97,0 +DA:99,1 +DA:100,0 +DA:106,0 +LF:33 +LH:17 +FN:25,26,RefreshTokenRepository.__init__ +FNDA:1,RefreshTokenRepository.__init__ +FN:28,48,RefreshTokenRepository.create +FNDA:0,RefreshTokenRepository.create +FN:50,79,RefreshTokenRepository.claim_for_rotation +FNDA:0,RefreshTokenRepository.claim_for_rotation +FN:81,83,RefreshTokenRepository.inspect +FNDA:0,RefreshTokenRepository.inspect +FN:85,88,RefreshTokenRepository.link_replacement +FNDA:0,RefreshTokenRepository.link_replacement +FN:90,97,RefreshTokenRepository.revoke_family +FNDA:0,RefreshTokenRepository.revoke_family +FN:99,106,RefreshTokenRepository.revoke_all_for_user +FNDA:0,RefreshTokenRepository.revoke_all_for_user +FNF:7 +FNH:1 +BRDA:72,0,jump to line 73,- +BRDA:72,0,jump to line 74,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\site.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +LF:14 +LH:14 +FN:10,11,SiteRepository.__init__ +FNDA:1,SiteRepository.__init__ +FN:13,15,SiteRepository.list_all +FNDA:1,SiteRepository.list_all +FN:17,20,SiteRepository.get_by_id +FNDA:1,SiteRepository.get_by_id +FNF:3 +FNH:3 +end_of_record +SF:app\repositories\user.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:21,0 +DA:22,0 +DA:24,1 +DA:25,0 +DA:27,1 +DA:28,0 +DA:29,0 +DA:31,1 +DA:32,0 +DA:37,0 +DA:39,1 +DA:49,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,1 +DA:64,0 +DA:74,1 +DA:76,0 +DA:80,1 +DA:81,0 +DA:85,1 +DA:86,0 +DA:92,1 +DA:93,0 +LF:35 +LH:19 +FN:17,18,UserRepository.__init__ +FNDA:1,UserRepository.__init__ +FN:20,22,UserRepository.get_by_email +FNDA:0,UserRepository.get_by_email +FN:24,25,UserRepository.get_by_id +FNDA:0,UserRepository.get_by_id +FN:27,29,UserRepository.list_all +FNDA:0,UserRepository.list_all +FN:31,37,UserRepository.count_active_admins +FNDA:0,UserRepository.count_active_admins +FN:39,59,UserRepository.create +FNDA:0,UserRepository.create +FN:61,72,UserRepository.update_password +FNDA:0,UserRepository.update_password +FN:74,78,UserRepository.rehash_password +FNDA:0,UserRepository.rehash_password +FN:80,83,UserRepository.touch_last_login +FNDA:0,UserRepository.touch_last_login +FN:85,90,UserRepository.set_role +FNDA:0,UserRepository.set_role +FN:92,97,UserRepository.set_active +FNDA:0,UserRepository.set_active +FNF:11 +FNH:1 +end_of_record +SF:app\schemas\__init__.py +DA:1,1 +DA:3,1 +LF:2 +LH:2 +end_of_record +SF:app\schemas\alert.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +LF:16 +LH:16 +end_of_record +SF:app\schemas\auth.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:29,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:46,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:59,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:67,1 +DA:68,1 +DA:69,1 +DA:70,1 +DA:73,1 +DA:74,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:87,1 +DA:91,1 +DA:93,1 +LF:43 +LH:43 +FN:28,41,valide_complexite +FNDA:1,valide_complexite +FN:55,56,PasswordChangeRequest._new_password_est_complexe +FNDA:1,PasswordChangeRequest._new_password_est_complexe +FN:69,70,ResetPasswordRequest._new_password_est_complexe +FNDA:1,ResetPasswordRequest._new_password_est_complexe +FN:83,84,PrincipalResponse.from_principal +FNDA:1,PrincipalResponse.from_principal +FNF:4 +FNH:4 +BRDA:39,0,jump to line 40,1 +BRDA:39,0,jump to line 41,1 +BRF:2 +BRH:2 +end_of_record +SF:app\schemas\errors.py +DA:5,1 +DA:8,1 +DA:12,1 +DA:17,1 +DA:21,1 +LF:5 +LH:5 +end_of_record +SF:app\schemas\health.py +DA:1,1 +DA:3,1 +DA:6,1 +DA:16,1 +LF:4 +LH:4 +end_of_record +SF:app\schemas\reading.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +LF:16 +LH:16 +end_of_record +SF:app\schemas\recommendation.py +DA:1,1 +DA:3,1 +DA:6,1 +DA:7,1 +LF:4 +LH:4 +end_of_record +SF:app\schemas\sensor.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:19,1 +DA:20,1 +DA:29,1 +DA:30,1 +DA:38,1 +DA:39,1 +LF:12 +LH:12 +end_of_record +SF:app\schemas\site.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:18,1 +DA:19,1 +LF:7 +LH:7 +end_of_record +SF:app\schemas\stats.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:18,1 +DA:19,1 +LF:7 +LH:7 +end_of_record +SF:app\schemas\user.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:24,1 +DA:25,1 +DA:38,1 +LF:12 +LH:12 +end_of_record +SF:app\services\__init__.py +end_of_record +SF:app\services\alert.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:14,1 +LF:8 +LH:8 +FN:8,9,AlertService.__init__ +FNDA:1,AlertService.__init__ +FN:11,14,AlertService.list_all +FNDA:1,AlertService.list_all +FNF:2 +FNH:2 +end_of_record +SF:app\services\auth.py +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:43,1 +DA:47,1 +DA:48,1 +DA:51,1 +DA:52,1 +DA:55,1 +DA:56,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:65,1 +DA:66,1 +DA:69,1 +DA:70,1 +DA:77,1 +DA:78,1 +DA:86,1 +DA:87,1 +DA:94,1 +DA:95,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:119,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:124,1 +DA:126,1 +DA:129,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:137,1 +DA:141,1 +DA:142,1 +DA:146,1 +DA:147,1 +DA:149,1 +DA:150,1 +DA:153,1 +DA:156,1 +DA:158,1 +DA:160,1 +DA:163,1 +DA:164,1 +DA:165,1 +DA:166,1 +DA:168,1 +DA:169,1 +DA:170,1 +DA:171,1 +DA:172,1 +DA:174,1 +DA:175,1 +DA:183,1 +DA:184,1 +DA:186,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,1 +DA:194,1 +DA:203,1 +DA:204,1 +DA:205,1 +DA:207,1 +DA:212,1 +DA:215,1 +DA:218,1 +DA:227,1 +DA:229,1 +DA:230,1 +DA:232,1 +DA:240,1 +DA:242,1 +DA:248,1 +DA:249,1 +DA:250,1 +DA:251,1 +DA:252,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:263,1 +DA:264,1 +DA:272,1 +DA:274,1 +DA:275,1 +DA:277,1 +DA:278,1 +DA:279,1 +DA:280,1 +DA:281,1 +DA:287,1 +DA:288,1 +DA:290,1 +DA:293,1 +DA:294,1 +DA:295,1 +DA:300,1 +DA:301,1 +DA:302,1 +DA:304,1 +DA:307,1 +DA:310,1 +DA:313,1 +DA:321,1 +DA:323,1 +DA:324,1 +DA:325,0 +DA:326,1 +DA:328,1 +DA:329,1 +DA:332,1 +DA:337,1 +DA:338,1 +DA:340,1 +DA:341,1 +DA:347,1 +DA:354,1 +DA:355,1 +DA:363,1 +DA:366,1 +DA:367,1 +DA:375,1 +DA:377,1 +DA:380,1 +DA:381,1 +DA:382,1 +DA:384,1 +DA:385,1 +DA:389,1 +DA:392,1 +DA:401,1 +DA:402,1 +DA:404,1 +DA:407,1 +DA:408,1 +DA:412,1 +DA:417,1 +DA:418,1 +DA:420,1 +DA:423,1 +DA:424,1 +DA:432,1 +DA:433,1 +DA:435,1 +DA:436,1 +DA:437,1 +DA:441,1 +DA:445,1 +DA:446,1 +DA:448,1 +DA:449,1 +DA:450,1 +DA:452,1 +DA:460,1 +DA:463,1 +DA:464,1 +LF:190 +LH:189 +FN:60,62,RateLimitedError.__init__ +FNDA:1,RateLimitedError.__init__ +FN:95,124,AuthService.__init__ +FNDA:1,AuthService.__init__ +FN:126,158,AuthService.authenticate +FNDA:1,AuthService.authenticate +FN:160,186,AuthService.refresh +FNDA:1,AuthService.refresh +FN:188,192,AuthService.logout +FNDA:1,AuthService.logout +FN:194,230,AuthService.change_password +FNDA:1,AuthService.change_password +FN:232,275,AuthService.request_password_reset +FNDA:1,AuthService.request_password_reset +FN:277,281,AuthService._envoie_email_reset +FNDA:1,AuthService._envoie_email_reset +FN:287,288,AuthService.is_reset_token_valid +FNDA:1,AuthService.is_reset_token_valid +FN:290,326,AuthService.confirm_password_reset +FNDA:1,AuthService.confirm_password_reset +FN:328,338,AuthService.logout_all +FNDA:1,AuthService.logout_all +FN:340,352,AuthService._session +FNDA:1,AuthService._session +FN:354,361,AuthService._en_principal +FNDA:1,AuthService._en_principal +FN:363,375,AuthService._ouvre_une_famille +FNDA:1,AuthService._ouvre_une_famille +FN:377,402,AuthService._traite_rotation_refusee +FNDA:1,AuthService._traite_rotation_refusee +FN:404,433,AuthService._refuse_si_limite +FNDA:1,AuthService._refuse_si_limite +FN:435,450,AuthService._refuse_si_limite_reset +FNDA:1,AuthService._refuse_si_limite_reset +FN:452,464,AuthService._echoue +FNDA:1,AuthService._echoue +FNF:18 +FNH:18 +BRDA:132,0,jump to line 133,1 +BRDA:132,0,jump to line 136,1 +BRDA:136,0,jump to line 137,1 +BRDA:136,0,jump to line 141,1 +BRDA:141,0,jump to line 142,1 +BRDA:141,0,jump to line 146,1 +BRDA:146,0,jump to line 147,1 +BRDA:146,0,jump to line 149,1 +BRDA:165,0,jump to line 166,1 +BRDA:165,0,jump to line 168,1 +BRDA:169,0,jump to line 170,1 +BRDA:169,0,jump to line 174,1 +BRDA:190,0,jump to line 191,1 +BRDA:190,0,jump to line 192,1 +BRDA:204,0,jump to line 205,1 +BRDA:204,0,jump to line 207,1 +BRDA:248,0,jump to line 249,1 +BRDA:248,0,jump to line 254,1 +BRDA:294,0,jump to line 295,1 +BRDA:294,0,jump to line 300,1 +BRDA:301,0,jump to line 302,1 +BRDA:301,0,jump to line 304,1 +BRDA:324,0,jump to line 325,0 +BRDA:324,0,jump to line 326,1 +BRDA:381,0,jump to line 382,1 +BRDA:381,0,jump to line 384,1 +BRDA:384,0,jump to line 385,1 +BRDA:384,0,jump to line 389,1 +BRDA:417,0,jump to line 418,1 +BRDA:417,0,jump to line 420,1 +BRDA:423,0,jump to line 424,1 +BRDA:423,0,jump to line 432,1 +BRDA:445,0,jump to line 446,1 +BRDA:445,0,jump to line 448,1 +BRF:34 +BRH:33 +end_of_record +SF:app\services\data_quality.py +DA:6,1 +DA:8,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +LF:8 +LH:8 +FN:15,18,qualite_ou_critique +FNDA:1,qualite_ou_critique +FNF:1 +FNH:1 +BRDA:16,0,jump to line 17,1 +BRDA:16,0,jump to line 18,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\reading.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:15,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:23,1 +DA:32,1 +DA:33,1 +DA:37,1 +DA:38,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +LF:29 +LH:29 +FN:20,21,ReadingService.__init__ +FNDA:1,ReadingService.__init__ +FN:23,35,ReadingService.list_history +FNDA:1,ReadingService.list_history +FN:38,53,ReadingService._resoudre_fenetre +FNDA:1,ReadingService._resoudre_fenetre +FN:56,59,_vers_utc +FNDA:1,_vers_utc +FNF:4 +FNH:4 +BRDA:46,0,jump to line 47,1 +BRDA:46,0,jump to line 49,1 +BRDA:49,0,jump to line 50,1 +BRDA:49,0,jump to line 51,1 +BRDA:51,0,jump to line 52,1 +BRDA:51,0,jump to line 53,1 +BRDA:57,0,jump to line 58,1 +BRDA:57,0,jump to line 59,1 +BRF:8 +BRH:8 +end_of_record +SF:app\services\recommendation.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +LF:17 +LH:17 +FN:16,17,RecommendationService.__init__ +FNDA:1,RecommendationService.__init__ +FN:19,20,RecommendationService.list_all +FNDA:1,RecommendationService.list_all +FN:22,26,RecommendationService.get_by_id +FNDA:1,RecommendationService.get_by_id +FNF:3 +FNH:3 +BRDA:24,0,jump to line 25,1 +BRDA:24,0,jump to line 26,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\sensor.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:21,1 +DA:29,1 +DA:30,1 +DA:35,1 +DA:36,1 +DA:44,1 +DA:45,1 +DA:52,1 +DA:53,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:67,1 +DA:73,1 +DA:74,1 +DA:75,1 +DA:82,1 +DA:83,1 +DA:85,1 +DA:86,1 +DA:93,1 +DA:99,1 +DA:113,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:126,1 +DA:132,1 +DA:133,1 +DA:134,1 +LF:49 +LH:49 +FN:59,61,SensorService.__init__ +FNDA:1,SensorService.__init__ +FN:63,70,SensorService.status +FNDA:1,SensorService.status +FN:73,110,_sante_site +FNDA:1,_sante_site +FN:113,118,_overall_depuis_qualite +FNDA:1,_overall_depuis_qualite +FN:121,129,_diagnostic +FNDA:1,_diagnostic +FN:132,136,_tout_en_echec +FNDA:1,_tout_en_echec +FNF:6 +FNH:6 +BRDA:74,0,jump to line 75,1 +BRDA:74,0,jump to line 82,1 +BRDA:85,0,jump to line 86,1 +BRDA:85,0,jump to line 93,1 +BRDA:114,0,jump to line 115,1 +BRDA:114,0,jump to line 116,1 +BRDA:116,0,jump to line 117,1 +BRDA:116,0,jump to line 118,1 +BRF:8 +BRH:8 +end_of_record +SF:app\services\site.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:54,1 +DA:69,1 +LF:30 +LH:30 +FN:36,38,SiteService.__init__ +FNDA:1,SiteService.__init__ +FN:40,41,SiteService.list_all +FNDA:1,SiteService.list_all +FN:43,47,SiteService.get_by_id +FNDA:1,SiteService.get_by_id +FN:49,82,SiteService.current +FNDA:1,SiteService.current +FNF:4 +FNH:4 +BRDA:45,0,jump to line 46,1 +BRDA:45,0,jump to line 47,1 +BRDA:53,0,jump to line 54,1 +BRDA:53,0,jump to line 69,1 +BRF:4 +BRH:4 +end_of_record +SF:app\services\stats.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:20,1 +DA:21,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:63,1 +DA:67,1 +LF:31 +LH:31 +FN:31,33,StatsService.__init__ +FNDA:1,StatsService.__init__ +FN:35,52,StatsService.summary +FNDA:1,StatsService.summary +FN:55,74,StatsService._resume_site +FNDA:1,StatsService._resume_site +FNF:3 +FNH:3 +BRDA:59,0,jump to line 60,1 +BRDA:59,0,jump to line 63,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\user.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:21,1 +DA:24,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:33,1 +DA:36,1 +DA:37,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +DA:50,1 +DA:51,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:66,1 +DA:67,0 +DA:69,1 +DA:72,1 +DA:73,1 +DA:75,1 +DA:76,1 +DA:83,1 +DA:90,1 +DA:91,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:109,1 +DA:110,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:115,0 +DA:117,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:129,1 +DA:130,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:139,1 +DA:140,1 +DA:147,1 +DA:148,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:156,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:162,1 +DA:163,1 +DA:164,1 +LF:85 +LH:83 +FN:51,64,UserService.__init__ +FNDA:1,UserService.__init__ +FN:66,67,UserService.list_all +FNDA:0,UserService.list_all +FN:69,91,UserService.create +FNDA:1,UserService.create +FN:93,110,UserService.change_role +FNDA:1,UserService.change_role +FN:112,130,UserService.set_active +FNDA:1,UserService.set_active +FN:132,148,UserService.reset_password +FNDA:1,UserService.reset_password +FN:150,154,UserService._exige +FNDA:1,UserService._exige +FN:156,164,UserService._refuse_si_dernier_admin +FNDA:1,UserService._refuse_si_dernier_admin +FNF:8 +FNH:7 +BRDA:72,0,jump to line 73,1 +BRDA:72,0,jump to line 75,1 +BRDA:95,0,jump to line 96,1 +BRDA:95,0,jump to line 98,1 +BRDA:114,0,jump to line 115,0 +BRDA:114,0,jump to line 117,1 +BRDA:121,0,jump to line 122,1 +BRDA:121,0,jump to line 123,1 +BRDA:152,0,jump to line 153,1 +BRDA:152,0,jump to line 154,1 +BRDA:161,0,jump to line 162,1 +BRDA:161,0,jump to line 163,1 +BRDA:163,0,jump to line 164,1 +BRDA:163,0,return from function '_refuse_si_dernier_admin',1 +BRF:14 +BRH:13 +end_of_record From ddf7e1778869780626fcd84e99cd44f7dac7d824 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:39:51 +0200 Subject: [PATCH 171/205] test: properties sonar --- apps/backend/sonar-project.properties | 15 --------------- sonar-project.properties | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) delete mode 100644 apps/backend/sonar-project.properties create mode 100644 sonar-project.properties diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties deleted file mode 100644 index f828cf3..0000000 --- a/apps/backend/sonar-project.properties +++ /dev/null @@ -1,15 +0,0 @@ -sonar.projectKey=ProjetPiscine_EnerVision -sonar.organization=groupe3-ener-vision -sonar.sourceEncoding=UTF-8 - -# Dossier contenant le code source -sonar.sources=app -# Dossier contenant les tests -sonar.tests=tests - -# Liste des fichiers et dossiers à exclure de l'analyse -sonar.exclusions=.pytest_cache,.venv,alembic,tests - -# Chemin vers le rapport de couverture de code -# Fichier généré par Pytest -sonar.python.coverage.reportPaths=cov.info diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..487ef67 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,17 @@ +sonar.projectKey=ProjetPiscine_EnerVision +sonar.organization=groupe3-ener-vision +sonar.sourceEncoding=UTF-8 + +# Dossier contenant le code source +sonar.sources=apps/frontend/src,apps/backend +# Dossier contenant les tests +sonar.tests=apps/frontend/src,apps/backend/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 + +# Chemin vers le rapport de couverture de code +# Fichier généré par Pytest +sonar.python.coverage.reportPaths=apps/backend/coverage.lcov +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From ec63798807a87ce6a99b63fa93002f6328f85b50 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:55:46 +0200 Subject: [PATCH 172/205] fix: lcov -> xml pour le rapport de couverture --- .github/workflows/sonarqube.yml | 4 ++-- sonar-project.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 3062a8b..b5a07eb 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -99,14 +99,14 @@ jobs: cache-dependency-glob: apps/backend/uv.lock - name : Lancement des tests et génénration du rapport de couverture (Back) - run: uv run pytest --cov-fail-under=85 --cov-report=lcov + run: uv run pytest --cov-fail-under=85 --cov-report=xml working-directory: apps/backend - name: Upload coverage uses: actions/upload-artifact@v4 with: name: backend-coverage - path: apps/backend/coverage.lcov + path: apps/backend/coverage.xml sonarqube: needs: [build-front, build-back, test-front, test-back] diff --git a/sonar-project.properties b/sonar-project.properties index 487ef67..43c28fd 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -13,5 +13,5 @@ sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dis # Chemin vers le rapport de couverture de code # Fichier généré par Pytest -sonar.python.coverage.reportPaths=apps/backend/coverage.lcov +sonar.python.coverage.reportPaths=apps/backend/coverage.xml sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 33807e3038356329b0e6a9751534e465864cdb0a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 14:11:44 +0200 Subject: [PATCH 173/205] =?UTF-8?q?fix(frontend):=20traite=20la=20revue=20?= =?UTF-8?q?de=20phyri0s=20sur=20la=20vue=20d=C3=A9tail=20d'un=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre points portant sur le code de cette PR : - L'échec de chargement laissait à l'écran le site précédemment affiché sous le bandeau d'erreur : `reportUnavailable()` vide désormais site, mesure et historique, pour qu'on ne lise pas les chiffres de A en croyant regarder B. - L'historique était tracé à rebours : l'API trie en timestamp décroissant (`ReadingRepository.list_history`), le graphique rétablit la chronologie. - Une consommation `null` (panne capteur) alimentait la jauge avec un 0, indiscernable d'un site qui ne consomme rien : la jauge n'est plus montée dans ce cas, la raison de l'absence est affichée à la place. Une consommation réellement mesurée à 0 continue d'afficher la jauge. - `getSite` et `getCurrent` ne dépendent pas l'un de l'autre : `forkJoin` économise un aller-retour en série à chaque ouverture de la page. Co-Authored-By: Claude Opus 5 (1M context) --- .../sites/site-detail/site-detail.html | 19 +++-- .../sites/site-detail/site-detail.scss | 5 ++ .../sites/site-detail/site-detail.spec.ts | 80 +++++++++++++++++++ .../features/sites/site-detail/site-detail.ts | 32 ++++++-- .../reading-history-chart.spec.ts | 27 ++++++- .../reading-history-chart.ts | 35 +++++--- 6 files changed, 172 insertions(+), 26 deletions(-) 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 e95ac75..9c4a4bc 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 @@ -36,13 +36,18 @@
Consommation vs capacité - - - {{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW - + @let consumption = consumptionKw(); + @if (consumption !== null) { + + + {{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW + + } @else { +

+ Consommation indisponible + ({{ consumptionReason() }}) +

+ }
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 78dd9ed..a0032be 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 @@ -74,6 +74,11 @@ font-weight: 700; } +.card__unavailable { + color: var(--color-text-muted); + margin: 0; +} + .metrics-grid { display: grid; grid-template-columns: repeat(2, 1fr); 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 3fdc269..5e3cf46 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 @@ -116,6 +116,86 @@ describe('SiteDetail', () => { expect(fixture.componentInstance.site()).toBeNull(); }); + it('efface les données du site précédent quand le chargement du suivant échoue', () => { + const getSite = vi + .fn() + .mockReturnValueOnce(of(SITE)) + .mockReturnValueOnce(throwError(() => new Error('404'))); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + expect(fixture.componentInstance.current()).toBeNull(); + expect(fixture.componentInstance.history()).toEqual([]); + expect(fixture.nativeElement.textContent).not.toContain('Site 1'); + }); + + it('interroge le site et sa mesure courante en parallèle', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET)); + const { fixture } = setup( + 'SITE001', + { getSite, getCurrent }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE001'); + expect(getCurrent).toHaveBeenCalledWith('SITE001'); + }); + + it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => { + const sansConsommation = { + ...CURRENT_COMPLET, + consumption_kw: null, + null_reasons: ['consumption_sensor_failure'], + data_quality: 'partial' as const, + }; + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(sansConsommation)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionKw()).toBeNull(); + expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Consommation indisponible'); + }); + + it('trace la jauge pour une consommation nulle réellement mesurée', () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible'); + }); + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { const partielle = { ...CURRENT_COMPLET, 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 047614a..21e716d 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 @@ -1,7 +1,7 @@ import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { catchError, EMPTY, filter, map, Observable, of, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; import { Site } from '../../../shared/models/site.model'; @@ -52,8 +52,14 @@ interface MetricDef { format: (value: number) => string; } +const CONSUMPTION_DEF: MetricDef = { + key: 'consumption_kw', + label: 'Consommation', + format: (v) => `${v.toFixed(1)} kW`, +}; + const METRIC_DEFS: MetricDef[] = [ - { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + CONSUMPTION_DEF, { key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` }, { key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` }, { key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) }, @@ -111,6 +117,15 @@ export class SiteDetail { hasMeasurement = computed(() => this.current()?.timestamp != null); + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); + + consumptionLabel = computed(() => { + const kw = this.consumptionKw(); + return kw != null ? CONSUMPTION_DEF.format(kw) : null; + }); + + consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current())); + qualityLabel = computed(() => { const quality = this.current()?.data_quality; return quality ? LIBELLE_PAR_QUALITE[quality] : null; @@ -156,10 +171,10 @@ export class SiteDetail { } private load(siteId: string) { - return this.sitesService.getSite(siteId).pipe( - switchMap((site) => - this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))), - ), + return forkJoin({ + site: this.sitesService.getSite(siteId), + current: this.sitesService.getCurrent(siteId), + }).pipe( switchMap(({ site, current }) => this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))), ), @@ -186,8 +201,13 @@ export class SiteDetail { return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; } + // Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé + // sous le bandeau et laisse lire les chiffres de A en croyant regarder B. private reportUnavailable(): Observable { this.error.set(UNAVAILABLE_MESSAGE); + this.site.set(null); + this.current.set(null); + this.history.set([]); return EMPTY; } } diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts index bb6232f..63be883 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -9,15 +9,21 @@ vi.mock('chart.js', () => { static register = vi.fn(); update = vi.fn(); destroy = vi.fn(); - data = { datasets: [{}] }; - constructor() { + data: { labels?: unknown[]; datasets: Record[] } = { datasets: [{}] }; + constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) { + if (config?.data) { + this.data = config.data; + } ChartMock.instances.push(this); } } return { Chart: ChartMock, registerables: [] }; }); -type ChartDouble = { destroy: ReturnType }; +type ChartDouble = { + destroy: ReturnType; + data: { labels?: unknown[]; datasets: Record[] }; +}; function lastChart(): ChartDouble | undefined { return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); @@ -66,6 +72,21 @@ describe('ReadingHistoryChart', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + // L'API trie en timestamp décroissant : le composant doit rétablir la chronologie. + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 }, + { ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']); + expect(chart?.data.datasets[0]['data']).toEqual([42, 60]); + }); + it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); const fixture = TestBed.createComponent(ReadingHistoryChart); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts index 922ca01..17d1e06 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -20,8 +20,23 @@ const QUALITY_COLORS: Record = { }; const UNKNOWN_QUALITY_COLOR = '#9ca3af'; -function pointColors(readings: Reading[]): string[] { - return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR)); +interface ChartSeries { + labels: string[]; + values: number[]; + colors: string[]; +} + +// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history +// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours. +function toSeries(readings: Reading[]): ChartSeries { + const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return { + labels: ordered.map((r) => r.timestamp), + values: ordered.map((r) => r.consumption_kw ?? 0), + colors: ordered.map((r) => + r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR, + ), + }; } @Component({ @@ -38,27 +53,27 @@ export class ReadingHistoryChart implements AfterViewInit, OnDestroy { constructor() { effect(() => { - const readings = this.readings(); + const series = toSeries(this.readings()); if (this.chart) { - this.chart.data.labels = readings.map((r) => r.timestamp); - this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); - this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + this.chart.data.labels = series.labels; + this.chart.data.datasets[0].data = series.values; + this.chart.data.datasets[0].pointBackgroundColor = series.colors; this.chart.update('none'); } }); } ngAfterViewInit(): void { - const readings = this.readings(); + const series = toSeries(this.readings()); this.chart = new Chart(this.canvasRef.nativeElement, { type: 'line', data: { - labels: readings.map((r) => r.timestamp), + labels: series.labels, datasets: [ { - data: readings.map((r) => r.consumption_kw ?? 0), + data: series.values, borderColor: '#3b82f6', - pointBackgroundColor: pointColors(readings), + pointBackgroundColor: series.colors, tension: 0.25, }, ], From eb4291b10a7da75efd0d9fd9a2c6198ab5d9be05 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 14:58:39 +0200 Subject: [PATCH 174/205] fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux --- apps/backend/tests/api/test_predictions.py | 11 +- .../src/app/core/mocks/predictions.fixture.ts | 99 ----------------- .../src/app/features/dashboard/dashboard.html | 10 +- .../app/features/dashboard/dashboard.spec.ts | 42 ++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++-- docs/architecture/00-vue-ensemble.md | 2 +- docs/architecture/20-backend.md | 13 --- docs/architecture/30-frontend.md | 19 ++-- ml/enervision_ml/data.py | 5 + ml/enervision_ml/score.py | 105 ++++++++++++------ ml/tests/test_data.py | 71 +++++++----- ml/tests/test_score.py | 51 ++++++++- 12 files changed, 258 insertions(+), 202 deletions(-) delete mode 100644 apps/frontend/src/app/core/mocks/predictions.fixture.ts diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py index cbc1cb6..184afc6 100644 --- a/apps/backend/tests/api/test_predictions.py +++ b/apps/backend/tests/api/test_predictions.py @@ -15,11 +15,14 @@ TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) -def principal(role: Role = Role.LECTEUR) -> Principal: +def lecteur() -> Principal: + # Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE` + # de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec + # autre chose que sa valeur par défaut. return Principal( id=uuid4(), - email=f"{role.value}@enervision.fr", - role=role, + email="lecteur@enervision.fr", + role=Role.LECTEUR, kind=AccountKind.HUMAIN, must_change_password=False, ) @@ -57,7 +60,7 @@ def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: def installe() -> FauxService: service = FauxService() app.dependency_overrides[get_prediction_service] = lambda: service - app.dependency_overrides[get_current_principal] = lambda: principal() + app.dependency_overrides[get_current_principal] = lambda: lecteur() return service yield installe diff --git a/apps/frontend/src/app/core/mocks/predictions.fixture.ts b/apps/frontend/src/app/core/mocks/predictions.fixture.ts deleted file mode 100644 index 5397e27..0000000 --- a/apps/frontend/src/app/core/mocks/predictions.fixture.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PredictionSummary } from '../../shared/models/prediction.model'; - -export const PREDICTIONS_FIXTURE: PredictionSummary = { - timestamp: '2026-09-18T09:00:00Z', - sites: [ - { - site_id: 'SITE001', - site_name: 'Bureau Paris La Défense', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 89.2, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE002', - site_name: 'Usine Lyon Vénissieux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 561.4, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE003', - site_name: 'Data Center Marseille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: null, - status: 'insufficient_data', - failure_reason: - "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site.", - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE004', - site_name: 'Bureau Bordeaux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 58.9, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE005', - site_name: 'Usine Toulouse', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 402.7, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE006', - site_name: 'Bureau Lille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 91.3, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - // Illustre le cas d'un site jamais scoré : `prediction` reste `null`, pas un statut inventé - // (même contrat que `PredictionService.summary()` côté backend). - site_id: 'SITE007', - site_name: 'Data Center Nantes', - prediction: null, - }, - ], -}; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d3c5a87..bd5f927 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -21,7 +21,13 @@
- @if (error(); as message) { + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); as message) { } @@ -85,7 +91,7 @@ {{ prediction.predicted_value | number: '1.0-1' }} kWh à {{ prediction.target_at | date: 'HH:mm' }}{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} } @else { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 120ff25..5d716ae 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -52,7 +52,9 @@ describe('Dashboard', () => { expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); + expect(fixture.componentInstance.alertsError()).toBeNull(); + expect(fixture.componentInstance.predictionsError()).toBeNull(); }); it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { @@ -80,13 +82,13 @@ describe('Dashboard', () => { vi.advanceTimersByTime(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).not.toBeNull(); expect(fixture.componentInstance.stats()).toBeNull(); vi.advanceTimersByTime(10000); expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(fixture.componentInstance.stats()).not.toBeNull(); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it("n'interrompt pas la page quand le chargement des alertes échoue", () => { @@ -107,6 +109,7 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.alerts().length).toBe(0); + expect(fixture.componentInstance.alertsError()).not.toBeNull(); }); it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { @@ -130,7 +133,38 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.predictions().length).toBe(0); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + }); + + it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { + vi.useFakeTimers(); + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + + // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit + // toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée. + vi.advanceTimersByTime(30000); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index d41d258..3671919 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe, DatePipe } from '@angular/common'; @@ -67,32 +67,44 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); predictions = signal([]); - error = signal(null); + + // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) + // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, + // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + statsError = signal(null); + alertsError = signal(null); + predictionsError = signal(null); ngOnInit(): void { this.alertsService .getAlerts() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((alerts) => this.alerts.set(alerts)); + .pipe(catchError(() => this.reportUnavailable(this.alertsError))) + .subscribe((alerts) => { + this.alertsError.set(null); + this.alerts.set(alerts); + }); // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. this.predictionsService .getPredictions() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((summary) => this.predictions.set(summary.sites)); + .pipe(catchError(() => this.reportUnavailable(this.predictionsError))) + .subscribe((summary) => { + this.predictionsError.set(null); + this.predictions.set(summary.sites); + }); // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), ), takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { - this.error.set(null); + this.statsError.set(null); this.stats.set(stats); }); } @@ -116,8 +128,8 @@ export class Dashboard implements OnInit { }); } - private reportUnavailable(): Observable { - this.error.set(UNAVAILABLE_MESSAGE); + private reportUnavailable(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); return EMPTY; } } diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 26b890e..a650a1e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -75,7 +75,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | +| 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 308a8bf..f60397d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -205,19 +205,6 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. -`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, -donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ -owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni -fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des -autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les -deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90 -jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre -large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle -métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI, -par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de -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. - ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index b7f6d10..fb10f92 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -31,10 +31,11 @@ Ce qui est en place : Ce qui n'existe pas encore : -- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, - `GET /api/v1/alerts` et `GET /api/v1/predictions` 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. +- **`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. - Aucun lint : ESLint n'est pas installé. @@ -85,10 +86,12 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement -si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en -production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si +`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il +suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide +et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index 6e08b54..f8e7bf9 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -119,6 +119,11 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame: LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas seulement `capacity_kw`. + + 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 + scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees. """ typee = frame.copy() for colonne in NUMERIC_COLUMNS: diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py index adf9634..c0b7e3f 100644 --- a/ml/enervision_ml/score.py +++ b/ml/enervision_ml/score.py @@ -30,6 +30,13 @@ from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_feature # Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure. LOOKBACK = timedelta(days=21) +# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure +# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont +# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait +# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard) +# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours. +MAX_STALENESS = timedelta(hours=24) + TARGET_METRIC = "consumption_kwh" PERIOD_MINUTES = 60 LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" @@ -38,6 +45,14 @@ INSUFFICIENT_DATA_REASON = ( ) +def _stale_reason(age: pd.Timedelta) -> str: + return ( + f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h " + f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement " + "arrêtée pour ce site." + ) + + @dataclass(frozen=True, slots=True) class ScoredSite: site_id: str @@ -86,10 +101,31 @@ def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True) -def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]: +def score( + booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime +) -> list[ScoredSite]: resultats: list[ScoredSite] = [] - insuffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].isna()] + # `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) : + # on en deduit l'age de cette derniere lecture par rapport a `instant`. + travail = scoring_frame.copy() + travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1)) + + perimes = travail[travail["_age"] > MAX_STALENESS] + for enregistrement in _records(perimes): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=_stale_reason(enregistrement["_age"]), + ) + ) + + a_jour = travail[travail["_age"] <= MAX_STALENESS] + + insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()] for enregistrement in _records(insuffisants): resultats.append( ScoredSite( @@ -101,7 +137,7 @@ def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite] ) ) - suffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].notna()] + suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()] if not suffisants.empty: typee = suffisants.copy() typee["site_type"] = typee["site_type"].astype("category") @@ -163,20 +199,23 @@ def write_predictions( connection.execute(_INSERT_PREDICTION, lignes) -def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]: - if csv_path is not None: - brute = load_from_csv(csv_path) - instant = now or ( - brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) - ) - return brute[brute["timestamp"] >= instant - LOOKBACK], instant +def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]: + brute = load_from_csv(csv_path) + instant = now or ( + brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) + ) + return brute[brute["timestamp"] >= instant - LOOKBACK], instant - instant = now or datetime.now(UTC) - engine = create_engine(config.database_url()) - try: - return load_recent_from_database(engine, since=instant - LOOKBACK), instant - finally: - engine.dispose() + +def _score_frame( + recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime +) -> list[ScoredSite]: + scoring_frame = build_scoring_frame(recent, site_id=site_id) + if scoring_frame.empty: + return [] + + booster = lgb.Booster(model_file=str(model_path)) + return score(booster, scoring_frame, instant=instant) def run_scoring( @@ -190,29 +229,27 @@ def run_scoring( En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future" calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base - joignable, cf. `ml/README.md`. + joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans + `build_scoring_frame` : le filtrer aussi ici serait redondant. """ - recent, _instant = _load_recent(csv_path=csv_path, now=now) - if site_id is not None: - recent = recent[recent["site_id"] == site_id] + if csv_path is not None: + recent, instant = _load_recent_from_csv(csv_path, now=now) + return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - scoring_frame = build_scoring_frame(recent, site_id=site_id) - if scoring_frame.empty: - return [] + # Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape. + engine = create_engine(config.database_url()) + try: + instant = now or datetime.now(UTC) + recent = load_recent_from_database(engine, since=instant - LOOKBACK) + resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - booster = lgb.Booster(model_file=str(model_path)) - resultats = score(booster, scoring_frame) - - if csv_path is None: reference = model_reference(model_path) - engine = create_engine(config.database_url()) - try: - with engine.begin() as connection: - write_predictions(connection, resultats, reference=reference) - finally: - engine.dispose() + with engine.begin() as connection: + write_predictions(connection, resultats, reference=reference) - return resultats + return resultats + finally: + engine.dispose() def parse_args() -> argparse.Namespace: diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py index 0a1f022..0aa42a1 100644 --- a/ml/tests/test_data.py +++ b/ml/tests/test_data.py @@ -1,34 +1,55 @@ +from pathlib import Path + import pandas as pd -from enervision_ml.data import NUMERIC_COLUMNS, OUTPUT_COLUMNS, _typer +from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv + +_CSV_HEADER = ( + "site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent," + "solar_irradiance_wm2,is_working_hours,site_type" +) -def make_frame_with_object_dtype_capacity() -> pd.DataFrame: - # Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base : - # dtype `object` rempli de `None`, pas `float64` rempli de `NaN`. - frame = pd.DataFrame( - {colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS} +def write_csv(tmp_path: Path, *lignes: str) -> Path: + csv_path = tmp_path / "recent.csv" + csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n") + return csv_path + + +def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None: + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") + + frame = load_from_csv(csv_path) + + for colonne in NUMERIC_COLUMNS: + assert frame[colonne].dtype == "float64" + + +def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None: + # Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type + # alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de + # divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement + # `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui). + csv_path = write_csv( + tmp_path, + "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office", + "SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office", ) - for colonne in NUMERIC_COLUMNS: - frame[colonne] = pd.Series([None, None], dtype="object") - return frame + + frame = load_from_csv(csv_path) + + assert frame["consumption_kwh"].dtype == "float64" + assert frame["consumption_kwh"].iloc[0] == 10.5 + assert pd.isna(frame["consumption_kwh"].iloc[1]) -def test_typer_coerces_an_all_null_object_column_to_float() -> None: - frame = make_frame_with_object_dtype_capacity() +def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None: + # `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette + # affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test + # garde le contrat visible malgre tout, au cas ou l'implementation changerait. + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") - typee = _typer(frame) + frame = load_from_csv(csv_path) - for colonne in NUMERIC_COLUMNS: - assert typee[colonne].dtype == "float64" - assert typee[colonne].isna().all() - - -def test_typer_preserves_real_numeric_values() -> None: - frame = make_frame_with_object_dtype_capacity() - frame["capacity_kw"] = pd.Series([100.0, None], dtype="object") - - typee = _typer(frame) - - assert typee["capacity_kw"].tolist()[0] == 100.0 - assert pd.isna(typee["capacity_kw"].tolist()[1]) + assert frame["capacity_kw"].dtype == "float64" + assert pd.isna(frame["capacity_kw"].iloc[0]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py index 28b0af5..fb3e015 100644 --- a/ml/tests/test_score.py +++ b/ml/tests/test_score.py @@ -8,6 +8,7 @@ import pytest from enervision_ml.features import TARGET_COLUMN from enervision_ml.score import ( LAG_168H_COLUMN, + MAX_STALENESS, ScoredSite, build_scoring_frame, model_reference, @@ -123,12 +124,23 @@ def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> assert scoring_frame.empty +def target_at_for(depart: datetime, heures: int) -> datetime: + """`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture + + 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption, + pour les tests qui ne visent pas ce filtre.""" + return depart + timedelta(hours=heures) + + def test_score_marks_insufficient_history_without_calling_the_model() -> None: depart = datetime(2026, 1, 1, tzinfo=UTC) scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart)) booster = FakeBooster() - resultats = score(booster, scoring_frame) # type: ignore[arg-type] + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 100), + ) assert resultats == [ ScoredSite( @@ -147,7 +159,11 @@ def test_score_predicts_when_history_is_sufficient() -> None: scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) booster = FakeBooster(valeur=99.5) - resultats = score(booster, scoring_frame) # type: ignore[arg-type] + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 200), + ) assert len(resultats) == 1 assert resultats[0].status == "available" @@ -156,6 +172,37 @@ def test_score_predicts_when_history_is_sufficient() -> None: assert booster.appels == [1] +def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + # Historique largement suffisant (168h+), mais l'instant de reference est loin apres la + # derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique. + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1) + booster = FakeBooster() + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert len(resultats) == 1 + assert resultats[0].status == "insufficient_data" + assert resultats[0].predicted_value is None + assert "vieille" in (resultats[0].failure_reason or "") + assert booster.appels == [] + + +def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + # `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure + # pour retomber exactement sur le seuil, ni en dessous ni au dessus. + instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1) + booster = FakeBooster(valeur=12.0) + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert resultats[0].status == "available" + assert booster.appels == [1] + + def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None: connection = FakeConnection() From aeb07e14db25ded4fbe5b272c8516d29be6aa77c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 15:49:54 +0200 Subject: [PATCH 175/205] =?UTF-8?q?feat(backend):=20moteur=20de=20r=C3=A8g?= =?UTF-8?q?les=20de=20recommandations=20et=20route=20de=20g=C3=A9n=C3=A9ra?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recommendation` n'avait aucun écrivain : les quatre couches de lecture étaient livrées, mais rien ne produisait de ligne. Le moteur comble ce trou. Le catalogue `REGLES` vit dans `app/services/`, pas dans `ml/` : il lit `alert.type`, `alert.severity`, `alert.value` et `alert.threshold`, sans modèle ni feature, et s'appuie sur deux repositories existants. L'arbitrage avec l'ADR 0005, qui annonçait #38 du côté ML, est tranché par l'ADR 0006. Sept règles, cinq par type d'alerte et deux transverses (sévérité critique, dépassement d'au moins 20 % du seuil), donc une à trois recommandations par alerte. L'idempotence est portée par la base : `create_missing()` insère en `ON CONFLICT DO NOTHING` sur `uq_recommendation_alert_rule`, ce qui supprime la fenêtre entre un contrôle préalable et l'insertion. `rule_reference` devient de ce fait une clé fonctionnelle, d'où le suffixe de version sur chaque référence. Deux déclencheurs : `POST /api/v1/recommendations/generate` réservé `admin`, et `python -m app.cli generate-recommendations` (cible `make recommendations`). Limite connue : aucune source n'alimente `alert` aujourd'hui, ni détection interne (#104) ni ingestion de l'API Mock. La route répond, le rapport reste à zéro, et la chaîne s'allume sans retoucher le moteur le jour où les alertes existent. Tests : 80 unitaires et API verts, plus 6 d'intégration dont l'idempotence jouée contre PostgreSQL. Closes #38 --- Makefile | 5 +- apps/backend/README.md | 1 + apps/backend/app/api/deps.py | 6 +- apps/backend/app/api/openapi.py | 2 +- .../app/api/v1/endpoints/recommendations.py | 30 +++- apps/backend/app/cli.py | 31 ++++ .../app/repositories/recommendation.py | 25 +++ apps/backend/app/schemas/recommendation.py | 6 + apps/backend/app/services/recommendation.py | 38 ++++- .../app/services/recommendation_rules.py | 117 +++++++++++++++ apps/backend/openapi.json | 109 +++++++++++++- apps/backend/tests/api/acces.py | 1 + .../backend/tests/api/test_recommendations.py | 60 +++++++- .../tests/repositories/test_recommendation.py | 42 +++++- .../tests/services/test_recommendation.py | 134 +++++++++++++++-- .../services/test_recommendation_rules.py | 142 ++++++++++++++++++ apps/backend/tests/test_cli.py | 27 ++++ .../0006-moteur-de-regles-dans-le-backend.md | 77 ++++++++++ docs/architecture/00-vue-ensemble.md | 5 + docs/architecture/20-backend.md | 14 ++ docs/architecture/40-data.md | 5 + 21 files changed, 854 insertions(+), 23 deletions(-) create mode 100644 apps/backend/app/services/recommendation_rules.py create mode 100644 apps/backend/tests/services/test_recommendation_rules.py create mode 100644 docs/adr/0006-moteur-de-regles-dans-le-backend.md diff --git a/Makefile b/Makefile index 2a4b3d2..a2a171f 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ML := ml .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train ml-score + ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -77,6 +77,9 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) +recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel + cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/README.md b/apps/backend/README.md index 6c48b3a..2aebb52 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -113,6 +113,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | | `/api/v1/recommendations` | Liste les recommandations | `lecteur` | | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` | +| `/api/v1/recommendations/generate` | Génère les recommandations depuis les alertes (POST) | `admin` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c247f4c..abf6293 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -187,7 +187,11 @@ AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] def get_recommendation_service(session: SessionDep) -> RecommendationService: - return RecommendationService(recommendations=RecommendationRepository(session)) + return RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 11b2604..8ca8c08 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "recommendations", "description": ( "Consultation des recommandations issues des alertes. Accessible à partir du rôle " - "`lecteur`." + "`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." ), }, { diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py index 87e8be1..180808a 100644 --- a/apps/backend/app/api/v1/endpoints/recommendations.py +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -1,13 +1,18 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, RecommendationServiceDep -from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.recommendation import RecommendationResponse +from app.schemas.recommendation import ( + RecommendationGenerationResponse, + RecommendationResponse, +) from app.services.recommendation import RecommendationNotFoundError router = APIRouter() +REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION} + REPONSES_INTROUVABLE: Reponses = { **REPONSE_VALIDATION, 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, @@ -38,3 +43,22 @@ async def get_recommendation( status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable" ) from erreur return RecommendationResponse.model_validate(recommendation) + + +@router.post( + "/generate", + response_model=RecommendationGenerationResponse, + summary="Génère les recommandations à partir des alertes", + responses=REPONSES_GENERATION, +) +async def generate_recommendations( + _: AdminDep, + service: RecommendationServiceDep, + site_id: str | None = None, +) -> RecommendationGenerationResponse: + rapport = await service.generate(site_id=site_id) + return RecommendationGenerationResponse( + alerts_examined=rapport.alertes_examinees, + recommendations_created=rapport.recommandations_creees, + already_present=rapport.deja_presentes, + ) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index f713fa5..5822e08 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -22,8 +22,11 @@ from app.core.hashing import build_hasher from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app +from app.repositories.alert import AlertRepository +from app.repositories.recommendation import RecommendationRepository from app.repositories.user import UserRepository from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite +from app.services.recommendation import RecommendationService LONGUEUR_MOT_DE_PASSE_GENERE = 24 CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -63,6 +66,22 @@ async def create_admin( ) +async def generate_recommendations(*, site_id: str | None) -> str: + async with get_session_factory()() as session: + service = RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) + rapport = await service.generate(site_id=site_id) + + return ( + f"{rapport.alertes_examinees} alerte(s) examinée(s), " + f"{rapport.recommandations_creees} recommandation(s) créée(s), " + f"{rapport.deja_presentes} déjà présente(s)" + ) + + # Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le # fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle # de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris. @@ -109,6 +128,14 @@ def build_parser() -> argparse.ArgumentParser: "export-openapi", help="Écrit le contrat OpenAPI sur disque" ) contrat.add_argument("--output", default=str(CHEMIN_CONTRAT)) + + recommandations = sous_commandes.add_parser( + "generate-recommendations", + help="Applique le moteur de règles aux alertes en base", + ) + recommandations.add_argument( + "--site-id", default=None, help="Limite le traitement aux alertes d'un site" + ) return parser @@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int: print(export_openapi(Path(arguments.output))) return 0 + if arguments.commande == "generate-recommendations": + print(asyncio.run(generate_recommendations(site_id=arguments.site_id))) + return 0 + mot_de_passe = read_password(generate=arguments.generate) succes, message = asyncio.run( diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py index 7870131..8b07349 100644 --- a/apps/backend/app/repositories/recommendation.py +++ b/apps/backend/app/repositories/recommendation.py @@ -1,11 +1,21 @@ from collections.abc import Sequence +from dataclasses import asdict, dataclass from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Recommendation +@dataclass(frozen=True, slots=True) +class NouvelleRecommandation: + alert_id: int + action: str + explanation: str + rule_reference: str + + class RecommendationRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -20,3 +30,18 @@ class RecommendationRepository: ) recommendation: Recommendation | None = await self._session.scalar(requete) return recommendation + + # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une + # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + if not nouvelles: + return 0 + + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees = (await self._session.scalars(requete)).all() + return len(creees) diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py index 8764615..bb22d02 100644 --- a/apps/backend/app/schemas/recommendation.py +++ b/apps/backend/app/schemas/recommendation.py @@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel): explanation: str rule_reference: str created_at: datetime + + +class RecommendationGenerationResponse(BaseModel): + alerts_examined: int + recommendations_created: int + already_present: int diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py index 31115ae..6b0ceb6 100644 --- a/apps/backend/app/services/recommendation.py +++ b/apps/backend/app/services/recommendation.py @@ -1,7 +1,15 @@ from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol from app.models.energy import Recommendation +from app.repositories.alert import AlertRepository from app.repositories.recommendation import RecommendationRepository +from app.services.recommendation_rules import applique_les_regles + + +class Transaction(Protocol): + async def commit(self) -> None: ... class RecommendationError(Exception): @@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError): pass +@dataclass(frozen=True, slots=True) +class RapportGeneration: + alertes_examinees: int + recommandations_creees: int + deja_presentes: int + + class RecommendationService: - def __init__(self, *, recommendations: RecommendationRepository) -> None: + def __init__( + self, + *, + recommendations: RecommendationRepository, + alerts: AlertRepository, + transaction: Transaction, + ) -> None: self._recommendations = recommendations + self._alerts = alerts + self._transaction = transaction async def list_all(self) -> Sequence[Recommendation]: return await self._recommendations.list_all() @@ -24,3 +47,16 @@ class RecommendationService: if recommendation is None: raise RecommendationNotFoundError(recommendation_id) return recommendation + + async def generate(self, *, site_id: str | None = None) -> RapportGeneration: + alertes = await self._alerts.list_all(site_id=site_id) + nouvelles = [nouvelle for alerte in alertes for nouvelle in applique_les_regles(alerte)] + + creees = await self._recommendations.create_missing(nouvelles) + await self._transaction.commit() + + return RapportGeneration( + alertes_examinees=len(alertes), + recommandations_creees=creees, + deja_presentes=len(nouvelles) - creees, + ) diff --git a/apps/backend/app/services/recommendation_rules.py b/apps/backend/app/services/recommendation_rules.py new file mode 100644 index 0000000..72fa185 --- /dev/null +++ b/apps/backend/app/services/recommendation_rules.py @@ -0,0 +1,117 @@ +# Piège : `rule_reference` est la clé d'idempotence en base, portée par la contrainte +# `uq_recommendation_alert_rule`. Renommer une référence déjà livrée ne remplace pas les +# recommandations existantes, il en crée de nouvelles à côté. Une règle qui change de sens +# prend donc une référence suffixée `-v2` - REGLES. + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from app.models.energy import Alert +from app.repositories.recommendation import NouvelleRecommandation +from app.schemas.alert import AlertSeverity, AlertType + +FACTEUR_DEPASSEMENT_MAJEUR: Final = 1.2 +POURCENTAGE_DEPASSEMENT_MAJEUR: Final = round((FACTEUR_DEPASSEMENT_MAJEUR - 1) * 100) + + +@dataclass(frozen=True, slots=True) +class Regle: + reference: str + action: str + declencheur: Callable[[Alert], bool] + motif: Callable[[Alert], str] + + +def _du_type(attendu: AlertType) -> Callable[[Alert], bool]: + return lambda alerte: alerte.type == attendu + + +def _de_severite(attendue: AlertSeverity) -> Callable[[Alert], bool]: + return lambda alerte: alerte.severity == attendue + + +# Un seuil nul ou négatif rendrait le rapport `value / threshold` arbitraire : l'alerte ne +# renseigne alors aucun dépassement exploitable, et la règle ne se déclenche pas. +def _depasse_largement_le_seuil(alerte: Alert) -> bool: + if alerte.value is None or alerte.threshold is None or alerte.threshold <= 0: + return False + return alerte.value >= alerte.threshold * FACTEUR_DEPASSEMENT_MAJEUR + + +REGLES: Final[tuple[Regle, ...]] = ( + Regle( + reference="spike-delestage-v1", + action="Délester les équipements non prioritaires sur le créneau du pic", + declencheur=_du_type(AlertType.SPIKE), + motif=lambda alerte: f"Pic de consommation signalé sur le site {alerte.site_id}", + ), + Regle( + reference="threshold-reduction-v1", + action="Ramener la puissance appelée sous le seuil contractuel", + declencheur=_du_type(AlertType.THRESHOLD), + motif=lambda alerte: f"Seuil de consommation dépassé sur le site {alerte.site_id}", + ), + Regle( + reference="outage-secours-v1", + action="Basculer sur l'alimentation de secours et prévenir l'exploitant", + declencheur=_du_type(AlertType.OUTAGE), + motif=lambda alerte: ( + f"Risque de surcharge ou de coupure imminente sur le site {alerte.site_id}" + ), + ), + Regle( + reference="sensor-maintenance-v1", + action="Planifier une intervention de maintenance sur le capteur", + declencheur=_du_type(AlertType.SENSOR), + motif=lambda alerte: ( + f"Capteur défaillant sur le site {alerte.site_id}, les mesures ne sont plus fiables" + ), + ), + Regle( + reference="anomaly-verification-v1", + action="Confronter la mesure à la prévision et vérifier le paramétrage du site", + declencheur=_du_type(AlertType.ANOMALY), + motif=lambda alerte: ( + f"Écart anormal entre la mesure et le comportement attendu du site {alerte.site_id}" + ), + ), + Regle( + reference="escalade-astreinte-v1", + action="Escalader à l'astreinte sous une heure", + declencheur=_de_severite(AlertSeverity.CRITICAL), + motif=lambda alerte: f"Alerte de sévérité critique sur le site {alerte.site_id}", + ), + Regle( + reference="contrat-puissance-v1", + action="Réévaluer la puissance souscrite au contrat", + declencheur=_depasse_largement_le_seuil, + motif=lambda alerte: ( + f"Dépassement d'au moins {POURCENTAGE_DEPASSEMENT_MAJEUR} % du seuil " + f"sur le site {alerte.site_id}" + ), + ), +) + + +def applique_les_regles(alerte: Alert) -> list[NouvelleRecommandation]: + contexte = _contexte_de_mesure(alerte) + return [ + NouvelleRecommandation( + alert_id=alerte.alert_id, + action=regle.action, + explanation=f"{regle.motif(alerte)}{contexte}.", + rule_reference=regle.reference, + ) + for regle in REGLES + if regle.declencheur(alerte) + ] + + +def _contexte_de_mesure(alerte: Alert) -> str: + if alerte.value is None: + return "" + grandeur = alerte.metric or "valeur" + if alerte.threshold is None: + return f" ({grandeur} mesurée à {alerte.value})" + return f" ({grandeur} mesurée à {alerte.value}, seuil {alerte.threshold})" diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index f7c445c..67b3877 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1453,6 +1453,90 @@ } } }, + "/api/v1/recommendations/generate": { + "post": { + "tags": [ + "recommendations" + ], + "summary": "Génère les recommandations à partir des alertes", + "operationId": "generate_recommendations_api_v1_recommendations_generate_post", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationGenerationResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + } + } + }, "/api/v1/stats/summary": { "get": { "tags": [ @@ -2344,6 +2428,29 @@ ], "title": "ReadingSource" }, + "RecommendationGenerationResponse": { + "properties": { + "alerts_examined": { + "type": "integer", + "title": "Alerts Examined" + }, + "recommendations_created": { + "type": "integer", + "title": "Recommendations Created" + }, + "already_present": { + "type": "integer", + "title": "Already Present" + } + }, + "type": "object", + "required": [ + "alerts_examined", + "recommendations_created", + "already_present" + ], + "title": "RecommendationGenerationResponse" + }, "RecommendationResponse": { "properties": { "recommendation_id": { @@ -3153,7 +3260,7 @@ }, { "name": "recommendations", - "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`." + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." }, { "name": "stats", diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py index e3b641b..2b38374 100644 --- a/apps/backend/tests/api/acces.py +++ b/apps/backend/tests/api/acces.py @@ -51,6 +51,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = { ("GET", "/api/v1/alerts"): Role.LECTEUR, ("GET", "/api/v1/recommendations"): Role.LECTEUR, ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, + ("POST", "/api/v1/recommendations/generate"): Role.ADMIN, ("GET", "/api/v1/stats/summary"): Role.LECTEUR, ("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR, diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py index d01db09..6e854bd 100644 --- a/apps/backend/tests/api/test_recommendations.py +++ b/apps/backend/tests/api/test_recommendations.py @@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Recommendation -from app.services.recommendation import RecommendationNotFoundError +from app.services.recommendation import RapportGeneration, RecommendationNotFoundError MOMENT = datetime(2024, 1, 1, tzinfo=UTC) @@ -40,6 +40,7 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.recommendation = recommendation() + self.site_demande: str | None = None async def list_all(self) -> list[Recommendation]: return [self.recommendation] @@ -49,6 +50,10 @@ class FauxService: raise self._erreur return self.recommendation + async def generate(self, *, site_id: str | None = None) -> RapportGeneration: + self.site_demande = site_id + return RapportGeneration(alertes_examinees=2, recommandations_creees=3, deja_presentes=1) + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -142,3 +147,56 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/recommendations/404") assert response.status_code == 404 + + +@pytest.fixture +def admin_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.ADMIN) + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi_en_admin(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_recommendation_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_recommendation_service, None) + + +async def test_generate_recommendations_returns_the_generation_report( + servi_en_admin: Callable[[], FauxService], client: AsyncClient +) -> None: + servi_en_admin() + + response = await client.post("/api/v1/recommendations/generate") + + assert response.status_code == 200 + assert response.json() == { + "alerts_examined": 2, + "recommendations_created": 3, + "already_present": 1, + } + + +async def test_generate_recommendations_forwards_the_requested_site( + servi_en_admin: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi_en_admin() + + await client.post("/api/v1/recommendations/generate", params={"site_id": "SITE002"}) + + assert service.site_demande == "SITE002" + + +async def test_generate_recommendations_refuses_a_reader( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.post("/api/v1/recommendations/generate") + + assert response.status_code == 403 diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py index 075c9eb..64b3b5f 100644 --- a/apps/backend/tests/repositories/test_recommendation.py +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -5,7 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert, Recommendation, Site -from app.repositories.recommendation import RecommendationRepository +from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository pytestmark = pytest.mark.integration @@ -83,3 +83,43 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier( await session.rollback() assert identifiants == sorted(identifiants) + + +def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation: + return NouvelleRecommandation( + alert_id=alert_id, + action="Délester les équipements non prioritaires", + explanation="Pic de consommation signalé.", + rule_reference=reference, + ) + + +async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + + creees = await depot.create_missing( + [nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")] + ) + await session.rollback() + + assert creees == 2 + + +async def test_create_missing_ignores_a_rule_already_held_for_the_alert( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + await depot.create_missing([nouvelle(alert_id)]) + + creees = await depot.create_missing([nouvelle(alert_id)]) + await session.rollback() + + assert creees == 0 + + +async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None: + creees = await RecommendationRepository(session).create_missing([]) + + assert creees == 0 diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py index e8ed2b2..725df25 100644 --- a/apps/backend/tests/services/test_recommendation.py +++ b/apps/backend/tests/services/test_recommendation.py @@ -1,10 +1,14 @@ +from collections.abc import Sequence from datetime import UTC, datetime import pytest -from app.models.energy import Recommendation +from app.models.energy import Alert, Recommendation +from app.repositories.recommendation import NouvelleRecommandation from app.services.recommendation import RecommendationNotFoundError, RecommendationService +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + def recommendation(recommendation_id: int = 1) -> Recommendation: return Recommendation( @@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation: action="Vérifier la consommation", explanation="Pic détecté", rule_reference="spike-v1", - created_at=datetime(2024, 1, 1, tzinfo=UTC), + created_at=MOMENT, + ) + + +def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity=severity, + message="Pic de consommation", + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, ) class FakeRepository: - def __init__(self, recommendations: list[Recommendation]) -> None: + def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None: self._recommendations = recommendations + self._creees = creees + self.recues: list[NouvelleRecommandation] = [] async def list_all(self) -> list[Recommendation]: return self._recommendations @@ -29,27 +53,111 @@ class FakeRepository: (r for r in self._recommendations if r.recommendation_id == recommendation_id), None ) + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + self.recues = list(nouvelles) + return len(self.recues) if self._creees is None else self._creees -async def test_list_all_returns_the_repository_recommendations() -> None: - service = RecommendationService( - recommendations=FakeRepository([recommendation(1), recommendation(2)]) + +class FakeAlertRepository: + def __init__(self, alertes: list[Alert]) -> None: + self._alertes = alertes + self.site_demande: str | None = None + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.site_demande = site_id + if site_id is None: + return self._alertes + return [a for a in self._alertes if a.site_id == site_id] + + +class FakeTransaction: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +def service( + recommendations: FakeRepository | None = None, + alerts: FakeAlertRepository | None = None, + transaction: FakeTransaction | None = None, +) -> RecommendationService: + return RecommendationService( + recommendations=recommendations or FakeRepository([]), + alerts=alerts or FakeAlertRepository([]), + transaction=transaction or FakeTransaction(), ) - recommendations = await service.list_all() + +async def test_list_all_returns_the_repository_recommendations() -> None: + depot = FakeRepository([recommendation(1), recommendation(2)]) + + recommendations = await service(recommendations=depot).list_all() assert [r.recommendation_id for r in recommendations] == [1, 2] async def test_get_by_id_returns_the_matching_recommendation() -> None: - service = RecommendationService(recommendations=FakeRepository([recommendation(1)])) - - trouve = await service.get_by_id(1) + trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1) assert trouve.recommendation_id == 1 async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None: - service = RecommendationService(recommendations=FakeRepository([])) - with pytest.raises(RecommendationNotFoundError): - await service.get_by_id(404) + await service().get_by_id(404) + + +async def test_generate_persists_one_proposal_per_triggered_rule() -> None: + depot = FakeRepository([]) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")]) + ).generate() + + assert {n.rule_reference for n in depot.recues} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + } + assert rapport.recommandations_creees == 2 + + +async def test_generate_commits_once() -> None: + transaction = FakeTransaction() + + await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate() + + assert transaction.commits == 1 + + +async def test_generate_restricts_the_alerts_to_the_requested_site() -> None: + alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")]) + depot = FakeRepository([]) + + rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002") + + assert alertes.site_demande == "SITE002" + assert rapport.alertes_examinees == 1 + assert {n.alert_id for n in depot.recues} == {2} + + +async def test_generate_reports_nothing_when_no_alert_matches() -> None: + rapport = await service().generate() + + assert rapport.alertes_examinees == 0 + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 0 + + +async def test_generate_counts_the_proposals_the_database_already_held() -> None: + depot = FakeRepository([], creees=0) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte()]) + ).generate() + + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 1 diff --git a/apps/backend/tests/services/test_recommendation_rules.py b/apps/backend/tests/services/test_recommendation_rules.py new file mode 100644 index 0000000..28a58fa --- /dev/null +++ b/apps/backend/tests/services/test_recommendation_rules.py @@ -0,0 +1,142 @@ +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Alert +from app.services.recommendation_rules import FACTEUR_DEPASSEMENT_MAJEUR, applique_les_regles + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +def alerte( + *, + alert_id: int = 1, + type_alerte: str = "spike", + severity: str = "high", + value: float | None = None, + threshold: float | None = None, + metric: str | None = None, + site_id: str = "SITE001", +) -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type=type_alerte, + severity=severity, + message="Alerte de test", + value=value, + threshold=threshold, + metric=metric, + prediction_id=None, + raw_data={}, + ) + + +@pytest.mark.parametrize( + ("type_alerte", "attendue"), + [ + ("spike", "spike-delestage-v1"), + ("threshold", "threshold-reduction-v1"), + ("outage", "outage-secours-v1"), + ("sensor", "sensor-maintenance-v1"), + ("anomaly", "anomaly-verification-v1"), + ], + ids=["pic", "seuil", "coupure", "capteur", "anomalie"], +) +def test_each_alert_type_yields_its_own_rule(type_alerte: str, attendue: str) -> None: + proposees = applique_les_regles(alerte(type_alerte=type_alerte)) + + assert [p.rule_reference for p in proposees] == [attendue] + + +def test_a_critical_alert_adds_the_escalation_rule() -> None: + proposees = applique_les_regles(alerte(severity="critical")) + + assert "escalade-astreinte-v1" in {p.rule_reference for p in proposees} + + +@pytest.mark.parametrize("severity", ["low", "medium", "high"], ids=["faible", "moyenne", "haute"]) +def test_a_non_critical_alert_does_not_escalate(severity: str) -> None: + proposees = applique_les_regles(alerte(severity=severity)) + + assert "escalade-astreinte-v1" not in {p.rule_reference for p in proposees} + + +def test_a_large_overshoot_adds_the_contract_rule() -> None: + proposees = applique_les_regles( + alerte(value=720.0 * FACTEUR_DEPASSEMENT_MAJEUR, threshold=720.0) + ) + + assert "contrat-puissance-v1" in {p.rule_reference for p in proposees} + + +def test_an_overshoot_below_the_factor_does_not_add_the_contract_rule() -> None: + proposees = applique_les_regles(alerte(value=800.0, threshold=720.0)) + + assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees} + + +@pytest.mark.parametrize( + ("value", "threshold"), + [(None, 720.0), (900.0, None), (900.0, 0.0), (900.0, -10.0)], + ids=["sans mesure", "sans seuil", "seuil nul", "seuil negatif"], +) +def test_the_contract_rule_stays_silent_without_an_exploitable_threshold( + value: float | None, threshold: float | None +) -> None: + proposees = applique_les_regles(alerte(value=value, threshold=threshold)) + + assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees} + + +def test_the_explanation_quotes_the_measure_and_the_threshold() -> None: + proposees = applique_les_regles(alerte(value=812.5, threshold=720.0, metric="consumption_kw")) + + assert "(consumption_kw mesurée à 812.5, seuil 720.0)" in proposees[0].explanation + + +def test_the_explanation_quotes_the_measure_alone_when_no_threshold_is_known() -> None: + proposees = applique_les_regles(alerte(value=812.5, metric="consumption_kw")) + + assert "(consumption_kw mesurée à 812.5)" in proposees[0].explanation + + +def test_the_explanation_omits_the_measure_when_the_alert_carries_none() -> None: + proposees = applique_les_regles(alerte()) + + assert "(" not in proposees[0].explanation + + +def test_the_explanation_names_the_site() -> None: + proposees = applique_les_regles(alerte(site_id="SITE042")) + + assert "SITE042" in proposees[0].explanation + + +def test_every_proposal_carries_the_alert_identifier() -> None: + proposees = applique_les_regles(alerte(alert_id=77, severity="critical")) + + assert {p.alert_id for p in proposees} == {77} + + +def test_an_alert_never_yields_the_same_rule_twice() -> None: + proposees = applique_les_regles( + alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw") + ) + + assert len(proposees) == len({p.rule_reference for p in proposees}) + + +def test_a_critical_alert_over_the_threshold_yields_the_three_rules() -> None: + proposees = applique_les_regles( + alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw") + ) + + assert {p.rule_reference for p in proposees} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + "contrat-puissance-v1", + } diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 7344bf7..2edf814 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -118,3 +118,30 @@ def test_main_exports_the_contract_without_asking_for_a_password( assert code == 0 assert destination.exists() assert str(destination) in capsys.readouterr().out + + +def test_build_parser_reads_the_generate_recommendations_arguments() -> None: + arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"]) + + assert arguments.commande == "generate-recommendations" + assert arguments.site_id == "SITE002" + + +def test_build_parser_defaults_the_generation_to_every_site() -> None: + arguments = cli.build_parser().parse_args(["generate-recommendations"]) + + assert arguments.site_id is None + + +def test_main_generates_the_recommendations_without_asking_for_a_password( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + async def fausse_generation(*, site_id: str | None) -> str: + return f"génération lancée pour {site_id}" + + monkeypatch.setattr(cli, "generate_recommendations", fausse_generation) + + code = cli.main(["generate-recommendations", "--site-id", "SITE002"]) + + assert code == 0 + assert "SITE002" in capsys.readouterr().out diff --git a/docs/adr/0006-moteur-de-regles-dans-le-backend.md b/docs/adr/0006-moteur-de-regles-dans-le-backend.md new file mode 100644 index 0000000..794825e --- /dev/null +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -0,0 +1,77 @@ +# 0006 - Le moteur de règles de recommandation vit dans le backend + +- Statut : accepté +- Date : 2026-09-18 + +## Contexte + +L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le +schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation, +rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité +`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc +**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision. + +Deux emplacements se disputaient le code : + +1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome + qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire. + L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant + que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même + module `enervision_ml.features` ». +2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ». + +## Décision + +**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur +`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()` +qui l'applique, persiste et valide la transaction. + +Trois raisons : + +- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et + `alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de + l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des + features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel + « prédiction et recommandation », pas l'emplacement du code. +- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà + filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à + maintenir deux représentations du même domaine. +- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer + par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans + l'application FastAPI. + +Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli +generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien +n'oblige à exposer un port pour régénérer des recommandations. + +## Conséquences + +- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique : + entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même + commit. +- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà + utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de + ne pas commiter. +- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING` + sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la + fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. + Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée + ferait réapparaître ses recommandations à côté des anciennes. +- **Le moteur ne produira rien tant que `alert` restera vide.** Aucun code ne produit aujourd'hui + de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne + s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. +- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait + à rouvrir : le moteur redeviendrait consommateur du pipeline ML. + +## Alternatives écartées + +- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de + l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération + hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des + features ou un modèle. +- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime + le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles, + et interdit toute trace de ce qui a été proposé et quand. +- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple, + mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que + rien n'exprime à ce stade. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index a650a1e..2f54762 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -165,3 +165,8 @@ Elles vivent dans `../adr/`, pas ici. | ADR | Objet | |---|---| | [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` | +| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | +| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête | +| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | +| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM | +| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 09d3b3d..44d50be 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -146,6 +146,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | +| POST | `/api/v1/recommendations/generate` | Applique le moteur de règles aux alertes, filtrable par `site_id`. `admin` | 401, 403, 422, 500 | | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | @@ -194,6 +195,19 @@ plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`erro LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. [ML-START.md](../../ML-START.md) section 3. +`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique +le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni +feature ML : le catalogue `REGLES` associe à chaque type et à chaque gravité d'alerte une action et +son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit +[40-data.md](40-data.md). L'idempotence est portée par la base, pas par le service : +`RecommendationRepository.create_missing()` insère en `ON CONFLICT DO NOTHING` sur +`uq_recommendation_alert_rule`, donc rejouer la génération sur les mêmes alertes ne crée rien et +le rapport rendu distingue `recommendations_created` de `already_present`. Le même traitement est +disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make +recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend +plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). +Tant qu'aucune source n'alimente `alert`, la route est fonctionnelle mais rend un rapport à zéro. + `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index ffd5e6d..b02fd13 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -224,6 +224,11 @@ Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. Elles servent à l’analyse des données et ne sont pas considérées comme des alertes actuelles. +Les lignes de `recommendation` sont écrites par le moteur de règles du backend +(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate` +ou par `make recommendations`. Le couple `(alert_id, rule_reference)` est unique : rejouer le +moteur sur les mêmes alertes n'ajoute aucune ligne. + ### Relations entre les tables - Un site possède plusieurs mesures, prévisions et alertes. From edd5e82d29633cc010e9a2c7dbd785f32aee1ff7 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 16:04:10 +0200 Subject: [PATCH 176/205] fix --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 43c28fd..49c6abe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -9,7 +9,7 @@ sonar.tests=apps/frontend/src,apps/backend/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 +sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/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 From a9e124a97d55445c72a1a8632f05515c19533873 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 16:10:06 +0200 Subject: [PATCH 177/205] feat(backend): detecte les alertes internes a partir des lectures et previsions --- apps/backend/app/api/deps.py | 7 +- apps/backend/app/detection/__init__.py | 0 apps/backend/app/detection/internal_alerts.py | 68 ++++ apps/backend/app/repositories/alert.py | 34 ++ apps/backend/app/repositories/prediction.py | 15 + apps/backend/app/repositories/reading.py | 12 + apps/backend/app/services/alert.py | 291 ++++++++++++++- apps/backend/tests/repositories/test_alert.py | 57 +++ .../tests/repositories/test_prediction.py | 55 +++ .../tests/repositories/test_reading.py | 50 +++ apps/backend/tests/services/test_alert.py | 347 +++++++++++++++++- apps/backend/tests/test_internal_alerts.py | 65 ++++ docs/architecture/20-backend.md | 29 ++ 13 files changed, 1021 insertions(+), 9 deletions(-) create mode 100644 apps/backend/app/detection/__init__.py create mode 100644 apps/backend/app/detection/internal_alerts.py create mode 100644 apps/backend/tests/test_internal_alerts.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c247f4c..6098403 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -180,7 +180,12 @@ SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] def get_alert_service(session: SessionDep) -> AlertService: - return AlertService(alerts=AlertRepository(session)) + return AlertService( + alerts=AlertRepository(session), + readings=ReadingRepository(session), + predictions=PredictionRepository(session), + sites=SiteRepository(session), + ) AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] diff --git a/apps/backend/app/detection/__init__.py b/apps/backend/app/detection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/detection/internal_alerts.py b/apps/backend/app/detection/internal_alerts.py new file mode 100644 index 0000000..d7e0785 --- /dev/null +++ b/apps/backend/app/detection/internal_alerts.py @@ -0,0 +1,68 @@ +# Détection d'alertes internes EnerVision (issue #104) : script lancé à la main pour l'instant, +# comme `enervision_ml.score` côté ML, sans automatisation Airflow pour l'ordonnancer. + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, datetime + +from app.core.config import get_settings +from app.db.session import get_session_factory +from app.repositories.alert import AlertRepository +from app.repositories.prediction import PredictionRepository +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository +from app.services.alert import AlertService + + +async def run_detection(*, now: datetime | None = None, site_id: str | None = None) -> int: + """Exécute les cinq règles de détection et enregistre les nouvelles alertes. Rend le nombre de + lignes effectivement insérées (les doublons de `source_alert_id` sont silencieusement + ignorés).""" + async with get_session_factory()() as session: + service = AlertService( + alerts=AlertRepository(session), + readings=ReadingRepository(session), + predictions=PredictionRepository(session), + sites=SiteRepository(session), + ) + nouvelles = await service.detect(now=now, site_id=site_id) + await session.commit() + return len(nouvelles) + + +def _parse_instant(valeur: str) -> datetime: + instant = datetime.fromisoformat(valeur) + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="python -m app.detection.internal_alerts", + description="Détection d'alertes internes EnerVision", + ) + parser.add_argument("--site-id", default=None, help="Limite la détection à un seul site.") + parser.add_argument( + "--now", + type=_parse_instant, + default=None, + help=( + "Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante." + ), + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + # Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base. + get_settings() + nombre = asyncio.run(run_detection(now=args.now, site_id=args.site_id)) + print(f"{nombre} nouvelle(s) alerte(s) enregistrée(s).") + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py index 4b0766f..f495a3b 100644 --- a/apps/backend/app/repositories/alert.py +++ b/apps/backend/app/repositories/alert.py @@ -1,6 +1,7 @@ from collections.abc import Sequence from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert @@ -19,3 +20,36 @@ class AlertRepository: if severity is not None: requete = requete.where(Alert.severity == severity) return (await self._session.scalars(requete)).all() + + async def create_many(self, alerts: Sequence[Alert]) -> Sequence[Alert]: + # `ON CONFLICT DO NOTHING` sur `uq_alert_source_reference` : rejouer la détection sur une + # fenêtre qui recouvre une exécution précédente ne doit pas dupliquer une alerte déjà + # enregistrée. `RETURNING` ne renvoie donc que les lignes effectivement insérées. + if not alerts: + return [] + valeurs = [ + { + "source_alert_id": alerte.source_alert_id, + "site_id": alerte.site_id, + "source": alerte.source, + "timestamp": alerte.timestamp, + "type": alerte.type, + "severity": alerte.severity, + "message": alerte.message, + "value": alerte.value, + "threshold": alerte.threshold, + "metric": alerte.metric, + "prediction_id": alerte.prediction_id, + "raw_data": alerte.raw_data, + } + for alerte in alerts + ] + requete = ( + insert(Alert) + .values(valeurs) + .on_conflict_do_nothing(constraint="uq_alert_source_reference") + .returning(Alert) + ) + resultat = await self._session.execute(requete) + await self._session.flush() + return resultat.scalars().all() diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py index f79311a..dd3dc28 100644 --- a/apps/backend/app/repositories/prediction.py +++ b/apps/backend/app/repositories/prediction.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +11,20 @@ class PredictionRepository: def __init__(self, session: AsyncSession) -> None: self._session = session + async def list_since( + self, *, since: datetime, site_id: str | None = None + ) -> Sequence[Prediction]: + # Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de + # `predicted_value` à comparer à une lecture réelle (détection d'anomalie). + requete = ( + select(Prediction) + .where(Prediction.target_at >= since, Prediction.status == "available") + .order_by(Prediction.site_id, Prediction.target_at) + ) + if site_id is not None: + requete = requete.where(Prediction.site_id == site_id) + return (await self._session.scalars(requete)).all() + async def latest_by_site(self) -> Sequence[Prediction]: # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule # ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index d005d16..3e9cec5 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -34,6 +34,18 @@ class ReadingRepository: lecture: Reading | None = await self._session.scalar(requete) return lecture + async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]: + # Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de + # comparer chaque lecture à celle qui la précède immédiatement pour le même site. + requete = ( + select(Reading) + .where(Reading.timestamp >= since) + .order_by(Reading.site_id, Reading.timestamp) + ) + if site_id is not None: + requete = requete.where(Reading.site_id == site_id) + return (await self._session.scalars(requete)).all() + async def list_history( self, *, diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py index a3ad16e..acae8cf 100644 --- a/apps/backend/app/services/alert.py +++ b/apps/backend/app/services/alert.py @@ -1,14 +1,301 @@ from collections.abc import Sequence +from datetime import UTC, datetime, timedelta -from app.models.energy import Alert +from app.models.energy import Alert, Prediction, Reading, Site from app.repositories.alert import AlertRepository +from app.repositories.prediction import PredictionRepository +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository + +# Fenêtre de lectures/prédictions analysée à chaque exécution : assez large pour couvrir une paire +# de lectures consécutives (spike) et une coupure prolongée (outage), sans réanalyser tout +# l'historique à chaque lancement manuel du script de détection. +LOOKBACK = timedelta(hours=48) + +# Cadence nominale d'une lecture : le CSV historique comme l'API Mock livrent un pas horaire. +EXPECTED_INTERVAL = timedelta(hours=1) +# Au-delà de trois pas manqués, on parle de coupure plutôt que d'un simple retard d'ingestion. +OUTAGE_THRESHOLD = EXPECTED_INTERVAL * 3 + +# +/-50% entre deux lectures consécutives du même site. +SPIKE_RELATIVE_THRESHOLD = 0.5 +# 30% d'écart entre la consommation réelle et la prévision du même site/instant. +ANOMALY_RELATIVE_THRESHOLD = 0.3 +# Une prévision quasi nulle rend l'écart relatif ininterprétable ; on l'ignore plutôt. +ANOMALY_MINIMUM_PREDICTED_VALUE = 1e-6 + +THRESHOLD_METRIC = "consumption_kw" +ANOMALY_METRIC = "consumption_kwh" +# `data_quality` -> sévérité du capteur défaillant. `good` est volontairement absent : il ne +# déclenche jamais d'alerte. +QUALITE_VERS_SEVERITE: dict[str, str] = { + "partial": "low", + "degraded": "medium", + "critical": "critical", +} class AlertService: - def __init__(self, *, alerts: AlertRepository) -> None: + def __init__( + self, + *, + alerts: AlertRepository, + readings: ReadingRepository, + predictions: PredictionRepository, + sites: SiteRepository, + ) -> None: self._alerts = alerts + self._readings = readings + self._predictions = predictions + self._sites = sites async def list_all( self, *, site_id: str | None = None, severity: str | None = None ) -> Sequence[Alert]: return await self._alerts.list_all(site_id=site_id, severity=severity) + + async def detect( + self, *, now: datetime | None = None, site_id: str | None = None + ) -> Sequence[Alert]: + """Compare les lectures/prévisions récentes aux cinq règles internes et enregistre les + alertes déclenchées (`source='enervision'`). Idempotent grâce à `source_alert_id` : + rejouer sur une fenêtre déjà analysée ne recrée pas les mêmes lignes.""" + instant = now or datetime.now(UTC) + depuis = instant - LOOKBACK + + sites = await self._sites.list_all() + if site_id is not None: + sites = [site for site in sites if site.site_id == site_id] + sites_par_id = {site.site_id: site for site in sites} + if not sites_par_id: + return [] + + lectures = [ + lecture + for lecture in await self._readings.list_since(since=depuis, site_id=site_id) + if lecture.site_id in sites_par_id + ] + predictions = [ + prediction + for prediction in await self._predictions.list_since(since=depuis, site_id=site_id) + if prediction.site_id in sites_par_id + ] + dernieres_lectures = { + lecture.site_id: lecture + for lecture in await self._readings.latest_by_site() + if lecture.site_id in sites_par_id + } + + candidates = [ + *_detect_threshold(lectures, sites_par_id), + *_detect_spike(lectures), + *_detect_anomaly(lectures, predictions), + *_detect_outage(sites, dernieres_lectures, instant), + *_detect_sensor(lectures), + ] + if not candidates: + return [] + return await self._alerts.create_many(candidates) + + +def _severity_from_ratio(ratio: float) -> str: + if ratio >= 2.0: + return "critical" + if ratio >= 1.5: + return "high" + if ratio >= 1.2: + return "medium" + return "low" + + +def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site]) -> list[Alert]: + # Seuil fixe = la capacité déclarée du site : dépasser `capacity_kw` est un dépassement + # matériel, pas une simple variation, et évite un seuil arbitraire non fourni par le domaine. + alertes = [] + for lecture in lectures: + site = sites_par_id[lecture.site_id] + valeur = lecture.consumption_kw + if site.capacity_kw is None or site.capacity_kw <= 0 or valeur is None: + continue + if valeur <= site.capacity_kw: + continue + alertes.append( + Alert( + source_alert_id=f"threshold:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="threshold", + severity=_severity_from_ratio(valeur / site.capacity_kw), + message=( + f"Puissance appelée {valeur:.1f} kW au-dessus de la capacité du site " + f"({site.capacity_kw:.1f} kW)" + ), + value=valeur, + threshold=site.capacity_kw, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + ) + return alertes + + +def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]: + # `lectures` est triée par site puis par heure (cf. `ReadingRepository.list_since`) : deux + # lignes consécutives du même site sont donc deux mesures consécutives dans le temps. + alertes = [] + precedente: Reading | None = None + for lecture in lectures: + if precedente is None or precedente.site_id != lecture.site_id: + precedente = lecture + continue + avant, apres = precedente.consumption_kw, lecture.consumption_kw + precedente = lecture + if avant is None or apres is None or avant == 0: + continue + variation = abs(apres - avant) / abs(avant) + if variation < SPIKE_RELATIVE_THRESHOLD: + continue + alertes.append( + Alert( + source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="spike", + severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD), + message=( + f"Variation brutale de {variation * 100:.0f}% entre deux lectures " + f"consécutives ({avant:.1f} kW -> {apres:.1f} kW)" + ), + value=apres, + threshold=avant, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + ) + return alertes + + +def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]: + # Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une + # cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`. + predictions_par_cle = { + (prediction.site_id, prediction.target_at): prediction + for prediction in predictions + if prediction.target_metric == ANOMALY_METRIC + } + alertes = [] + for lecture in lectures: + prediction = predictions_par_cle.get((lecture.site_id, lecture.timestamp)) + reel = lecture.consumption_kwh + if prediction is None or reel is None or prediction.predicted_value is None: + continue + predite = prediction.predicted_value + if abs(predite) < ANOMALY_MINIMUM_PREDICTED_VALUE: + continue + ecart = abs(reel - predite) / abs(predite) + if ecart < ANOMALY_RELATIVE_THRESHOLD: + continue + alertes.append( + Alert( + source_alert_id=f"anomaly:{ANOMALY_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="anomaly", + severity=_severity_from_ratio(ecart / ANOMALY_RELATIVE_THRESHOLD), + message=( + f"Écart de {ecart * 100:.0f}% entre la consommation mesurée ({reel:.1f} kWh) " + f"et la prévision ({predite:.1f} kWh)" + ), + value=reel, + threshold=predite, + metric=ANOMALY_METRIC, + prediction_id=prediction.prediction_id, + raw_data={}, + ) + ) + return alertes + + +def _detect_outage( + sites: Sequence[Site], dernieres_lectures: dict[str, Reading], now: datetime +) -> list[Alert]: + alertes = [] + for site in sites: + derniere = dernieres_lectures.get(site.site_id) + if derniere is None: + alertes.append( + _outage_alert( + site.site_id, + now, + reference=None, + message="Aucune lecture n'a jamais été reçue pour ce site", + severity="critical", + ) + ) + continue + absence = now - derniere.timestamp + if absence < OUTAGE_THRESHOLD: + continue + alertes.append( + _outage_alert( + site.site_id, + now, + reference=derniere.timestamp, + message=( + f"Aucune lecture depuis {absence} (dernière lecture : " + f"{derniere.timestamp.isoformat()})" + ), + severity=_severity_from_ratio(absence / OUTAGE_THRESHOLD), + ) + ) + return alertes + + +def _outage_alert( + site_id: str, now: datetime, *, reference: datetime | None, message: str, severity: str +) -> Alert: + return Alert( + source_alert_id=f"outage:{reference.isoformat() if reference is not None else 'jamais'}", + site_id=site_id, + source="enervision", + timestamp=now, + type="outage", + severity=severity, + message=message, + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, + ) + + +def _detect_sensor(lectures: Sequence[Reading]) -> list[Alert]: + alertes = [] + for lecture in lectures: + severite = QUALITE_VERS_SEVERITE.get(lecture.data_quality or "") + if severite is None: + continue + raisons = ", ".join(lecture.null_reasons or []) or "raison non précisée" + alertes.append( + Alert( + source_alert_id=f"sensor:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="sensor", + severity=severite, + message=f"Qualité de mesure {lecture.data_quality} ({raisons})", + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, + ) + ) + return alertes diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py index d2a78d0..16c5a9a 100644 --- a/apps/backend/tests/repositories/test_alert.py +++ b/apps/backend/tests/repositories/test_alert.py @@ -89,3 +89,60 @@ async def test_list_all_returns_an_empty_list_when_there_is_nothing( alertes = await depot.list_all(site_id=identifiant_site()) assert list(alertes) == [] + + +def _alerte_a_inserer(*, site_id: str, source_alert_id: str) -> Alert: + return Alert( + source_alert_id=source_alert_id, + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity="high", + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +async def test_create_many_inserts_every_alert(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + + creees = await depot.create_many( + [ + _alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:a"), + _alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:b"), + ] + ) + identifiants = [a.alert_id for a in creees] + await session.rollback() + + assert len(identifiants) == 2 + assert all(identifiant is not None for identifiant in identifiants) + + +async def test_create_many_skips_a_duplicate_source_alert_id(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + await depot.create_many( + [_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")] + ) + + rejouees = await depot.create_many( + [_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")] + ) + await session.rollback() + + assert rejouees == [] + + +async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession) -> None: + depot = AlertRepository(session) + + creees = await depot.create_many([]) + + assert creees == [] diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py index 2be7ddd..708a469 100644 --- a/apps/backend/tests/repositories/test_prediction.py +++ b/apps/backend/tests/repositories/test_prediction.py @@ -29,6 +29,61 @@ async def creer_prediction( return prediction +async def test_list_since_excludes_predictions_before_the_cutoff(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + dedans = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 16, tzinfo=UTC) + ) + await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC) + ) + + resultats = await depot.list_since( + since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id + ) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [dedans.prediction_id] + + +async def test_list_since_excludes_predictions_that_are_not_available( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + await creer_prediction( + session, + site_id=site.site_id, + target_at=datetime(2026, 9, 16, tzinfo=UTC), + status="insufficient_data", + predicted_value=None, + failure_reason="pas assez d'historique", + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + await session.rollback() + + assert list(resultats) == [] + + +async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = PredictionRepository(session) + voulue = await creer_prediction(session, site_id=premier.site_id) + await creer_prediction(session, site_id=second.site_id) + + resultats = await depot.list_since( + since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id + ) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [voulue.prediction_id] + + async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None: site = await creer_site(session) depot = PredictionRepository(session) diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 4f12df0..aaff856 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -155,6 +155,56 @@ async def test_latest_for_site_ignores_the_readings_of_the_other_sites( assert trouvee is None +async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + plus_recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC) + ) + plus_ancienne = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id] + + +async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + dedans = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC) + ) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)) + + resultats = await depot.list_since( + since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [dedans.reading_id] + + +async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = ReadingRepository(session) + voulue = await creer_lecture(session, site_id=premier.site_id) + await creer_lecture(session, site_id=second.site_id) + + resultats = await depot.list_since( + since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [voulue.reading_id] + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py index 4a88802..c99fd6f 100644 --- a/apps/backend/tests/services/test_alert.py +++ b/apps/backend/tests/services/test_alert.py @@ -1,7 +1,10 @@ -from datetime import UTC, datetime +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from app.models.energy import Alert -from app.services.alert import AlertService +from app.services.alert import OUTAGE_THRESHOLD, AlertService, _severity_from_ratio + +NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def alert( @@ -26,10 +29,36 @@ def alert( ) +@dataclass +class FauxSite: + site_id: str + capacity_kw: float | None = None + + +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime + consumption_kw: float | None = None + consumption_kwh: float | None = None + data_quality: str | None = None + null_reasons: list[str] | None = None + + +@dataclass +class FauxPrediction: + site_id: str + target_at: datetime + predicted_value: float | None + target_metric: str = "consumption_kwh" + prediction_id: int = 1 + + class FakeRepository: def __init__(self, alerts: list[Alert]) -> None: self._alerts = alerts self.appels: list[tuple[str | None, str | None]] = [] + self.crees: list[Alert] = [] async def list_all( self, *, site_id: str | None = None, severity: str | None = None @@ -37,19 +66,325 @@ class FakeRepository: self.appels.append((site_id, severity)) return self._alerts + async def create_many(self, alerts: list[Alert]) -> list[Alert]: + self.crees = list(alerts) + return self.crees + + +@dataclass +class FauxDepotLectures: + depuis: list[FauxLecture] = field(default_factory=list) + dernieres: list[FauxLecture] = field(default_factory=list) + + async def list_since(self, *, since: datetime, site_id: str | None = None) -> list[FauxLecture]: + return [lecture for lecture in self.depuis if site_id is None or lecture.site_id == site_id] + + async def latest_by_site(self) -> list[FauxLecture]: + return self.dernieres + + +@dataclass +class FauxDepotPredictions: + predictions: list[FauxPrediction] = field(default_factory=list) + + async def list_since( + self, *, since: datetime, site_id: str | None = None + ) -> list[FauxPrediction]: + return [p for p in self.predictions if site_id is None or p.site_id == site_id] + + +@dataclass +class FauxDepotSites: + sites: list[FauxSite] + + async def list_all(self) -> list[FauxSite]: + return self.sites + + +def service( + *, + sites: list[FauxSite], + lectures: list[FauxLecture] | None = None, + dernieres: list[FauxLecture] | None = None, + predictions: list[FauxPrediction] | None = None, + alerts: FakeRepository | None = None, +) -> tuple[AlertService, FakeRepository]: + depot_alertes = alerts or FakeRepository([]) + dernieres_lectures = dernieres if dernieres is not None else (lectures or []) + return ( + AlertService( + alerts=depot_alertes, # type: ignore[arg-type] + readings=FauxDepotLectures(depuis=lectures or [], dernieres=dernieres_lectures), # type: ignore[arg-type] + predictions=FauxDepotPredictions(predictions or []), # type: ignore[arg-type] + sites=FauxDepotSites(sites), # type: ignore[arg-type] + ), + depot_alertes, + ) + async def test_list_all_returns_the_repository_alerts() -> None: - service = AlertService(alerts=FakeRepository([alert(1), alert(2)])) + svc, _ = service(sites=[], alerts=FakeRepository([alert(1), alert(2)])) - alertes = await service.list_all() + alertes = await svc.list_all() assert [a.alert_id for a in alertes] == [1, 2] async def test_list_all_relays_the_filters_to_the_repository() -> None: depot = FakeRepository([]) - service = AlertService(alerts=depot) + svc, _ = service(sites=[], alerts=depot) - await service.list_all(site_id="site-1", severity="critical") + await svc.list_all(site_id="site-1", severity="critical") assert depot.appels == [("site-1", "critical")] + + +async def test_detect_raises_a_threshold_alert_above_site_capacity() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=150.0)], + ) + + await svc.detect(now=NOW) + + (candidate,) = depot.crees + assert candidate.type == "threshold" + assert candidate.severity == "high" + assert candidate.value == 150.0 + assert candidate.threshold == 100.0 + assert candidate.metric == "consumption_kw" + + +async def test_detect_ignores_a_reading_within_capacity() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=80.0)], + ) + + await svc.detect(now=NOW) + + assert depot.crees == [] + + +async def test_detect_ignores_threshold_when_the_site_has_no_declared_capacity() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=None)], + lectures=[FauxLecture("A", NOW, consumption_kw=9999.0)], + ) + + await svc.detect(now=NOW) + + assert depot.crees == [] + + +async def test_detect_raises_a_spike_alert_on_a_brutal_consecutive_variation() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "spike"] + assert candidate.value == 160.0 + assert candidate.threshold == 100.0 + assert candidate.timestamp == NOW + + +async def test_detect_ignores_a_moderate_consecutive_variation() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=110.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_never_compares_consecutive_readings_across_two_sites() -> None: + svc, depot = service( + sites=[FauxSite("A"), FauxSite("B")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=10.0), + FauxLecture("B", NOW, consumption_kw=1000.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_raises_an_anomaly_alert_far_from_the_matching_prediction() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=70.0)], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "anomaly"] + assert candidate.value == 100.0 + assert candidate.threshold == 70.0 + assert candidate.metric == "consumption_kwh" + assert candidate.prediction_id == 1 + + +async def test_detect_ignores_a_reading_close_to_its_prediction() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=95.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_reading() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[FauxPrediction("A", target_at=NOW - timedelta(hours=1), predicted_value=1.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +async def test_detect_raises_an_outage_alert_past_the_threshold() -> None: + derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1) + svc, depot = service( + sites=[FauxSite("A")], + lectures=[], + dernieres=[FauxLecture("A", derniere)], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "outage"] + assert candidate.severity in {"low", "medium", "high", "critical"} + + +async def test_detect_ignores_a_site_still_within_the_outage_threshold() -> None: + derniere = NOW - OUTAGE_THRESHOLD + timedelta(minutes=1) + svc, depot = service( + sites=[FauxSite("A")], + lectures=[], + dernieres=[FauxLecture("A", derniere)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "outage"] == [] + + +async def test_detect_raises_a_critical_outage_alert_for_a_site_never_read() -> None: + svc, depot = service(sites=[FauxSite("A")], lectures=[], dernieres=[]) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "outage"] + assert candidate.severity == "critical" + assert candidate.source_alert_id == "outage:jamais" + + +async def test_detect_raises_a_sensor_alert_on_a_degraded_reading() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, data_quality="critical", null_reasons=["missing:x"])], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "sensor"] + assert candidate.severity == "critical" + + +async def test_detect_ignores_a_good_quality_reading_for_the_sensor_rule() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, data_quality="good")], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "sensor"] == [] + + +async def test_detect_scopes_to_a_single_site_when_asked() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0), FauxSite("B", capacity_kw=100.0)], + lectures=[ + FauxLecture("A", NOW, consumption_kw=150.0), + FauxLecture("B", NOW, consumption_kw=150.0), + ], + ) + + await svc.detect(now=NOW, site_id="A") + + assert {a.site_id for a in depot.crees} == {"A"} + + +async def test_detect_returns_early_when_there_is_no_site() -> None: + svc, depot = service(sites=[]) + + resultat = await svc.detect(now=NOW) + + assert resultat == [] + assert depot.crees == [] + + +async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0), + FauxLecture("A", NOW, consumption_kw=50.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_ignores_an_anomaly_when_the_prediction_is_near_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=5.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=0.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +def test_severity_from_ratio_covers_every_band() -> None: + assert _severity_from_ratio(1.0) == "low" + assert _severity_from_ratio(1.2) == "medium" + assert _severity_from_ratio(1.5) == "high" + assert _severity_from_ratio(2.0) == "critical" + + +async def test_detect_does_not_call_create_many_when_nothing_triggers() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=10.0, data_quality="good")], + ) + + resultat = await svc.detect(now=NOW) + + assert resultat == [] + assert depot.crees == [] diff --git a/apps/backend/tests/test_internal_alerts.py b/apps/backend/tests/test_internal_alerts.py new file mode 100644 index 0000000..4740150 --- /dev/null +++ b/apps/backend/tests/test_internal_alerts.py @@ -0,0 +1,65 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.detection import internal_alerts +from app.repositories.alert import AlertRepository +from tests.repositories.test_reading import creer_lecture +from tests.repositories.test_site import creer as creer_site + + +def test_parse_args_defaults_to_no_site_and_no_instant() -> None: + arguments = internal_alerts.parse_args([]) + + assert arguments.site_id is None + assert arguments.now is None + + +def test_parse_args_reads_the_site_id() -> None: + arguments = internal_alerts.parse_args(["--site-id", "site-1"]) + + assert arguments.site_id == "site-1" + + +def test_parse_args_parses_the_instant_option() -> None: + arguments = internal_alerts.parse_args(["--now", "2026-09-16T12:00:00+00:00"]) + + assert arguments.now == datetime(2026, 9, 16, 12, tzinfo=UTC) + + +def test_parse_instant_treats_a_naive_datetime_as_utc() -> None: + assert internal_alerts._parse_instant("2026-09-16T12:00:00") == datetime( + 2026, 9, 16, 12, tzinfo=UTC + ) + + +def test_main_prints_how_many_alerts_were_recorded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + async def fausse_execution(*, now: datetime | None, site_id: str | None) -> int: + return 3 + + monkeypatch.setattr(internal_alerts, "run_detection", fausse_execution) + + code = internal_alerts.main([]) + + assert code == 0 + assert "3 nouvelle" in capsys.readouterr().out + + +@pytest.mark.integration +async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None: + site = await creer_site(session, capacity_kw=100.0) + instant = datetime(2026, 9, 16, 12, tzinfo=UTC) + await creer_lecture(session, site_id=site.site_id, timestamp=instant, consumption_kw=150.0) + await session.commit() + + nombre = await internal_alerts.run_detection(now=instant, site_id=site.site_id) + + alertes = await AlertRepository(session).list_all(site_id=site.site_id) + types = [a.type for a in alertes] + await session.rollback() + + assert nombre == 1 + assert types == ["threshold"] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 09d3b3d..8c3641d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -207,6 +207,35 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. +### Détection d'alertes internes + +`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour +le type `anomaly`, les `prediction`) des dernières 48h (`LOOKBACK`) à cinq règles et enregistre une +ligne `alert` par déclenchement, avec `source="enervision"`. `metric`/`value`/`threshold` gardent +leur sens dans chaque règle plutôt que d'être laissés à `null` par commodité : + +| `type` | Règle | `value` / `threshold` | +|---|---|---| +| `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site | +| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site | mesure actuelle / mesure précédente | +| `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite | +| `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` | +| `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` | + +La sévérité de chaque alerte (hors `sensor`, dérivée directement de `data_quality`) suit le même +barème par ratio observé/seuil : `low` sous 1.2, `medium` sous 1.5, `high` sous 2.0, `critical` +au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO NOTHING` sur +`uq_alert_source_reference`, et `source_alert_id` est construit de façon déterministe (règle + +horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une +alerte. + +Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par +Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans +`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une +connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104) +débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait +jusqu'ici rien à référencer côté `source="enervision"`. + ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un From f9c2a4610c25a91c798aca8ec1ba8f2bebaf60fb Mon Sep 17 00:00:00 2001 From: ValentinDeFaria <123947752+ValentinDeFaria@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:56:59 +0200 Subject: [PATCH 178/205] Update dashboard.ts --- apps/frontend/src/app/features/dashboard/dashboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index bfc5785..2ba20c0 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -61,7 +61,6 @@ export class Dashboard implements OnInit { private alertsService = inject(AlertsService); public auth = inject(AuthService); private predictionsService = inject(PredictionsService); - private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); From c059f838bbbc45c440bde45e498a94c1f42a365f Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 16:58:03 +0200 Subject: [PATCH 179/205] fix(backend): fiabilise le tri des lectures/predictions et la detection de redemarrage a zero --- apps/backend/app/repositories/prediction.py | 8 ++- apps/backend/app/repositories/reading.py | 5 +- apps/backend/app/services/alert.py | 60 +++++++++++----- .../tests/repositories/test_prediction.py | 24 +++++++ .../tests/repositories/test_reading.py | 23 +++++++ apps/backend/tests/services/test_alert.py | 69 ++++++++++++++++++- apps/backend/tests/test_internal_alerts.py | 36 ++++++++-- docs/architecture/20-backend.md | 13 +++- 8 files changed, 208 insertions(+), 30 deletions(-) diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py index dd3dc28..5939899 100644 --- a/apps/backend/app/repositories/prediction.py +++ b/apps/backend/app/repositories/prediction.py @@ -16,10 +16,16 @@ class PredictionRepository: ) -> Sequence[Prediction]: # Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de # `predicted_value` à comparer à une lecture réelle (détection d'anomalie). + # Piège : `prediction` n'a pas d'unicité sur `(site_id, target_at)` (cf. + # `enervision_ml.score`, qui insère toujours une nouvelle ligne plutôt que d'écraser la + # précédente). `prediction_id` en dernier départage donc les égalités de `target_at` par + # ordre croissant : `_detect_anomaly` construit un dict qui garde le dernier rencontré, + # c'est-à-dire le run le plus récent plutôt qu'une ligne choisie au hasard par le plan + # d'exécution. requete = ( select(Prediction) .where(Prediction.target_at >= since, Prediction.status == "available") - .order_by(Prediction.site_id, Prediction.target_at) + .order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id) ) if site_id is not None: requete = requete.where(Prediction.site_id == site_id) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 3e9cec5..82a8565 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -37,10 +37,13 @@ class ReadingRepository: async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]: # Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de # comparer chaque lecture à celle qui la précède immédiatement pour le même site. + # `reading_id` en dernier départage : `uq_reading_source` autorise deux lignes au même + # `site_id`+`timestamp` quand la `source` diffère (même piège que `latest_for_site`), sans + # quoi l'ordre entre elles ne serait pas garanti d'un appel à l'autre. requete = ( select(Reading) .where(Reading.timestamp >= since) - .order_by(Reading.site_id, Reading.timestamp) + .order_by(Reading.site_id, Reading.timestamp, Reading.reading_id) ) if site_id is not None: requete = requete.where(Reading.site_id == site_id) diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py index acae8cf..44a1db7 100644 --- a/apps/backend/app/services/alert.py +++ b/apps/backend/app/services/alert.py @@ -142,43 +142,65 @@ def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site] def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]: - # `lectures` est triée par site puis par heure (cf. `ReadingRepository.list_since`) : deux - # lignes consécutives du même site sont donc deux mesures consécutives dans le temps. + # `lectures` est triée par site, heure puis `reading_id` (cf. `ReadingRepository.list_since`) : + # deux lignes consécutives du même site sont donc deux mesures consécutives dans le temps, + # sauf lorsqu'elles partagent le même horodatage (deux `source` différentes pour le même + # instant, permises par `uq_reading_source`) : ce n'est alors pas une variation réelle, on + # l'ignore plutôt que de générer une fausse alerte figée par son `source_alert_id`. alertes = [] precedente: Reading | None = None for lecture in lectures: - if precedente is None or precedente.site_id != lecture.site_id: + if ( + precedente is None + or precedente.site_id != lecture.site_id + or precedente.timestamp == lecture.timestamp + ): precedente = lecture continue avant, apres = precedente.consumption_kw, lecture.consumption_kw precedente = lecture - if avant is None or apres is None or avant == 0: + if avant is None or apres is None: + continue + if avant == 0: + # Une variation relative n'a pas de sens depuis zéro, mais un redémarrage direct à + # une consommation positive reste le signal le plus alarmant du lot : `critical` + # plutôt qu'un ratio indéfini. + if apres > 0: + alertes.append(_spike_alert(lecture, avant, apres, severity="critical")) continue variation = abs(apres - avant) / abs(avant) if variation < SPIKE_RELATIVE_THRESHOLD: continue alertes.append( - Alert( - source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", - site_id=lecture.site_id, - source="enervision", - timestamp=lecture.timestamp, - type="spike", + _spike_alert( + lecture, + avant, + apres, severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD), - message=( - f"Variation brutale de {variation * 100:.0f}% entre deux lectures " - f"consécutives ({avant:.1f} kW -> {apres:.1f} kW)" - ), - value=apres, - threshold=avant, - metric=THRESHOLD_METRIC, - prediction_id=None, - raw_data={}, ) ) return alertes +def _spike_alert(lecture: Reading, avant: float, apres: float, *, severity: str) -> Alert: + return Alert( + source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="spike", + severity=severity, + message=( + f"Variation brutale entre deux lectures consécutives ({avant:.1f} kW -> {apres:.1f} kW)" + ), + value=apres, + threshold=avant, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + + def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]: # Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une # cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`. diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py index 708a469..da71aea 100644 --- a/apps/backend/tests/repositories/test_prediction.py +++ b/apps/backend/tests/repositories/test_prediction.py @@ -68,6 +68,30 @@ async def test_list_since_excludes_predictions_that_are_not_available( assert list(resultats) == [] +async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id( + session: AsyncSession, +) -> None: + # `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans + # nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce + # départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus + # récent. + site = await creer_site(session) + depot = PredictionRepository(session) + cible = datetime(2026, 9, 16, tzinfo=UTC) + premier_run = await creer_prediction( + session, site_id=site.site_id, target_at=cible, predicted_value=10.0 + ) + second_run = await creer_prediction( + session, site_id=site.site_id, target_at=cible, predicted_value=20.0 + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [premier_run.prediction_id, second_run.prediction_id] + + async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: premier = await creer_site(session) second = await creer_site(session) diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index aaff856..ac3f854 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -189,6 +189,29 @@ async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSess assert identifiants == [dedans.reading_id] +async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id( + session: AsyncSession, +) -> None: + # `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source` + # diffère (même piège que `latest_for_site`). Sans ce départage, `_detect_spike` traiterait + # cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution. + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 16, tzinfo=UTC) + premiere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10 + ) + seconde = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42 + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [premiere.reading_id, seconde.reading_id] + + async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: premier = await creer_site(session) second = await creer_site(session) diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py index c99fd6f..97b2b0a 100644 --- a/apps/backend/tests/services/test_alert.py +++ b/apps/backend/tests/services/test_alert.py @@ -262,6 +262,28 @@ async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_re assert [a for a in depot.crees if a.type == "anomaly"] == [] +async def test_detect_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> ( + None +): + # `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id` + # croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la + # liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir. + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[ + FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1), + FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2), + ], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "anomaly"] + assert candidate.threshold == 70.0 + assert candidate.prediction_id == 2 + + async def test_detect_raises_an_outage_alert_past_the_threshold() -> None: derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1) svc, depot = service( @@ -345,7 +367,35 @@ async def test_detect_returns_early_when_there_is_no_site() -> None: assert depot.crees == [] -async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> None: +async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0), + FauxLecture("A", NOW, consumption_kw=0.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None: svc, depot = service( sites=[FauxSite("A")], lectures=[ @@ -356,6 +406,23 @@ async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> Non await svc.detect(now=NOW) + (candidate,) = [a for a in depot.crees if a.type == "spike"] + assert candidate.severity == "critical" + assert candidate.value == 50.0 + assert candidate.threshold == 0.0 + + +async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW, consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + assert [a for a in depot.crees if a.type == "spike"] == [] diff --git a/apps/backend/tests/test_internal_alerts.py b/apps/backend/tests/test_internal_alerts.py index 4740150..690ac00 100644 --- a/apps/backend/tests/test_internal_alerts.py +++ b/apps/backend/tests/test_internal_alerts.py @@ -1,8 +1,10 @@ from datetime import UTC, datetime import pytest +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import get_session_factory from app.detection import internal_alerts from app.repositories.alert import AlertRepository from tests.repositories.test_reading import creer_lecture @@ -50,16 +52,36 @@ def test_main_prints_how_many_alerts_were_recorded( @pytest.mark.integration async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None: + # `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait + # rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur + # le modèle de `tests/api/test_matrice_acces.py`. site = await creer_site(session, capacity_kw=100.0) + site_id = site.site_id instant = datetime(2026, 9, 16, 12, tzinfo=UTC) - await creer_lecture(session, site_id=site.site_id, timestamp=instant, consumption_kw=150.0) + await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0) await session.commit() - nombre = await internal_alerts.run_detection(now=instant, site_id=site.site_id) + try: + nombre = await internal_alerts.run_detection(now=instant, site_id=site_id) - alertes = await AlertRepository(session).list_all(site_id=site.site_id) - types = [a.type for a in alertes] - await session.rollback() + alertes = await AlertRepository(session).list_all(site_id=site_id) + types = [a.type for a in alertes] + await session.rollback() - assert nombre == 1 - assert types == ["threshold"] + assert nombre == 1 + assert types == ["threshold"] + finally: + # `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les + # objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une + # requête hors contexte async. D'où `site_id`, capturé avant. + async with get_session_factory()() as nettoyage: + await nettoyage.execute( + text("delete from alert where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.execute( + text("delete from reading where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.execute( + text("delete from site where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.commit() diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8c3641d..731ff21 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -217,7 +217,7 @@ leur sens dans chaque règle plutôt que d'être laissés à `null` par commodit | `type` | Règle | `value` / `threshold` | |---|---|---| | `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site | -| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site | mesure actuelle / mesure précédente | +| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site, ou redémarrage direct à une valeur positive depuis zéro (`critical`) | mesure actuelle / mesure précédente | | `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite | | `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` | | `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` | @@ -229,6 +229,17 @@ au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO N horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une alerte. +**Pièges de tri corrigés en revue** : `reading`/`prediction` n'ont pas d'unicité sur leur couple +métier (`uq_reading_source` autorise deux `source` différentes au même `site_id`+`timestamp`, +`prediction` n'a aucune contrainte sur `(site_id, target_at)`, chaque run de scoring gardant sa +propre ligne). `ReadingRepository.list_since()`/`PredictionRepository.list_since()` départagent +donc les égalités par `reading_id`/`prediction_id` croissant, comme le font déjà +`latest_by_site()`/`latest_for_site()` sur les mêmes tables ; sans ce départage, l'ordre entre +lignes à égalité n'est pas garanti d'un appel à l'autre, et `_detect_spike`/`_detect_anomaly` +auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spike` ignore en plus +explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul +instant réel, pas une variation). + Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans `apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une From 0ddfb1997d5cb37329a3c76ad55a569d4fabf268 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:43:04 +0200 Subject: [PATCH 180/205] =?UTF-8?q?feat(apps):=20configure=20la=20connexio?= =?UTF-8?q?n=20=C3=A0=20l'API=20Mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ apps/backend/.env.example | 4 ++++ apps/backend/app/core/config.py | 5 +++++ apps/backend/pyproject.toml | 2 +- apps/backend/uv.lock | 4 ++-- docker-compose.yml | 6 ++++++ 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 54dc3d8..250573f 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,9 @@ APP_LOG_LEVEL=INFO APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 + +# API Mock EnerVision +APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr +APP_MOCK_API_USERNAME=change_me +APP_MOCK_API_PASSWORD=change_me +APP_MOCK_API_TIMEOUT_SECONDS=10 diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 8dff67f..da73dbb 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -18,3 +18,7 @@ APP_SMTP_HOST=localhost APP_SMTP_PORT=1025 APP_SMTP_USE_TLS=false APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr +APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr +APP_MOCK_API_USERNAME=change_me +APP_MOCK_API_PASSWORD=change_me +APP_MOCK_API_TIMEOUT_SECONDS=10 diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index e374709..e622ea7 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -34,6 +34,11 @@ class Settings(BaseSettings): database_pool_size: int = 5 database_max_overflow: int = 10 + mock_api_base_url: str = "https://api-mock.charlieandre.fr" + mock_api_username: str | None = None + mock_api_password: SecretStr | None = None + mock_api_timeout_seconds: float = Field(default=10.0, gt=0) + jwt_issuer: str = "enervision-api" jwt_audience: str = "enervision-web" access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 5336ba6..cfe6481 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "argon2-cffi>=23.1", "anyio>=4.0", "aiosmtplib>=5.1.3", + "httpx>=0.28.1", "pandas>=3.0.5", ] @@ -27,7 +28,6 @@ dev = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", - "httpx>=0.28.1", "pandas-stubs>=3.0.5.260914", ] diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 59ff61b..a8b434f 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -326,6 +326,7 @@ dependencies = [ { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "httpx" }, { name = "pandas" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, @@ -338,7 +339,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "mypy" }, { name = "pandas-stubs" }, { name = "pytest" }, @@ -355,6 +355,7 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "pandas", specifier = ">=3.0.5" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, @@ -367,7 +368,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=2.3.1" }, { name = "pandas-stubs", specifier = ">=3.0.5.260914" }, { name = "pytest", specifier = ">=9.1.1" }, diff --git a/docker-compose.yml b/docker-compose.yml index 3f7f9ea..91e315e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,12 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:?} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:?} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:?} + APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} APP_SMTP_HOST: mailpit APP_SMTP_PORT: "1025" From 0318ee6cc5881177d0b0ab65717e4f8b0ff0a8b7 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:43:29 +0200 Subject: [PATCH 181/205] feat(apps): ajoute l'import depuis l'API Mock --- apps/backend/app/etl/mock_api_import.py | 315 ++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 apps/backend/app/etl/mock_api_import.py diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py new file mode 100644 index 0000000..f143f16 --- /dev/null +++ b/apps/backend/app/etl/mock_api_import.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +from datetime import datetime +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings + +SOURCE_HISTORY = "api_history" + + +def create_mock_api_client() -> httpx.AsyncClient: + settings = get_settings() + + if settings.mock_api_username is None or settings.mock_api_password is None: + raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") + + return httpx.AsyncClient( + base_url=settings.mock_api_base_url.rstrip("/"), + auth=( + settings.mock_api_username, + settings.mock_api_password.get_secret_value(), + ), + timeout=settings.mock_api_timeout_seconds, + ) + + +async def fetch_sites( + client: httpx.AsyncClient, +) -> list[dict[str, Any]]: + response = await client.get("/api/v1/sites") + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/sites doit être une liste.") + + return payload + + +async def upsert_sites( + connection: AsyncConnection, + sites: list[dict[str, Any]], +) -> None: + if not sites: + return + + await connection.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name, + location, + capacity_kw, + status + ) + VALUES ( + :site_id, + :site_type, + :site_name, + :location, + :capacity_kw, + :status + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name, + location = EXCLUDED.location, + capacity_kw = EXCLUDED.capacity_kw, + status = EXCLUDED.status + """ + ), + sites, + ) + + +async def fetch_readings( + client: httpx.AsyncClient, + site_id: str, + start_time: datetime, + end_time: datetime, + limit: int = 1000, +) -> list[dict[str, Any]]: + response = await client.get( + "/api/v1/readings", + params={ + "site_id": site_id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "limit": limit, + }, + ) + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/readings doit être une liste.") + + return payload + + +def build_reading_row( + reading: dict[str, Any], +) -> dict[str, Any]: + timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00")) + return { + "site_id": reading["site_id"], + "timestamp": timestamp, + "source": SOURCE_HISTORY, + "dataset_id": None, + "consumption_kw": reading.get("consumption_kw"), + "consumption_kwh": reading.get("consumption_kwh"), + "consumption_euros": None, + "voltage_v": reading.get("voltage_v"), + "current_a": reading.get("current_a"), + "power_factor": reading.get("power_factor"), + "temperature_celsius": reading.get("temperature_celsius"), + "humidity_percent": reading.get("humidity_percent"), + "solar_irradiance_wm2": None, + "is_working_hours": None, + "data_quality": reading.get("data_quality"), + "null_reasons": reading.get("null_reasons"), + "imputed_values": None, + "imputation_method": None, + "raw_data": json.dumps( + reading, + ensure_ascii=False, + ), + } + + +READING_INSERT = text( + """ + INSERT INTO reading ( + site_id, + timestamp, + source, + dataset_id, + consumption_kw, + consumption_kwh, + consumption_euros, + voltage_v, + current_a, + power_factor, + temperature_celsius, + humidity_percent, + solar_irradiance_wm2, + is_working_hours, + data_quality, + null_reasons, + imputed_values, + imputation_method, + raw_data + ) + VALUES ( + :site_id, + :timestamp, + :source, + :dataset_id, + :consumption_kw, + :consumption_kwh, + :consumption_euros, + :voltage_v, + :current_a, + :power_factor, + :temperature_celsius, + :humidity_percent, + :solar_irradiance_wm2, + :is_working_hours, + :data_quality, + :null_reasons, + CAST(:imputed_values AS jsonb), + :imputation_method, + CAST(:raw_data AS jsonb) + ) + ON CONFLICT DO NOTHING + """ +) + + +def build_reading_batch( + readings: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [build_reading_row(reading) for reading in readings] + + +async def import_mock_api_history( + start_time: datetime, + end_time: datetime, + limit: int, + dry_run: bool, +) -> None: + settings = get_settings() + + async with create_mock_api_client() as client: + sites = await fetch_sites(client) + + print(f"Sites récupérés : {len(sites)}") + + all_readings: list[dict[str, Any]] = [] + + for site in sites: + site_id = site["site_id"] + + readings = await fetch_readings( + client=client, + site_id=site_id, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + + print(f"{site_id}: {len(readings)} lectures") + + all_readings.extend(readings) + + print(f"Lectures récupérées : {len(all_readings)}") + + if dry_run: + print("Dry-run terminé : aucune donnée écrite.") + return + + engine = create_async_engine( + str(settings.database_url), + pool_pre_ping=True, + ) + + try: + async with engine.begin() as connection: + await upsert_sites( + connection, + sites, + ) + + rows = build_reading_batch(all_readings) + + if rows: + await connection.execute( + READING_INSERT, + rows, + ) + + finally: + await engine.dispose() + + print("Import API Mock terminé.") + + +def parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision")) + + parser.add_argument( + "--start-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--end-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--limit", + type=int, + default=1000, + ) + + parser.add_argument( + "--dry-run", + action="store_true", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if args.limit < 1 or args.limit > 1000: + raise ValueError("--limit doit être compris entre 1 et 1000.") + + if args.start_time >= args.end_time: + raise ValueError("--start-time doit être antérieur à --end-time.") + + asyncio.run( + import_mock_api_history( + start_time=args.start_time, + end_time=args.end_time, + limit=args.limit, + dry_run=args.dry_run, + ) + ) + + +if __name__ == "__main__": + main() From e66ef867297c7faae2e5d445be6a883c0f804232 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:43:49 +0200 Subject: [PATCH 182/205] test(apps): couvre l'import depuis l'API Mock --- .../backend/tests/etl/test_mock_api_import.py | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 apps/backend/tests/etl/test_mock_api_import.py diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py new file mode 100644 index 0000000..c51a153 --- /dev/null +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -0,0 +1,304 @@ +import json +from datetime import datetime +from typing import Any + +import httpx +import pytest +from httpx import AsyncClient, MockTransport, Request, Response +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.etl.mock_api_import import ( + READING_INSERT, + SOURCE_HISTORY, + build_reading_batch, + build_reading_row, + fetch_readings, + fetch_sites, +) + + +def make_site() -> dict[str, Any]: + return { + "site_id": "SITE001", + "site_type": "office", + "site_name": "Bureau Paris La Défense", + "location": "Paris, France", + "capacity_kw": 200, + "status": "active", + } + + +def make_reading() -> dict[str, Any]: + return { + "timestamp": "2024-06-15T12:00:00Z", + "site_id": "SITE001", + "site_type": "office", + "consumption_kw": 87.34, + "consumption_kwh": 87.34, + "voltage_v": 401.2, + "current_a": 132.5, + "power_factor": 0.923, + "temperature_celsius": 22.1, + "humidity_percent": 58.4, + "null_reasons": [], + "data_quality": "good", + } + + +async def test_fetch_sites_returns_sites() -> None: + def handler(request: Request) -> Response: + assert request.url.path == "/api/v1/sites" + return Response( + status_code=200, + json=[make_site()], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + sites = await fetch_sites(client) + + assert len(sites) == 1 + assert sites[0]["site_id"] == "SITE001" + assert sites[0]["site_type"] == "office" + + +async def test_fetch_readings_sends_expected_query_parameters() -> None: + captured_params: dict[str, str] = {} + + def handler(request: Request) -> Response: + nonlocal captured_params + + captured_params = dict(request.url.params) + + return Response( + status_code=200, + json=[make_reading()], + ) + + transport = MockTransport(handler) + + start_time = datetime.fromisoformat("2024-06-15T12:00:00") + end_time = datetime.fromisoformat("2024-06-15T13:00:00") + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + readings = await fetch_readings( + client=client, + site_id="SITE001", + start_time=start_time, + end_time=end_time, + limit=60, + ) + + assert len(readings) == 1 + assert captured_params["site_id"] == "SITE001" + assert captured_params["start_time"] == "2024-06-15T12:00:00" + assert captured_params["end_time"] == "2024-06-15T13:00:00" + assert captured_params["limit"] == "60" + + +async def test_fetch_readings_rejects_non_list_response() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json={"unexpected": "payload"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="La réponse /api/v1/readings doit être une liste", + ): + await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +async def test_fetch_readings_raises_on_http_error() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=404, + json={"detail": "Site non trouvé"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises(httpx.HTTPStatusError): + await fetch_readings( + client=client, + site_id="SITE999", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +def test_build_reading_row_respects_database_contract() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + assert row["site_id"] == "SITE001" + assert row["source"] == SOURCE_HISTORY + assert row["source"] == "api_history" + assert row["dataset_id"] is None + + assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + + assert row["consumption_kw"] == 87.34 + assert row["consumption_kwh"] == 87.34 + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_null_values_and_quality() -> None: + reading = make_reading() + + reading["consumption_kw"] = None + reading["consumption_kwh"] = None + reading["voltage_v"] = None + reading["current_a"] = None + reading["power_factor"] = None + reading["data_quality"] = "degraded" + reading["null_reasons"] = [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + row = build_reading_row(reading) + + assert row["consumption_kw"] is None + assert row["consumption_kwh"] is None + assert row["voltage_v"] is None + assert row["current_a"] is None + assert row["power_factor"] is None + + assert row["data_quality"] == "degraded" + assert row["null_reasons"] == [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_raw_source_data() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + raw_data = json.loads(row["raw_data"]) + + assert raw_data == reading + + +def test_build_reading_batch_transforms_all_readings() -> None: + first = make_reading() + + second = make_reading() + second["timestamp"] = "2024-06-15T12:01:00Z" + second["consumption_kw"] = 90.5 + + rows = build_reading_batch([first, second]) + + assert len(rows) == 2 + + assert rows[0]["site_id"] == "SITE001" + assert rows[0]["consumption_kw"] == 87.34 + + assert rows[1]["site_id"] == "SITE001" + assert rows[1]["consumption_kw"] == 90.5 + + +@pytest.mark.integration +async def test_reading_insert_is_idempotent( + session: AsyncSession, +) -> None: + reading = make_reading() + row = build_reading_row(reading) + + await session.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name, + location, + capacity_kw, + status + ) + VALUES ( + :site_id, + :site_type, + :site_name, + :location, + :capacity_kw, + :status + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name, + location = EXCLUDED.location, + capacity_kw = EXCLUDED.capacity_kw, + status = EXCLUDED.status + """ + ), + make_site(), + ) + + await session.execute( + READING_INSERT, + [row], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + result = await session.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE site_id = :site_id + AND timestamp = :timestamp + AND source = :source + """ + ), + { + "site_id": row["site_id"], + "timestamp": row["timestamp"], + "source": row["source"], + }, + ) + + assert result.scalar_one() == 1 + + await session.rollback() From 96dd1f834c66041b4c8d849cc99b4b5b4f6c1873 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:44:25 +0200 Subject: [PATCH 183/205] docs: documente l'ingestion depuis l'API Mock --- docs/architecture/40-data.md | 401 +++++++++++++++++++++++++++++------ etl/README.md | 329 ++++++++++++++++++++++++++-- 2 files changed, 639 insertions(+), 91 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index ffd5e6d..65a4d62 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -6,10 +6,14 @@ système qui en découle. ## Ce que couvre ce document -**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données -d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles -marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne -d'ingestion, les agrégats continus, la compression et la rétention. +**Douze tables applicatives existent** : six pour l'authentification et six pour les données +d'énergie, dont l'hypertable `reading`. + +Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible` +décrivent les éléments prévus mais pas encore réalisés. + +L'ingestion des deux sources de données du MVP est maintenant implémentée. L'orchestration +Airflow, les agrégats continus, la compression et la rétention restent des cibles. ## Trois emplacements, trois rôles @@ -35,8 +39,16 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle - établit `alembic_version` et refuse de s'appliquer si l'extension manque : +- Six révisions Alembic sont actuellement appliquées. +- La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version` + et refuse de s'appliquer si l'extension TimescaleDB manque. +- Les révisions suivantes créent les tables liées à l'authentification : + `app_user`, `login_attempt`, `audit_log` et `refresh_token`. +- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`. +- La révision `c0adab96238c` ajoute les tables `password_reset_attempt` + et `password_reset_token`. + +La garde de la première migration est : ```sql IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN @@ -47,37 +59,58 @@ END IF; Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. -Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, -puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée -les six tables de données décrites en fin de document et déclare l'hypertable `reading`. - ## Cycle de vie d'une mesure -Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats -continus, ni la compression, ni la rétention ne sont écrits. +Statut : `Partiellement fait`. + +Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources de données du MVP : + +- le dataset historique CSV/JSON avec `historical_import.py` ; +- l'API Mock avec `mock_api_import.py`. + +Les traitements sont actuellement exécutables directement depuis le backend. + +L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus, +la compression et les politiques de rétention. ```mermaid flowchart LR - src["Source de mesures"] -.-> ing["Ingestion Airflow"] - ing -.-> hy[("Hypertable reading")] + csv["CSV + JSON"] --> hist["historical_import.py"] + mock["API Mock"] --> api["mock_api_import.py"] + + hist --> hy[("Hypertable reading")] + api --> hy + + airflow["Airflow"] -.-> hist + airflow -.-> api + hy -.-> agg[("Agrégat continu")] hy -.-> comp["Compression"] hy -.-> ret["Rétention"] - agg -.-> api["API FastAPI"] + + agg -.-> backend["API FastAPI"] agg -.-> graf["Grafana"] ``` -Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout -l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. +Les flèches pleines représentent les traitements actuellement implémentés. + +Les flèches pointillées représentent les éléments encore prévus comme cibles. + +Les lectures futures de l'API et de Grafana visent l'agrégat continu plutôt que la table brute +lorsque cette partie TimescaleDB sera mise en place. ## Tables d'authentification -Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les -hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. +Statut : `Fait`. + +Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les hypertables ; +elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. ```mermaid erDiagram APP_USER ||--o{ REFRESH_TOKEN : ouvre + APP_USER ||--o{ PASSWORD_RESET_TOKEN : recoit + APP_USER { uuid id PK string email UK @@ -88,6 +121,7 @@ erDiagram bool must_change_password timestamptz credentials_changed_at } + REFRESH_TOKEN { uuid id PK uuid family_id @@ -99,6 +133,7 @@ erDiagram text revoked_reason uuid replaced_by } + LOGIN_ATTEMPT { bigint id PK timestamptz occurred_at @@ -106,6 +141,7 @@ erDiagram inet client_ip text outcome } + AUDIT_LOG { bigint id PK timestamptz occurred_at @@ -114,31 +150,47 @@ erDiagram text action jsonb detail } + + PASSWORD_RESET_ATTEMPT { + bigint id PK + timestamptz occurred_at + string email_tried + inet client_ip + } + + PASSWORD_RESET_TOKEN { + uuid id PK + uuid user_id FK + bytea token_hash UK + timestamptz issued_at + timestamptz expires_at + timestamptz consumed_at + inet client_ip + text user_agent + } ``` -Quatre choix de modélisation portent une intention et se défendent seuls : +Plusieurs choix de modélisation portent une intention précise : - **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de - `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition - au rôle PostgreSQL qui portera le cantonnement de l'ETL. -- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le - changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui - lit un audit. -- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il - glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. -- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont - dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur - d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). + `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif. +- **`credentials_changed_at`, une seule colonne**, couvre notamment le changement de mot de passe, + le changement de rôle et la désactivation. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. +- **`audit_log.actor_id` n'a aucune clé étrangère** afin de conserver les informations d'audit + même si l'entité d'origine évolue. +- `password_reset_token` ne stocke que l'empreinte du jeton et jamais sa valeur directement. +- `password_reset_attempt` est séparée de `audit_log`, car son volume peut être piloté + par des demandes externes répétées. -`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est -donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils -refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est -piloté par l'attaquant. +`audit_log` porte des déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. +Elle n'est donc **pas** une hypertable. ## Gabarit de révision créant une hypertable -Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision -`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel. +Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. + +La révision `e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel. ```python def upgrade() -> None: @@ -182,24 +234,28 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà. ## Questions ouvertes -Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit -porte sur son exploitation, plus sur sa forme. +Elles portent maintenant principalement sur l'exploitation du schéma : -- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure. -- **Quels agrégats continus**, et sur quelles fenêtres. -- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. -- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. +- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure. +- **Quels agrégats continus** créer et sur quelles fenêtres. +- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser. +- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures. ## Modélisation détaillée des données -Cette modélisation prend en compte les fichiers CSV historiques, -leurs métadonnées JSON et les données de l’API Mock. -Elle comprend six tables, depuis le stockage des mesures -jusqu’aux recommandations proposées à l’utilisateur. +Cette modélisation prend en compte : + +- les fichiers CSV historiques ; +- leurs métadonnées JSON ; +- les données de l'API Mock. + +Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées +à l'utilisateur. ### Schéma de données Le diagramme ci-dessous présente les tables et leurs relations. + La révision `e6d2026091501` les crée. ![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) @@ -208,21 +264,20 @@ La révision `e6d2026091501` les crée. ### Description des tables -Chaque table remplit un rôle précis dans le traitement et l’exploitation -des données. +Chaque table remplit un rôle précis dans le traitement et l'exploitation des données. | Table | Rôle | Origine des informations | |---|---|---| -| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l'import | | `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | | `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | -| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision | | `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | -| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | +| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision | -Les anomalies historiques décrites dans les JSON sont conservées -dans `dataset.metadata`. Elles servent à l’analyse des données -et ne sont pas considérées comme des alertes actuelles. +Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. + +Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles. ### Relations entre les tables @@ -232,15 +287,21 @@ et ne sont pas considérées comme des alertes actuelles. - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. -## Ingestion des données historiques +# Ingestion des données historiques -Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet. +Statut : `Fait`. -Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024. +Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre +du projet. -Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. +Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période +du 1er janvier 2023 au 31 décembre 2024. -### Architecture du flux +Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. + +Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. + +## Architecture du flux historique ```text Dataset CSV + métadonnées JSON @@ -271,17 +332,26 @@ Dataset CSV + métadonnées JSON Le pipeline est développé en Python. -Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB. +Pandas est utilisé pour l'extraction, la validation et la préparation des données. + +SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB. Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité. -Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape. +Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. + +Aucune imputation n'est réalisée à cette étape. Le chargement des mesures est effectué par batches de 1 000 lignes. -Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`. +Les données provenant du dataset CSV sont identifiées par : -### Résultats validés +```text +source = "csv" +dataset_id = identifiant du dataset +``` + +## Résultats validés pour l'historique Le chargement de référence a permis d'obtenir : @@ -290,14 +360,207 @@ Le chargement de référence a permis d'obtenir : - 122 647 mesures ; - 0 doublon détecté dans le dataset source. -L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647. +L'idempotence a également été vérifiée par une deuxième exécution du pipeline : +aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647. -La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`. +La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline +est disponible dans `etl/README.md`. -### Évolution prévue +# Ingestion depuis l'API Mock -L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow. +Statut : `Fait`. -L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs. +La deuxième source du pipeline Data est l'API Mock EnerVision. -Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning. +Le traitement est implémenté dans : + +```text +apps/backend/app/etl/mock_api_import.py +``` + +## Endpoints utilisés + +Le pipeline récupère les informations des sites depuis : + +```text +GET /api/v1/sites +``` + +puis les mesures historiques simulées depuis : + +```text +GET /api/v1/readings +``` + +Pour `/api/v1/readings`, les informations suivantes sont envoyées : + +```text +site_id +start_time +end_time +limit +``` + +Les paramètres de ligne de commande disponibles pour l'import sont : + +```text +--start-time +--end-time +--limit +--dry-run +``` + +## Flux d'ingestion API Mock + +```text + API Mock + | + +-----+------+ + | | + v v + /sites /readings + | | + +-----+------+ + | + v + mock_api_import.py + | + v + Transformation + + qualité data + | + v +PostgreSQL / TimescaleDB + | | + v v + site reading +``` + +Les informations des sites sont insérées ou mises à jour dans `site`. + +Les mesures sont enregistrées dans l'hypertable `reading` avec : + +```text +source = "api_history" +dataset_id = NULL +``` + +Les données provenant de l'API Mock ne sont donc pas associées à un enregistrement de la table +`dataset`. + +La réponse source reçue depuis l'API est conservée dans : + +```text +raw_data +``` + +## Qualité des données de l'API Mock + +Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion. + +Les informations suivantes fournies par l'API sont conservées : + +```text +data_quality +null_reasons +``` + +Cette conservation permet de distinguer une valeur manquante d'une valeur réelle égale à zéro +et de garder les informations liées aux éventuelles défaillances de capteurs. + +Aucune imputation n'est réalisée pendant cette phase : + +```text +imputed_values = NULL +imputation_method = NULL +``` + +## Validation de l'import API Mock + +Un scénario de validation a été exécuté pour les 7 sites sur la période : + +```text +15/06/2024 12:00 UTC +à +15/06/2024 13:00 UTC +``` + +avec : + +```text +limit = 60 +``` + +Résultat : + +```text +7 sites +60 lectures par site +420 lectures récupérées +``` + +Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base. + +Les contrôles ont confirmé : + +- `source = "api_history"` ; +- `dataset_id = NULL` ; +- la conservation des valeurs `NULL` ; +- la conservation de `data_quality` ; +- la conservation de `null_reasons` ; +- la conservation de `raw_data`. + +L'idempotence a été vérifiée en rejouant le même import. + +Une mesure déjà présente n'est pas ajoutée une seconde fois. + +Les tests automatisés couvrent également : + +- la récupération des sites ; +- les paramètres envoyés à `/api/v1/readings` ; +- les réponses HTTP en erreur ; +- le format de la réponse ; +- la transformation des mesures ; +- les valeurs manquantes ; +- la qualité des données ; +- la conservation des données sources ; +- l'idempotence en base. + +# Évolution prévue + +La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow. + +```text +CSV / JSON ----------------+ + | + v + +------------------+ + | Airflow | + +------------------+ + | + +----------------+----------------+ + | | + v v +historical_import.py mock_api_import.py + | | + +----------------+----------------+ + | + v + PostgreSQL / TimescaleDB +``` + +Airflow servira à : + +- planifier les traitements ; +- définir leur ordre d'exécution ; +- suivre leur état ; +- gérer et remonter les erreurs ; +- faciliter les exécutions récurrentes. + +Airflow ne remplacera pas la logique ETL déjà implémentée. + +Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation +et du chargement des données. + +Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle +de Machine Learning. \ No newline at end of file diff --git a/etl/README.md b/etl/README.md index b835311..15c376b 100644 --- a/etl/README.md +++ b/etl/README.md @@ -2,9 +2,10 @@ ## Objectif -Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB. +Le pipeline ETL EnerVision permet d'intégrer les données énergétiques dans PostgreSQL/TimescaleDB à partir de deux sources : -Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024. +- le dataset historique CSV/JSON fourni dans le cadre du projet ; +- l'API Mock EnerVision. Le pipeline assure : @@ -12,12 +13,15 @@ Le pipeline assure : - la validation de leur structure et de leur cohérence ; - la normalisation des données nécessaires au stockage ; - le suivi de la qualité des données ; -- la traçabilité du dataset importé ; +- la traçabilité des données importées ; - le chargement des données dans PostgreSQL/TimescaleDB ; +- la conservation des valeurs manquantes et des informations de qualité ; - l'idempotence du chargement afin d'éviter la création de doublons. ## Données sources +### Dataset historique + Le dataset est fourni par le formateur dans le cadre du projet EnerVision. Il contient les deux fichiers suivants : @@ -29,7 +33,7 @@ dataset_metadata.json Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement. -Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans : +Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement les fichiers fournis par le formateur et les place dans : ```text data/raw/ @@ -47,14 +51,26 @@ data/ Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git. +### API Mock + +La deuxième source est l'API Mock EnerVision. + +Elle permet de récupérer : + +- les informations des sites avec `GET /api/v1/sites` ; +- les mesures simulées avec `GET /api/v1/readings`. + +L'API Mock est utilisée pour compléter les données historiques avec des mesures simulées récupérées sur une période donnée. + ## Technologies utilisées | Technologie | Utilisation | |---|---| | Python | Développement du pipeline ETL | -| Pandas | Lecture, validation et transformation des données | -| JSON | Lecture des métadonnées du dataset | -| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset | +| Pandas | Lecture, validation et transformation du dataset historique | +| JSON | Lecture des métadonnées et conservation des données sources | +| HTTPX | Appels HTTP asynchrones vers l'API Mock | +| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset historique | | SQLAlchemy Async | Connexion et chargement asynchrone en base | | PostgreSQL | Stockage relationnel | | TimescaleDB | Stockage des séries temporelles énergétiques | @@ -62,11 +78,14 @@ Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/ | Alembic | Gestion des migrations du schéma | | uv | Gestion et exécution de l'environnement Python | | Ruff | Contrôle de la qualité du code | +| mypy | Vérification du typage | | Pytest | Tests automatisés | -## Fonctionnement du pipeline +# Import du dataset historique -Le script principal d'import se trouve dans : +## Fonctionnement du pipeline historique + +Le script d'import se trouve dans : ```text apps/backend/app/etl/historical_import.py @@ -207,7 +226,7 @@ Valeurs manquantes identifiées : | `humidity_percent` | 3 423 | | `solar_irradiance_wm2` | 3 964 | -## Exécution en dry-run +## Exécution historique en dry-run Depuis le dossier : @@ -227,7 +246,7 @@ uv run python -m app.etl.historical_import ` Aucune donnée n'est écrite dans la base pendant cette exécution. -## Chargement réel +## Chargement historique réel Depuis `apps/backend/` : @@ -249,7 +268,7 @@ Chargement : 2000/122647 Chargement : 122647/122647 ``` -## Résultats obtenus +## Résultats obtenus pour le dataset historique Après le chargement initial, les contrôles en base ont confirmé : @@ -266,7 +285,7 @@ Le premier import a créé : nouvelles lectures : 122647 ``` -## Idempotence +## Idempotence du dataset historique Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence. @@ -280,7 +299,7 @@ nouvelles lectures : 0 Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé. -## Vérifications SQL +## Vérifications SQL du dataset historique Depuis la racine du projet, vérifier le nombre d'enregistrements avec : @@ -302,21 +321,213 @@ Vérifier la source des mesures avec : docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;" ``` -Résultat attendu : +Résultat attendu pour le dataset historique : ```text csv | 122647 ``` -## Tests et qualité +# Import depuis l'API Mock -Les tests automatisés du pipeline sont situés dans : +## Fonctionnement + +Le script d'import de l'API Mock se trouve dans : + +```text +apps/backend/app/etl/mock_api_import.py +``` + +Le flux est le suivant : + +```text + API Mock + | + +-----+------+ + | | + v v + /sites /readings + | | + +-----+------+ + | + v + mock_api_import.py + | + v + Transformation + + qualité data + | + v +PostgreSQL / TimescaleDB + | | + v v + site reading +``` + +Le pipeline commence par récupérer les sites avec : + +```text +GET /api/v1/sites +``` + +Il récupère ensuite les mesures de chaque site avec : + +```text +GET /api/v1/readings +``` + +Les paramètres envoyés à `/api/v1/readings` sont : + +```text +site_id +start_time +end_time +limit +``` + +Le paramètre `limit` doit être compris entre 1 et 1000. + +## Configuration de l'API Mock + +La connexion à l'API Mock est configurée avec les variables d'environnement suivantes : + +```text +APP_MOCK_API_BASE_URL +APP_MOCK_API_USERNAME +APP_MOCK_API_PASSWORD +APP_MOCK_API_TIMEOUT_SECONDS +``` + +Les identifiants réels ne sont pas versionnés dans Git. + +Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution. + +## Transformation des mesures API + +Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec : + +```text +source = "api_history" +dataset_id = NULL +``` + +Les mesures provenant de l'API ne sont donc pas rattachées à un dataset historique. + +Le timestamp reçu depuis l'API est converti en `datetime` avec timezone avant le chargement. + +La réponse source est conservée dans : + +```text +raw_data +``` + +afin de préserver la donnée reçue et faciliter la traçabilité. + +## Qualité des données API + +Les valeurs `NULL` fournies par l'API sont conservées telles quelles. + +Une valeur manquante n'est pas transformée en zéro et la mesure n'est pas supprimée. + +Le pipeline conserve également : + +```text +data_quality +null_reasons +``` + +Les niveaux de qualité possibles sont : + +```text +good +partial +degraded +critical +``` + +Aucune imputation n'est réalisée pendant l'ingestion : + +```text +imputed_values = NULL +imputation_method = NULL +``` + +Cette stratégie permet de distinguer une véritable valeur nulle ou manquante d'une consommation égale à zéro et de conserver les informations liées aux défaillances de capteurs. + +## Dry-run de l'API Mock + +Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL. + +Depuis `apps/backend/` : + +```powershell +uv run python -m app.etl.mock_api_import ` + --start-time "2024-06-15T12:00:00" ` + --end-time "2024-06-15T13:00:00" ` + --limit 60 ` + --dry-run +``` + +## Chargement réel depuis l'API Mock + +Depuis `apps/backend/` : + +```powershell +uv run python -m app.etl.mock_api_import ` + --start-time "2024-06-15T12:00:00" ` + --end-time "2024-06-15T13:00:00" ` + --limit 60 +``` + +## Résultat validé pour l'API Mock + +Le scénario de validation utilisé couvre la période : + +```text +15/06/2024 12:00 UTC +à +15/06/2024 13:00 UTC +``` + +avec une limite de 60 lectures par site. + +Résultat obtenu : + +```text +sites récupérés : 7 +lectures par site : 60 +lectures récupérées : 420 +source : api_history +dataset_id : NULL +``` + +Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé : + +- l'enregistrement des mesures dans `reading` ; +- la présence des 7 sites ; +- `source = "api_history"` ; +- `dataset_id = NULL` ; +- la conservation des valeurs `NULL` ; +- la conservation de `data_quality` ; +- la conservation de `null_reasons` ; +- la conservation de la donnée source dans `raw_data`. + +## Idempotence de l'import API Mock + +Le même import a été exécuté plusieurs fois afin de vérifier qu'une mesure déjà présente n'est pas créée une seconde fois. + +L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur la gestion des conflits lors de l'insertion. + +Un test d'intégration automatisé vérifie également ce comportement. + +# Tests et qualité + +Les tests automatisés des pipelines ETL sont situés dans : ```text apps/backend/tests/etl/ ``` -Ils couvrent notamment : +Les tests de l'import historique couvrent notamment : - la validation du dataset ; - les colonnes obligatoires ; @@ -328,22 +539,96 @@ Ils couvrent notamment : - la construction des mesures destinées à la BDD ; - le respect des contraintes du modèle de données. +Les tests de l'import API Mock couvrent notamment : + +- la récupération des sites ; +- l'appel à `/api/v1/readings` ; +- les paramètres `site_id`, `start_time`, `end_time` et `limit` ; +- la gestion des erreurs HTTP ; +- la validation du format de la réponse ; +- la transformation des mesures ; +- la conservation des valeurs `NULL` ; +- la conservation de `data_quality` et `null_reasons` ; +- `source = "api_history"` ; +- `dataset_id = NULL` ; +- la conservation de `raw_data` ; +- l'idempotence du chargement. + Exécuter les tests ETL : ```powershell uv run pytest tests\etl -v ``` +Exécuter les tests unitaires de l'import API Mock : + +```powershell +uv run pytest tests\etl\test_mock_api_import.py -v +``` + +Exécuter le test d'intégration de l'import API Mock : + +```powershell +uv run pytest tests\etl\test_mock_api_import.py -m integration -v +``` + Contrôler la qualité du code : ```powershell uv run ruff check app\etl tests\etl ``` -## Suite du pipeline Data +Contrôler le typage : -L'import historique constitue la première brique du pipeline Data EnerVision. +```powershell +uv run mypy app +``` -La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning. +Exécuter la suite complète avec le seuil de couverture : -Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL. \ No newline at end of file +```powershell +uv run pytest --cov-fail-under=85 +``` + +Lors de la validation de l'import API Mock : + +```text +8 tests unitaires passés +1 test d'intégration passé +``` + +La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %. + +# Suite du pipeline Data + +Deux sources de données sont maintenant prises en charge : + +```text +Dataset CSV/JSON + | + v +historical_import.py + | + +-----------------+ + | + v + PostgreSQL / TimescaleDB + ^ + | + +-----------------+ + | +mock_api_import.py + ^ + | + API Mock +``` + +La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP. + +La prochaine étape consiste à orchestrer ces traitements avec Apache Airflow. + +Airflow permettra de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. + +Airflow ne remplacera pas la logique ETL Python existante. Les scripts actuels resteront responsables de l'extraction, de la validation, de la transformation et du chargement. + +Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. \ No newline at end of file From 452cfdef85341005f1614e5cdafbe941e2cfbae1 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Mon, 21 Sep 2026 09:36:10 +0200 Subject: [PATCH 184/205] test(etl): corrige les points bloquants de la revue API Mock --- .../backend/tests/etl/test_mock_api_import.py | 380 ++++++++++++++++-- docker-compose.yml | 8 +- 2 files changed, 354 insertions(+), 34 deletions(-) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index c51a153..2b4c07c 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -1,6 +1,9 @@ import json +import sys from datetime import datetime +from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -8,6 +11,7 @@ from httpx import AsyncClient, MockTransport, Request, Response from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +import app.etl.mock_api_import as mock_api_import from app.etl.mock_api_import import ( READING_INSERT, SOURCE_HISTORY, @@ -15,6 +19,7 @@ from app.etl.mock_api_import import ( build_reading_row, fetch_readings, fetch_sites, + upsert_sites, ) @@ -49,6 +54,7 @@ def make_reading() -> dict[str, Any]: async def test_fetch_sites_returns_sites() -> None: def handler(request: Request) -> Response: assert request.url.path == "/api/v1/sites" + return Response( status_code=200, json=[make_site()], @@ -163,7 +169,9 @@ def test_build_reading_row_respects_database_contract() -> None: assert row["source"] == "api_history" assert row["dataset_id"] is None - assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + assert row["timestamp"] == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00" + ) assert row["consumption_kw"] == 87.34 assert row["consumption_kwh"] == 87.34 @@ -234,6 +242,342 @@ def test_build_reading_batch_transforms_all_readings() -> None: assert rows[1]["consumption_kw"] == 90.5 +def test_create_mock_api_client_requires_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = SimpleNamespace( + mock_api_username=None, + mock_api_password=None, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + with pytest.raises( + ValueError, + match="Les identifiants de l'API Mock ne sont pas configurés", + ): + mock_api_import.create_mock_api_client() + + +async def test_create_mock_api_client_uses_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + password = MagicMock() + password.get_secret_value.return_value = "test-password" + + settings = SimpleNamespace( + mock_api_base_url="https://mock.test/", + mock_api_username="test-user", + mock_api_password=password, + mock_api_timeout_seconds=10.0, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + client = mock_api_import.create_mock_api_client() + + try: + assert str(client.base_url) == "https://mock.test" + assert client.timeout.connect == 10.0 + finally: + await client.aclose() + + +async def test_upsert_sites_with_empty_list_does_nothing() -> None: + connection = AsyncMock() + + await upsert_sites( + connection, + [], + ) + + connection.execute.assert_not_awaited() + + +async def test_import_mock_api_history_dry_run_does_not_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://unused", + ), + ) + + create_engine_mock = MagicMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=True, + ) + + create_engine_mock.assert_not_called() + + +async def test_import_mock_api_history_loads_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://test:test@localhost/test", + ), + ) + + connection = AsyncMock() + + transaction_context = MagicMock() + transaction_context.__aenter__ = AsyncMock( + return_value=connection, + ) + transaction_context.__aexit__ = AsyncMock( + return_value=None, + ) + + engine = MagicMock() + engine.begin.return_value = transaction_context + engine.dispose = AsyncMock() + + create_engine_mock = MagicMock( + return_value=engine, + ) + + upsert_sites_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + monkeypatch.setattr( + mock_api_import, + "upsert_sites", + upsert_sites_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=False, + ) + + create_engine_mock.assert_called_once_with( + "postgresql+asyncpg://test:test@localhost/test", + pool_pre_ping=True, + ) + + upsert_sites_mock.assert_awaited_once_with( + connection, + [make_site()], + ) + + connection.execute.assert_awaited_once() + engine.dispose.assert_awaited_once() + + +def test_parse_datetime_accepts_z_suffix() -> None: + result = mock_api_import.parse_datetime( + "2024-06-15T12:00:00Z", + ) + + assert result == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + + +def test_parse_args_reads_cli_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + "--dry-run", + ], + ) + + args = mock_api_import.parse_args() + + assert args.start_time == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + assert args.end_time == datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + assert args.limit == 60 + assert args.dry_run is True + + +def test_main_rejects_limit_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "0", + ], + ) + + with pytest.raises( + ValueError, + match="--limit doit être compris entre 1 et 1000", + ): + mock_api_import.main() + + +def test_main_rejects_invalid_period( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T14:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + ], + ) + + with pytest.raises( + ValueError, + match="--start-time doit être antérieur à --end-time", + ): + mock_api_import.main() + + +def test_main_runs_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + start_time = datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + end_time = datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + + import_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "parse_args", + lambda: SimpleNamespace( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ), + ) + + monkeypatch.setattr( + mock_api_import, + "import_mock_api_history", + import_mock, + ) + + mock_api_import.main() + + import_mock.assert_awaited_once_with( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ) + + @pytest.mark.integration async def test_reading_insert_is_idempotent( session: AsyncSession, @@ -241,35 +585,11 @@ async def test_reading_insert_is_idempotent( reading = make_reading() row = build_reading_row(reading) - await session.execute( - text( - """ - INSERT INTO site ( - site_id, - site_type, - site_name, - location, - capacity_kw, - status - ) - VALUES ( - :site_id, - :site_type, - :site_name, - :location, - :capacity_kw, - :status - ) - ON CONFLICT (site_id) - DO UPDATE SET - site_type = EXCLUDED.site_type, - site_name = EXCLUDED.site_name, - location = EXCLUDED.location, - capacity_kw = EXCLUDED.capacity_kw, - status = EXCLUDED.status - """ - ), - make_site(), + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], ) await session.execute( diff --git a/docker-compose.yml b/docker-compose.yml index 91e315e..12b5c96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,10 +50,10 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - - APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:?} - APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:?} - APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:?} + + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} From 19c38fe571f19d6393efe9bd868694c4d07aef1f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:45:25 +0200 Subject: [PATCH 185/205] fix(backend): decoupe l'insertion des recommandations en lots et remet les docs a jour `create_missing()` construisait un seul `INSERT ... VALUES` pour la totalite des propositions. Avec quatre colonnes par ligne et le plafond asyncpg de 32 767 parametres, la route echouait au-dela de 8 191 recommandations par appel, cas devenu realiste maintenant que la detection interne (#104) alimente `alert` en continu. L'insertion passe par des lots de `TAILLE_DE_LOT` lignes, sur le patron de `app/etl/historical_import.py`. L'ADR 0006, `20-backend.md` et la description de la PR annoncaient qu'aucune source n'alimentait `alert` et que #104 n'etait pas commencee. #104 est livree sur `dev` depuis la #113 : les phrases sont corrigees plutot que laissees a vieillir dans un ADR. --- .../app/repositories/recommendation.py | 26 +++++++++++-------- .../tests/repositories/test_recommendation.py | 17 ++++++++++++ .../0006-moteur-de-regles-dans-le-backend.md | 10 ++++--- docs/architecture/20-backend.md | 4 ++- docs/architecture/40-data.md | 5 ++-- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py index 8b07349..144957b 100644 --- a/apps/backend/app/repositories/recommendation.py +++ b/apps/backend/app/repositories/recommendation.py @@ -16,6 +16,9 @@ class NouvelleRecommandation: rule_reference: str +TAILLE_DE_LOT = 1000 + + class RecommendationRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -34,14 +37,15 @@ class RecommendationRepository: # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: - if not nouvelles: - return 0 - - requete = ( - insert(Recommendation) - .values([asdict(nouvelle) for nouvelle in nouvelles]) - .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") - .returning(Recommendation.recommendation_id) - ) - creees = (await self._session.scalars(requete)).all() - return len(creees) + creees = 0 + # Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre + # colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait. + for debut in range(0, len(nouvelles), TAILLE_DE_LOT): + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees += len((await self._session.scalars(requete)).all()) + return creees diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py index 64b3b5f..6585878 100644 --- a/apps/backend/tests/repositories/test_recommendation.py +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -5,6 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert, Recommendation, Site +from app.repositories import recommendation as module_recommendation from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository pytestmark = pytest.mark.integration @@ -123,3 +124,19 @@ async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSe creees = await RecommendationRepository(session).create_missing([]) assert creees == 0 + + +async def test_create_missing_inserts_every_proposal_across_several_batches( + session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2) + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)] + + creees = await depot.create_missing(propositions) + enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id] + await session.rollback() + + assert creees == 5 + assert len(enregistrees) == 5 diff --git a/docs/adr/0006-moteur-de-regles-dans-le-backend.md b/docs/adr/0006-moteur-de-regles-dans-le-backend.md index 794825e..4f23dce 100644 --- a/docs/adr/0006-moteur-de-regles-dans-le-backend.md +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -57,9 +57,13 @@ n'oblige à exposer un port pour régénérer des recommandations. fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée ferait réapparaître ses recommandations à côté des anciennes. -- **Le moteur ne produira rien tant que `alert` restera vide.** Aucun code ne produit aujourd'hui - de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne - s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. +- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée + par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ; + l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant + que la détection n'a pas tourné, sans que le moteur soit à retoucher. +- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT` + lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes, + et la détection interne peut alimenter `alert` au fil de l'eau. - Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait à rouvrir : le moteur redeviendrait consommateur du pipeline ML. diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c45b134..c6189a6 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -206,7 +206,9 @@ le rapport rendu distingue `recommendations_created` de `already_present`. Le m disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). -Tant qu'aucune source n'alimente `alert`, la route est fonctionnelle mais rend un rapport à zéro. +Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la +génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est +découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres. `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index b02fd13..88724e9 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -226,8 +226,9 @@ et ne sont pas considérées comme des alertes actuelles. Les lignes de `recommendation` sont écrites par le moteur de règles du backend (`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate` -ou par `make recommendations`. Le couple `(alert_id, rule_reference)` est unique : rejouer le -moteur sur les mêmes alertes n'ajoute aucune ligne. +ou par `make recommendations`, à partir des alertes déjà en base. Le couple +`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune +ligne. ### Relations entre les tables From 56c6b79a5ae4b409f64b7e93238a8943ee75d87b Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Mon, 21 Sep 2026 09:47:07 +0200 Subject: [PATCH 186/205] style(etl): applique le formatage Ruff aux tests API Mock --- apps/backend/tests/etl/test_mock_api_import.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 2b4c07c..36ad2d8 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -169,9 +169,7 @@ def test_build_reading_row_respects_database_contract() -> None: assert row["source"] == "api_history" assert row["dataset_id"] is None - assert row["timestamp"] == datetime.fromisoformat( - "2024-06-15T12:00:00+00:00" - ) + assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") assert row["consumption_kw"] == 87.34 assert row["consumption_kwh"] == 87.34 From 2d7b4bd74dca05cfd02461ef296c6a7b314051a5 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:51:09 +0200 Subject: [PATCH 187/205] =?UTF-8?q?fix:=20publie=20le=20service=20frontend?= =?UTF-8?q?=20sur=203000,=20le=20port=20qu'=C3=A9coute=20son=20nginx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le compose mappait vers le port 80 du conteneur alors que le nginx de l'image écoute sur 3000 (apps/frontend/nginx.conf, EXPOSE 3000). Le port publié ne pointait sur rien, le service frontend ne répondait pas. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3f7f9ea..0741885 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: frontend: build: ./apps/frontend ports: - - "${FRONTEND_PORT:-3000}:80" + - "${FRONTEND_PORT:-3000}:3000" restart: unless-stopped From b3efb9820830db0e434955a87ac412c7a185f147 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:51:09 +0200 Subject: [PATCH 188/205] feat(infra): reverse proxy Nginx et terminaison TLS devant la stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le SPA appelle /api/v1 en relatif et rien ne routait cet appel vers l'API une fois en conteneur. Le cookie de rafraîchissement prend le préfixe __Secure- dès que APP_ENV sort de local, donc sans HTTPS il n'était jamais posé et l'authentification ne survivait pas à un rechargement de page. Un service proxy, image officielle nginx dont la configuration est montée en volume, devient le seul composant publié : 80 redirige vers 443 et sert le défi ACME, 443 termine le TLS, sert le SPA sur / et l'API sur /api/ sous la même origine, pose HSTS et CSP que l'application refuse délibérément de poser, et ajoute une limitation de débit au frontal. Backend et frontend ne sont plus publiés, la base et l'interface Mailpit sont ramenées sur la boucle locale. nginx lit toujours les deux mêmes fichiers de certificat : seule leur fabrication varie, script openssl pour la démonstration, deploy-hook certbot le jour où un domaine public existera. Le chemin ACME est livré et documenté, pas exercé : sur une IP privée le défi HTTP-01 ne peut pas aboutir. --- .env.example | 10 ++++ .github/dependabot.yml | 6 +++ .gitignore | 3 ++ Makefile | 38 ++++++++++++++- docker-compose.prod.yml | 66 ++++++++++++++++++++++++++ infra/proxy/README.md | 75 ++++++++++++++++++++++++++++++ infra/proxy/acme-deploy-hook.sh | 11 +++++ infra/proxy/conf.d/enervision.conf | 65 ++++++++++++++++++++++++++ infra/proxy/nginx.conf | 40 ++++++++++++++++ infra/proxy/tls/.gitkeep | 0 scripts/tls-selfsigned.sh | 50 ++++++++++++++++++++ 11 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 docker-compose.prod.yml create mode 100644 infra/proxy/README.md create mode 100755 infra/proxy/acme-deploy-hook.sh create mode 100644 infra/proxy/conf.d/enervision.conf create mode 100644 infra/proxy/nginx.conf create mode 100644 infra/proxy/tls/.gitkeep create mode 100755 scripts/tls-selfsigned.sh diff --git a/.env.example b/.env.example index 54dc3d8..a57c7d9 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,13 @@ APP_LOG_LEVEL=INFO APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 +FRONTEND_PORT=3000 + +# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur. +MAILPIT_SMTP_PORT=1025 +MAILPIT_UI_PORT=8025 + +# Stack complète derrière le reverse proxy (docker-compose.prod.yml). +# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat. +PUBLIC_HOST=enervision.local +ACME_EMAIL= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ecebb0d..a925ee4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,3 +38,9 @@ updates: directory: "/apps/frontend" schedule: interval: "weekly" + + # Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose + - package-ecosystem: "docker-compose" + directory: "/" + schedule: + interval: "weekly" diff --git a/.gitignore b/.gitignore index 47574d1..6a16931 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ ml/mlruns/ ml/mlartifacts/ ml/mlflow.db +# TLS : certificats du reverse proxy, générés par script ou par certbot +infra/proxy/tls/*.pem + # IDE et OS .idea/ .vscode/ diff --git a/Makefile b/Makefile index 2a4b3d2..a541692 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,23 @@ BACKEND := apps/backend FRONTEND := apps/frontend ML := ml +COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml + +# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose. +# Le `ifdef` évite d'exporter une valeur vide, qui masquerait alors celle du fichier `.env`. +ifdef PUBLIC_HOST +export PUBLIC_HOST +endif +ifdef ACME_EMAIL +export ACME_EMAIL +endif .DEFAULT_GOAL := help .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train ml-score + ml-lint ml-typecheck ml-test ml-check ml-train ml-score \ + tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -80,6 +91,31 @@ ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=che docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) +tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser + PUBLIC_HOST=$${PUBLIC_HOST:-enervision.local} ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) + +stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... requis + @test -f infra/proxy/tls/fullchain.pem \ + || { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; } + $(COMPOSE_PROD) up -d --build + +stack-down: ## Arrête la stack complète en conservant les données + $(COMPOSE_PROD) stop + +stack-logs: ## Suit les journaux du reverse proxy + $(COMPOSE_PROD) logs -f proxy + +tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST et ACME_EMAIL requis + $(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \ + -d $${PUBLIC_HOST:?PUBLIC_HOST=... requis} \ + --email $${ACME_EMAIL:?ACME_EMAIL=... requis} \ + --agree-tos --no-eff-email --deploy-hook /deploy-hook.sh + $(COMPOSE_PROD) exec proxy nginx -s reload + +tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy + $(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh + $(COMPOSE_PROD) exec proxy nginx -s reload + db-up: ## Démarre la base PostgreSQL TimescaleDB docker compose up -d db diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..70599b8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,66 @@ +# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste +# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et +# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide. +# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul +# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy. + +name: enervision + +services: + db: + ports: !override + - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" + + mailpit: + ports: !override + - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" + + backend: + ports: !reset null + command: + - uvicorn + - app.main:create_app + - --factory + - --host + - 0.0.0.0 + - --port + - "8000" + - --proxy-headers + - --forwarded-allow-ips=* + environment: + APP_ENV: prod + APP_DEBUG: "false" + APP_TRUST_PROXY_HEADERS: "true" + APP_CORS_ORIGINS: https://${PUBLIC_HOST:?PUBLIC_HOST est requis pour la stack complète} + APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST}/reset-password + + frontend: + ports: !reset null + + proxy: + image: nginx:1.28-alpine + depends_on: + - backend + - frontend + ports: + - "80:80" + - "443:443" + volumes: + - ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro + - ./infra/proxy/conf.d:/etc/nginx/conf.d:ro + - ./infra/proxy/tls:/etc/nginx/tls:ro + - acme_webroot:/var/www/certbot + restart: unless-stopped + + certbot: + image: certbot/certbot + profiles: ["acme"] + volumes: + - letsencrypt:/etc/letsencrypt + - acme_webroot:/var/www/certbot + - ./infra/proxy/tls:/tls + - ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro + +volumes: + acme_webroot: + letsencrypt: diff --git a/infra/proxy/README.md b/infra/proxy/README.md new file mode 100644 index 0000000..0e5659b --- /dev/null +++ b/infra/proxy/README.md @@ -0,0 +1,75 @@ +# Reverse proxy + +Terminaison TLS et routage de la stack déployée. Seul composant publié sur le réseau : il +écoute en 80 et 443, et rien d'autre ne sort du réseau Compose. + +- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit. +- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité, + routage. +- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git. +- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`. + +Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la +configuration est montée en volume par `docker-compose.prod.yml`. + +## Routage + +| Chemin | Destination | Remarque | +|---|---|---| +| `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS | +| `/api/v1/auth/` | `backend:8000` | Limitation de débit resserrée, 30 requêtes par minute | +| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel | +| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues | + +`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**, +pas sous `/api`. Ils tombent donc dans `location /`, donc sur le SPA : ils ne sont pas joignables +depuis l'extérieur, sans qu'aucune règle de blocage ait à être écrite. Y toucher, c'est les +exposer. + +## Certificat : deux modes, un seul emplacement + +nginx lit toujours `tls/fullchain.pem` et `tls/privkey.pem`. Seule leur fabrication change, la +configuration n'a jamais à bouger. + +### Démonstration, certificat auto-signé + +```bash +make tls-selfsigned PUBLIC_HOST=enervision.local +make stack-up +``` + +Le navigateur avertira d'un émetteur inconnu : c'est attendu, et c'est le seul mode exploitable +tant que la machine cible n'a pas de nom de domaine public. + +### Let's Encrypt + +Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable +depuis Internet. La cible documentée aujourd'hui (`ssh_host = "10.0.0.10"`, serveur de l'école) +ne remplit ni l'une ni l'autre condition : le chemin ci-dessous est livré et documenté, il n'a +pas été exercé. + +```bash +make stack-up # nginx doit tourner pour servir le défi +make tls-acme PUBLIC_HOST=enervision.fr ACME_EMAIL=ops@enervision.fr +``` + +Renouvellement, à passer en tâche planifiée sur la machine : + +```cron +17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1 +``` + +Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un +greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour. + +## Vérifier la configuration sans démarrer la stack + +```bash +docker run --rm \ + -v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \ + -v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \ + -v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \ + nginx:1.28-alpine nginx -t +``` + +Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image. diff --git a/infra/proxy/acme-deploy-hook.sh b/infra/proxy/acme-deploy-hook.sh new file mode 100755 index 0000000..8ce7143 --- /dev/null +++ b/infra/proxy/acme-deploy-hook.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Contrainte : certbot écrit dans /etc/letsencrypt/live//, nginx lit /etc/nginx/tls/. +# Ce hook recopie le résultat à l'emplacement unique que la configuration nginx connaît, ce +# qui rend le mode auto-signé et le mode ACME interchangeables sans toucher à un vhost. + +set -eu + +cp -L "$RENEWED_LINEAGE/fullchain.pem" /tls/fullchain.pem +cp -L "$RENEWED_LINEAGE/privkey.pem" /tls/privkey.pem +chmod 644 /tls/fullchain.pem +chmod 600 /tls/privkey.pem diff --git a/infra/proxy/conf.d/enervision.conf b/infra/proxy/conf.d/enervision.conf new file mode 100644 index 0000000..cbadfaa --- /dev/null +++ b/infra/proxy/conf.d/enervision.conf @@ -0,0 +1,65 @@ +# Piège : `X-Forwarded-For` se construit avec `$proxy_add_x_forwarded_for`, qui ajoute l'IP +# réelle en fin de chaîne. `get_client_ip()` (apps/backend/app/api/deps.py) ne lit que le +# dernier élément : toute autre forme rend la limitation de débit par IP globale, donc le +# déni de service auto-infligé que ce code cherche précisément à éviter. +# Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de +# nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la +# variable et le résolveur interne de Docker : la résolution redevient dynamique. + +server { + listen 80 default_server; + server_name _; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl default_server; + http2 on; + server_name _; + + resolver 127.0.0.11 valid=10s ipv6=off; + + ssl_certificate /etc/nginx/tls/fullchain.pem; + ssl_certificate_key /etc/nginx/tls/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # L'application refuse délibérément de poser ces deux en-têtes, verrouillé par + # tests/api/test_hardening.py. Ils appartiennent au terminateur TLS, c'est-à-dire ici. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + + location /api/v1/auth/ { + limit_req zone=auth burst=20 nodelay; + set $cible_api http://backend:8000; + proxy_pass $cible_api$request_uri; + } + + location /api/ { + limit_req zone=api burst=40 nodelay; + set $cible_api http://backend:8000; + proxy_pass $cible_api$request_uri; + } + + location / { + set $cible_web http://frontend:3000; + proxy_pass $cible_web$request_uri; + } +} diff --git a/infra/proxy/nginx.conf b/infra/proxy/nginx.conf new file mode 100644 index 0000000..d0da97b --- /dev/null +++ b/infra/proxy/nginx.conf @@ -0,0 +1,40 @@ +# Contrainte : les directives `limit_req_zone` ne sont valides que dans le bloc `http`. +# Les `location` de conf.d/enervision.conf s'y réfèrent par nom, `api` et `auth`. + +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server_tokens off; + + log_format enervision '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent $request_time ' + '"$http_referer" "$http_user_agent"'; + access_log /var/log/nginx/access.log enervision; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + client_max_body_size 2m; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types application/javascript application/json application/xml + image/svg+xml text/css text/plain; + + limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s; + limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m; + limit_req_status 429; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/infra/proxy/tls/.gitkeep b/infra/proxy/tls/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tls-selfsigned.sh b/scripts/tls-selfsigned.sh new file mode 100755 index 0000000..633f3ad --- /dev/null +++ b/scripts/tls-selfsigned.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Contrainte : nginx lit toujours infra/proxy/tls/{fullchain,privkey}.pem, quel que soit le +# mode d'obtention. Ce script remplit ces deux fichiers pour la démonstration, certbot les +# remplit par acme-deploy-hook.sh. La configuration nginx ne connaît pas la différence. + +set -euo pipefail + +RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DESTINATION="$RACINE/infra/proxy/tls" +HOTE="${PUBLIC_HOST:-enervision.local}" +ADRESSE="${PUBLIC_IP:-}" +JOURS="${TLS_DAYS:-365}" +ECRASER=0 + +for argument in "$@"; do + case "$argument" in + --force) ECRASER=1 ;; + *) + echo "Usage : PUBLIC_HOST=exemple.local [PUBLIC_IP=10.0.0.10] $0 [--force]" >&2 + exit 2 + ;; + esac +done + +if [[ -f "$DESTINATION/fullchain.pem" && $ECRASER -eq 0 ]]; then + echo "Un certificat existe déjà dans $DESTINATION." >&2 + echo "Relancer avec --force pour l'écraser." >&2 + exit 1 +fi + +mkdir -p "$DESTINATION" + +NOMS="DNS:$HOTE,DNS:localhost" +if [[ -n "$ADRESSE" ]]; then + NOMS="$NOMS,IP:$ADRESSE" +fi + +openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days "$JOURS" \ + -subj "/CN=$HOTE" \ + -addext "subjectAltName=$NOMS" \ + -keyout "$DESTINATION/privkey.pem" \ + -out "$DESTINATION/fullchain.pem" 2>/dev/null + +chmod 600 "$DESTINATION/privkey.pem" +chmod 644 "$DESTINATION/fullchain.pem" + +echo "Certificat auto-signé écrit dans $DESTINATION." +echo " Noms couverts : $NOMS" +echo " Validité : $JOURS jours" +echo "Le navigateur avertira d'un émetteur inconnu, c'est attendu hors Let's Encrypt." From 0c487fa7be7a366db7a63dafeb92934ae483ac68 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:51:19 +0200 Subject: [PATCH 189/205] =?UTF-8?q?docs:=20acte=20la=20terminaison=20TLS?= =?UTF-8?q?=20par=20l'ADR=200007=20et=20met=20=C3=A0=20jour=20les=20vues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ADR 0007 tranche le reverse proxy en Compose plutôt que l'ingress k3s, qui supposait un registre et des manifestes inexistants, et referme la première question ouverte de 10-infra.md. Les vues suivent : troisième topologie et ports 80/443 dans 10-infra.md, TLS, HSTS et CSP passent d'« Absent, et assumé » à « En place » dans la vue d'ensemble, la ligne API8 transport rejoint les points couverts de la traçabilité OWASP. Trois affirmations périmées disparaissent au passage : le compose a bien un service frontend, environment.ts ne pointe plus sur localhost:8000, et le Dockerfile du front n'est plus mono-étage sur une branche. --- README.md | 23 ++++- docs/README.md | 2 + ...-terminaison-tls-et-reverse-proxy-nginx.md | 98 +++++++++++++++++++ docs/architecture/00-vue-ensemble.md | 21 ++-- docs/architecture/10-infra.md | 60 ++++++++++-- docs/architecture/20-backend.md | 7 +- docs/architecture/30-frontend.md | 23 ++--- .../31-contrat-authentification.md | 18 ++-- docs/architecture/owasp-traceabilite.md | 6 +- 9 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md diff --git a/README.md b/README.md index 33426fb..7e1181e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | +| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | ML | LightGBM, MLflow | `ml` | Entrainement initialise | @@ -51,9 +52,11 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ ├── plugins/ Operateurs et hooks maison │ ├── include/ Requetes SQL et ressources des DAGs │ └── tests/ Tests d'integrite des DAGs -├── infra/terraform/ -│ ├── modules/ Modules reutilisables -│ └── environments/ Racines Terraform, une par environnement +├── infra/ +│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage +│ └── terraform/ +│ ├── modules/ Modules reutilisables +│ └── environments/ Racines Terraform, une par environnement ├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow ├── monitoring/ │ ├── prometheus/ Collecte et regles d'alerte @@ -98,6 +101,20 @@ Verifier que la base repond et que l'extension est chargee : curl -s localhost:8000/api/v1/health/ready ``` +## Stack complète derrière le reverse proxy + +Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine : + +```bash +make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration +make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié +``` + +Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun +nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans +[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans +[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). + ## Conventions - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. diff --git a/docs/README.md b/docs/README.md index 17859f5..3918425 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,3 +11,5 @@ | [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | | [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête | | [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | +| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global | +| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | diff --git a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md new file mode 100644 index 0000000..1d5eab4 --- /dev/null +++ b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md @@ -0,0 +1,98 @@ +# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose + +- Statut : accepté +- Date : 2026-09-21 + +## Contexte + +Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress +remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans +« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte. +`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » : +servir le SPA et l'API sous la même origine, et servir en HTTPS. + +Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers +`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En +développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le +fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend +le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et +l'authentification ne tient pas au rechargement de page. + +La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible +documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le +`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas +aboutir, faute de DNS public et de port 80 entrant. + +## Décision + +**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à +publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la +base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par +l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement. + +**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers +l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et +redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister. + +**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`. +Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de +certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura +pas à changer le jour de la bascule. + +**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par +`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut +pas savoir si elle est jointe en HTTPS, le terminateur, si. + +## Pourquoi Compose et pas l'ingress k3s + +Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le +provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais +été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux +topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un +stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les +deux topologies », et il ne tient pas dans le jalon. + +Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un +cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix +d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes. + +## Ce que le proxy n'expose pas, et pourquoi c'est structurel + +`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**, +pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans +`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est +nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer » +Swagger, il publiera les métriques avec. + +## Conséquences + +- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent + ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine + CORS, et le cookie devient `__Secure-ev_refresh`. +- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec + `$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que + lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP + du proxy, c'est-à-dire globalement. +- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20 + requêtes par seconde sur l'API, 30 par minute sur `/api/v1/auth/`. +- La ligne API8 transport de `owasp-traceabilite.md` se referme. +- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas + été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur + avertit, et c'est la situation réelle du projet, pas un raccourci. +- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`, + sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement. + +## Alternatives écartées + +- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un + registre et des manifestes qui n'existent pas, à quatre jours du rendu. +- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS : + moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front + redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire. +- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le + défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx. +- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine + possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la + configuration nginx le jour où ces deux éléments existent. +- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi + la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index a650a1e..c5c2416 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -46,6 +46,7 @@ flowchart TB navigateur["Navigateur"] subgraph machine["Machine on-premise"] + proxy["Reverse proxy Nginx
:80 et :443"] front["Frontend Angular 22
apps/frontend"] api["API FastAPI
apps/backend"] db[("PostgreSQL 17
TimescaleDB")] @@ -54,7 +55,9 @@ flowchart TB grafana["Grafana"] end - navigateur --> front + navigateur --> proxy + proxy --> front + proxy --> api front -.-> api api --> db airflow -.-> db @@ -78,7 +81,7 @@ collecteur ne vient le lire. | 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | -| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | +| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | | CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien | @@ -137,6 +140,11 @@ consolidée. jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB. - **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture. - **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. +- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige + 80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que + l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal** + distincte de celle de l'application. Voir + [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). - **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root` sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les `.example`. @@ -150,13 +158,10 @@ consolidée. arrêteraient une application compromise. Même raison de report. - **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut agir sur le site B. C'est la limite connue du modèle. -- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore. -- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas - l'infrastructure. +- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi + HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME + est livré et documenté mais pas exercé. - **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD. -- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP - simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir - [31-contrat-authentification.md](31-contrat-authentification.md). ## Décisions structurantes diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 745c6f5..c1c18a6 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -1,12 +1,13 @@ # Infrastructure -Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans -quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux. +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. | Topologie | Sert à | Statut | |---|---|---| | Docker Compose | Développer et recetter sur le poste | `Fait` | -| k3s single-node | Déployer sur le serveur on-premise | `En cours` | +| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` | +| k3s single-node | Cible à terme | `En cours` | ## Poste de développement @@ -48,7 +49,44 @@ Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devine 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). -## Cible de déploiement +## Machine cible, exécution Docker + +Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le +`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de +l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). + +```mermaid +flowchart LR + navigateur["Navigateur"] + + subgraph machine["Machine on-premise"] + proxy["service proxy
nginx:1.28-alpine
:80 et :443"] + front["service frontend
nginx statique :3000"] + api["service backend
uvicorn :8000"] + db[("service db
:5432")] + mail["service mailpit"] + end + + navigateur -->|"HTTPS"| proxy + proxy -->|"/"| front + proxy -->|"/api/"| api + api --> db + api --> mail +``` + +Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont +plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables +par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et +la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md). + +Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas : + +- Servir le SPA et l'API sous la même origine est ce qui rend le cookie `__Secure-ev_refresh` + utilisable. Sans cela, `apiUrl: '/api/v1'` ne mène nulle part une fois en conteneur. +- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP + compte sur l'IP du proxy et devient globale. + +## Cible à terme, k3s Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais été appliqué. @@ -104,6 +142,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de | `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` | | Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` | | Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` | +| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | +| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` | ## Ports et noms @@ -112,12 +152,14 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de | PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris | | PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` | | API | `8000` | Identique en conteneur et hors conteneur | -| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend | +| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` | +| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte | +| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME | | SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable | | 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` | -## Le trou entre les deux topologies +## Le trou vers k3s Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première @@ -125,7 +167,11 @@ question à trancher, avant toute ressource Kubernetes. ## Questions ouvertes -- **Quel ingress** remplace Traefik, et qui termine le TLS. +- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par + l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la + topologie Compose, pas pour Kubernetes. +- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat + reste auto-signé. - **Quel registre d'images**, et comment il est alimenté sans CI. - **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le cluster ou à côté. diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 731ff21..4fc2118 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -374,9 +374,12 @@ Le reste, par ordre de surface : de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL. - En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au - terminateur TLS, que l'application ne connaît pas. + terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose + ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). - Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. -- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative. +- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy. + `APP_TRUST_PROXY_HEADERS` doit alors valoir vrai, sinon le compteur par IP devient global. +- Pas de journalisation des accès applicative. ## Observabilité diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index fb10f92..ee0b6f8 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -97,11 +97,11 @@ En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. -En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif -(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur -`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...` -sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le -backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md). +En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL +absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build +de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy** +qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir +[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). ## Exécution @@ -118,13 +118,14 @@ le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.1 tester ni construire le frontend. Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`, -englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en -développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200 -n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. +englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis +`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut +d'`APP_CORS_ORIGINS`, côté backend. -Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans -`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage -avec un service statique, il reste à écrire. +Le service `frontend` du `docker-compose.yml` sert le build statique par le nginx de +`apps/frontend/Dockerfile`, multi-étage, qui **écoute sur 3000**. En déploiement il n'est plus +publié du tout : le reverse proxy est seul à sortir sur le réseau, et l'atteint par le réseau +Compose. ## Sécurité diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index 981cd85..4ad3dcf 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -129,18 +129,18 @@ n'est pas envoyé et le rafraîchissement échoue toujours. En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est **même origine** et le cookie marche sans rien configurer. -En production, `src/environments/environment.ts` contient encore le gabarit -`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun -cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.** +En déploiement, les deux conditions sont désormais remplies par le reverse proxy +([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)) : `environment.ts` porte un +`apiUrl` relatif, `/api/v1`, et le proxy sert le SPA sur `/` et l'API sur `/api/` **sous la même +origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-ev_refresh` +utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et +l'authentification ne survit pas à un rechargement de page. -Deux corrections, à faire avant la démonstration : - -1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un - `location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ; -2. servir en HTTPS. +Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout +vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie. Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin -réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque. +réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque. ## Origines autorisées diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 34e6853..0c82fc6 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -42,17 +42,21 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints | 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 | | CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | +| Terminaison TLS au frontal, redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit au frontal | `infra/proxy/conf.d/enervision.conf`, ADR 0007 | API8 Security Misconfiguration, A05 | Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01. +Note sur API8 : le transport est couvert, le certificat ne l'est qu'à moitié. Tant qu'aucun nom de +domaine public ne résout vers la machine, le défi HTTP-01 de Let's Encrypt ne peut pas aboutir et +le certificat servi reste auto-signé. Le chemin ACME est livré et documenté, pas exercé. + ## Non couvert, et pourquoi | Item | État | Raison | |---|---|---| | **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. | -| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | | **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | | **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | | **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | From b941880c220b0a443b64535e1ad1b072b2b42615 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 10:03:23 +0200 Subject: [PATCH 190/205] feat(etl,ml): orchestre l'entrainement et le scoring LightGBM via deux DAGs Airflow --- .env.example | 15 + .gitignore | 3 + Makefile | 30 +- db/init/120-airflow-database.sql | 5 + docker-compose.yml | 76 +- docs/architecture/00-vue-ensemble.md | 14 +- docs/architecture/10-infra.md | 29 + etl/README.md | 4 +- etl/airflow/Dockerfile | 40 + etl/airflow/dags/ml_score.py | 33 + etl/airflow/dags/ml_train.py | 36 + etl/airflow/pyproject.toml | 32 + etl/airflow/tests/conftest.py | 16 + etl/airflow/tests/test_dags.py | 52 + etl/airflow/uv.lock | 1970 ++++++++++++++++++++++++++ 15 files changed, 2345 insertions(+), 10 deletions(-) create mode 100644 db/init/120-airflow-database.sql create mode 100644 etl/airflow/Dockerfile create mode 100644 etl/airflow/dags/ml_score.py create mode 100644 etl/airflow/dags/ml_train.py create mode 100644 etl/airflow/pyproject.toml create mode 100644 etl/airflow/tests/conftest.py create mode 100644 etl/airflow/tests/test_dags.py create mode 100644 etl/airflow/uv.lock diff --git a/.env.example b/.env.example index 54dc3d8..b754fe9 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,18 @@ APP_LOG_LEVEL=INFO APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 + +# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le +# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus. +AIRFLOW_PORT=8080 +# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +AIRFLOW_FERNET_KEY=change_me +# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la +# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" +AIRFLOW_WEBSERVER_SECRET_KEY=change_me +AIRFLOW_ADMIN_USERNAME=admin +# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les +# comptes `app_user` d'EnerVision. +AIRFLOW_ADMIN_PASSWORD=change_me +AIRFLOW_ADMIN_EMAIL=admin@enervision.fr diff --git a/.gitignore b/.gitignore index 47574d1..f1715a0 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ ml/mlruns/ ml/mlartifacts/ ml/mlflow.db +# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests) +etl/airflow/tests/.airflow_home/ + # IDE et OS .idea/ .vscode/ diff --git a/Makefile b/Makefile index 2a4b3d2..62e6de3 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,20 @@ BACKEND := apps/backend FRONTEND := apps/frontend ML := ml +AIRFLOW := etl/airflow .DEFAULT_GOAL := help -.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ +.PHONY: help install install-backend install-frontend install-ml install-airflow \ + dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train ml-score + ml-lint ml-typecheck ml-test ml-check ml-train ml-score \ + airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML +install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow install-backend: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups @@ -22,6 +25,9 @@ install-frontend: ## Installe les dépendances du frontend install-ml: ## Installe les dépendances du pipeline ML cd $(ML) && uv sync --all-groups +install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow + cd $(AIRFLOW) && uv sync --all-groups + dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud @trap 'kill 0' EXIT INT TERM; \ $(MAKE) --no-print-directory dev-backend & \ @@ -77,6 +83,24 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) +airflow-lint: ## Analyse statique des DAGs Airflow + cd $(AIRFLOW) && uv run ruff check . + +airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue + cd $(AIRFLOW) && uv run pytest + +airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow + +airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. + docker compose up -d airflow-init airflow-webserver airflow-scheduler + @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" + +airflow-down: ## Arrête le webserver et le scheduler Airflow + docker compose stop airflow-webserver airflow-scheduler + +airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) + docker compose logs -f airflow-scheduler + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/db/init/120-airflow-database.sql b/db/init/120-airflow-database.sql new file mode 100644 index 0000000..05b7f72 --- /dev/null +++ b/db/init/120-airflow-database.sql @@ -0,0 +1,5 @@ +-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base +-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire +-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un +-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115). +CREATE DATABASE airflow; diff --git a/docker-compose.yml b/docker-compose.yml index 3f7f9ea..eb79b60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,32 @@ name: enervision +# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du +# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte +# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce +# dernier puisse au besoin l'inspecter sans en devenir dependant. +x-airflow-common: &airflow-common + build: + context: . + dockerfile: etl/airflow/Dockerfile + environment: &airflow-common-env + AIRFLOW__CORE__EXECUTOR: LocalExecutor + AIRFLOW__CORE__LOAD_EXAMPLES: "false" + AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?} + AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:?} + AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow + # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003/CLAUDE.md) : + # 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 + volumes: + - ./etl/airflow/dags:/opt/airflow/dags + - ./etl/airflow/plugins:/opt/airflow/plugins + - ./etl/airflow/include:/opt/airflow/include + - airflow_logs:/opt/airflow/logs + - airflow_ml_state:/opt/ml/state + restart: unless-stopped + services: db: image: timescale/timescaledb-ha:pg17 @@ -19,6 +45,7 @@ services: - pgdata:/home/postgres/pgdata/data - ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro - ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro + - ./db/init/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s @@ -64,7 +91,54 @@ services: ports: - "${FRONTEND_PORT:-3000}:80" restart: unless-stopped - + + # Conteneur unique, jamais redemarre : migre la base de metadonnees puis cree le premier compte + # (idempotent, `|| true` sur la creation qui echoue si le compte existe deja). `webserver` et + # `scheduler` attendent qu'il se termine avec succes avant de demarrer. + airflow-init: + <<: *airflow-common + restart: "no" + command: + - bash + - -c + - | + airflow db migrate + airflow users create \ + --username "${AIRFLOW_ADMIN_USERNAME:-admin}" \ + --password "${AIRFLOW_ADMIN_PASSWORD:?}" \ + --firstname Admin \ + --lastname EnerVision \ + --role Admin \ + --email "${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}" \ + || true + + airflow-webserver: + <<: *airflow-common + command: webserver + ports: + - "${AIRFLOW_PORT:-8080}:8080" + depends_on: + db: + condition: service_healthy + airflow-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + airflow-scheduler: + <<: *airflow-common + command: scheduler + depends_on: + db: + condition: service_healthy + airflow-init: + condition: service_completed_successfully volumes: pgdata: + airflow_logs: + airflow_ml_state: diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index a650a1e..3e99407 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -57,7 +57,7 @@ flowchart TB navigateur --> front front -.-> api api --> db - airflow -.-> db + airflow --> db prom -.-> api grafana -.-> db grafana -.-> prom @@ -67,6 +67,10 @@ 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 : deux DAGs orchestrent l'entraînement et +le scoring du modèle ML (issue #115), cf. plus bas et [20-backend.md](20-backend.md). Le reste du +périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit. + Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -77,15 +81,17 @@ 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | -| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | +| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG | | CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien | ## Flux bout en bout -Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base. +Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les +deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas +l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous. ```mermaid sequenceDiagram diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 745c6f5..7bd751c 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -48,6 +48,33 @@ Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devine 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). +### Airflow (`ml_train`/`ml_score`, issue #115) + +Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make +airflow-up`, pas dans `make dev`) : + +| Service | Rôle | Points notables | +|---|---|---| +| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | +| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | +| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --frozen --no-dev python -m enervision_ml...`), c'est lui qui a besoin du volume `airflow_ml_state` | + +Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : +l'image doit pouvoir `COPY` `ml/pyproject.toml`/`ml/uv.lock`/`ml/enervision_ml` pour se +synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync --locked` à la +construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers +ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow. + +Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier +`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute +`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois : +`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`. + +`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base +est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur +(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la +construction de l'image. + ## Cible de déploiement Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais @@ -116,6 +143,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de | SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable | | Base applicative | `enervision` | Variable `POSTGRES_DB` | | Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` | +| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` | +| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | ## Le trou entre les deux topologies diff --git a/etl/README.md b/etl/README.md index b835311..324e0a0 100644 --- a/etl/README.md +++ b/etl/README.md @@ -344,6 +344,6 @@ uv run ruff check app\etl tests\etl L'import historique constitue la première brique du pipeline Data EnerVision. -La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning. +Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`), mais orchestre pour l'instant le pipeline ML (`ml_train`/`ml_score`, issue #115), pas encore ce pipeline ETL : orchestrer `historical_import.py` (normalisation et chargement micro-batch, issues #15/#16) reste à faire. -Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL. \ No newline at end of file +Le principe reste le même que documenté à l'origine : Airflow orchestre les traitements existants sans remplacer leur logique métier, cf. `etl/airflow/dags/ml_train.py`/`ml_score.py` pour un exemple concret de ce patron (des `BashOperator` qui invoquent le script tel quel). \ No newline at end of file diff --git a/etl/airflow/Dockerfile b/etl/airflow/Dockerfile new file mode 100644 index 0000000..52e1877 --- /dev/null +++ b/etl/airflow/Dockerfile @@ -0,0 +1,40 @@ +# Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14, +# distinct du Python 3.12 qui fait tourner Airflow lui-meme, pour que les DAGs puissent lancer +# `uv run python -m enervision_ml.train`/`.score` en sous-processus (cf. docs/architecture/ +# 20-backend.md, section Détection d'alertes internes pour le meme raisonnement applique a +# app/detection). Airflow ne devient jamais un consommateur direct de LightGBM/MLflow. +FROM apache/airflow:2.10.4-python3.12 + +# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans +# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file` +# au premier `import lightgbm`, seulement au moment ou une tache tourne reellement. +USER root +RUN apt-get update \ + && apt-get install --no-install-recommends -y libgomp1 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +# Pre-cree, appartenant a `airflow` : docker-compose y monte un volume nomme partage entre +# `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des +# permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il +# serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000). +RUN mkdir -p /opt/ml/state && chown -R airflow:root /opt/ml +USER airflow + +# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de +# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot +# (apps/backend/Dockerfile). +COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/opt/ml/.venv + +WORKDIR /opt/ml + +COPY --chown=airflow:root ml/pyproject.toml ml/uv.lock ./ +RUN uv sync --locked --no-install-project --no-dev + +COPY --chown=airflow:root ml/enervision_ml ./enervision_ml +RUN uv sync --locked --no-dev + +WORKDIR /opt/airflow diff --git a/etl/airflow/dags/ml_score.py b/etl/airflow/dags/ml_score.py new file mode 100644 index 0000000..94d7708 --- /dev/null +++ b/etl/airflow/dags/ml_score.py @@ -0,0 +1,33 @@ +"""DAG de scoring horaire du modele LightGBM (issue #115). + +Planifie toutes les heures, au rythme documente par `enervision_ml.score` (score le prochain pas +horaire par site). Reutilise le modele ecrit par `ml_train` (DAG separe, declenche a la main) : +ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la tache echoue +(`FileNotFoundError`) plutot que de rester silencieuse. +""" + +from __future__ import annotations + +from datetime import datetime + +from airflow.models.dag import DAG +from airflow.operators.bash import BashOperator + +MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" + +with DAG( + dag_id="ml_score", + description="Score le prochain pas horaire par site (enervision_ml.score).", + schedule="@hourly", + start_date=datetime(2026, 1, 1), + catchup=False, + tags=["ml"], +) as dag: + # `--frozen --no-dev` : cf. `ml_train.py`, meme raisonnement. + BashOperator( + task_id="score", + bash_command=( + "cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.score " + f"--model {MODEL_PATH}" + ), + ) diff --git a/etl/airflow/dags/ml_train.py b/etl/airflow/dags/ml_train.py new file mode 100644 index 0000000..f4ec36b --- /dev/null +++ b/etl/airflow/dags/ml_train.py @@ -0,0 +1,36 @@ +"""DAG d'entrainement du modele LightGBM (issue #115). + +Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise +(cf. `docs/architecture/20-backend.md`). Declenchement manuel depuis l'UI ou la CLI Airflow en +attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que ce DAG +ecrit, il ne reentraine jamais rien lui-meme. +""" + +from __future__ import annotations + +from datetime import datetime + +from airflow.models.dag import DAG +from airflow.operators.bash import BashOperator + +MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" +MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db" + +with DAG( + dag_id="ml_train", + description="Entraine le modele LightGBM de prevision de consommation (enervision_ml.train).", + schedule=None, + start_date=datetime(2026, 1, 1), + catchup=False, + tags=["ml"], +) as dag: + # `--frozen --no-dev` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image + # (groupe `dev` exclu). Sans `--no-dev` ici, `uv run` resynchronise ruff/mypy a chaque + # execution : un acces reseau evitable, sur le chemin d'execution d'une tache planifiee. + BashOperator( + task_id="train", + bash_command=( + "cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.train " + f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}" + ), + ) diff --git a/etl/airflow/pyproject.toml b/etl/airflow/pyproject.toml new file mode 100644 index 0000000..a792f9f --- /dev/null +++ b/etl/airflow/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "enervision-airflow" +version = "0.1.0" +description = "DAGs d'orchestration EnerVision (Airflow)" +requires-python = ">=3.12,<3.13" +dependencies = [ + "apache-airflow==2.10.4", +] + +[dependency-groups] +dev = [ + "ruff>=0.16.7", + "pytest>=9.1.1", +] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["dags", "tests"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "RUF"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/etl/airflow/tests/conftest.py b/etl/airflow/tests/conftest.py new file mode 100644 index 0000000..0b6e04c --- /dev/null +++ b/etl/airflow/tests/conftest.py @@ -0,0 +1,16 @@ +"""Isole Airflow d'un `~/airflow` reel : `AIRFLOW_HOME` doit etre pose avant le premier `import +airflow`, donc ici plutot que dans une fixture (les fixtures s'executent trop tard, apres que les +modules de test aient deja importe `airflow`).""" + +import os +from pathlib import Path + +_AIRFLOW_HOME = Path(__file__).resolve().parent / ".airflow_home" +_AIRFLOW_HOME.mkdir(exist_ok=True) + +os.environ.setdefault("AIRFLOW_HOME", str(_AIRFLOW_HOME)) +os.environ.setdefault("AIRFLOW__CORE__LOAD_EXAMPLES", "False") +os.environ.setdefault("AIRFLOW__CORE__UNIT_TEST_MODE", "True") +os.environ.setdefault( + "AIRFLOW__DATABASE__SQL_ALCHEMY_CONN", f"sqlite:///{_AIRFLOW_HOME / 'airflow.db'}" +) diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py new file mode 100644 index 0000000..9b03f49 --- /dev/null +++ b/etl/airflow/tests/test_dags.py @@ -0,0 +1,52 @@ +"""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 pathlib import Path + +import pytest +from airflow.models.dagbag import DagBag + +DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" + + +@pytest.fixture(scope="module") +def dagbag() -> DagBag: + return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) + + +def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None: + assert dagbag.import_errors == {} + + +def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None: + assert set(dagbag.dag_ids) == {"ml_train", "ml_score"} + + +def test_ml_train_has_no_schedule() -> None: + dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) + assert dagbag.dags["ml_train"].timetable.summary == "None" + + +def test_ml_score_runs_every_hour() -> None: + # `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend. + dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) + assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *" + + +def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: + tache = dagbag.dags["ml_train"].get_task("train") + assert "enervision_ml.train" in tache.bash_command + + +def test_ml_score_task_calls_the_scoring_module(dagbag: DagBag) -> None: + tache = dagbag.dags["ml_score"].get_task("score") + assert "enervision_ml.score" in tache.bash_command + + +def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None: + entrainement = dagbag.dags["ml_train"].get_task("train").bash_command + scoring = dagbag.dags["ml_score"].get_task("score").bash_command + chemin_modele = "/opt/ml/state/models/lightgbm-consumption.txt" + + assert chemin_modele in entrainement + assert chemin_modele in scoring diff --git a/etl/airflow/uv.lock b/etl/airflow/uv.lock new file mode 100644 index 0000000..0fd3a71 --- /dev/null +++ b/etl/airflow/uv.lock @@ -0,0 +1,1970 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +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" } +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" }, +] + +[[package]] +name = "aiosmtplib" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" } +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 = "alembic" +version = "1.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/10/181eecdd552217d0342492bd6f3b8a96e973083379aace3d3402830ddc03/alembic-1.19.2.tar.gz", hash = "sha256:297950a8a91f6770eb82bfbce9bea55c728b90a5386c6e81430191a319d138b0", size = 2082643, upload-time = "2026-09-04T17:10:11.212Z" } +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 = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "apache-airflow" +version = "2.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = "argcomplete" }, + { name = "asgiref" }, + { name = "attrs" }, + { name = "blinker" }, + { 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 = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "lazy-object-proxy" }, + { name = "linkify-it-py" }, + { name = "lockfile" }, + { name = "markdown-it-py" }, + { name = "markupsafe" }, + { name = "marshmallow-oneofschema" }, + { name = "mdit-py-plugins" }, + { name = "methodtools" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pendulum" }, + { name = "pluggy" }, + { name = "psutil" }, + { name = "pygments" }, + { name = "pyjwt" }, + { name = "python-daemon" }, + { name = "python-dateutil" }, + { name = "python-nvd3" }, + { name = "python-slugify" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3339-validator" }, + { name = "rich" }, + { name = "rich-argparse" }, + { name = "setproctitle" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-jsonfield" }, + { name = "tabulate" }, + { name = "tenacity" }, + { name = "termcolor" }, + { name = "universal-pathlib" }, + { name = "werkzeug" }, +] +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" } +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" }, +] + +[[package]] +name = "apache-airflow-providers-common-compat" +version = "1.9.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" } +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" }, +] + +[[package]] +name = "apache-airflow-providers-common-io" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apache-airflow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/30/d95fff6ee741a36f79720b55b4a4858c46d9d3bd67fa20657b790a6e76e6/apache_airflow_providers_common_io-1.6.5.tar.gz", hash = "sha256:9eeb3e744b9758ba68f75d72605a7e68519e3e5432efa5e21a550ddec5d0c050", size = 23861, upload-time = "2025-11-17T19:10:40.239Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/e5/9b88e3141effb5f8ddb291b9f8bc9b5c33cd447748336c18f3e3757b1d6c/apache_airflow_providers_common_io-1.6.5-py3-none-any.whl", hash = "sha256:4fcfb416c2531cf0d767a28c3613efe386a7cd0a107101c85489cc675aa0a622", size = 19794, upload-time = "2025-11-17T19:08:43.114Z" }, +] + +[[package]] +name = "apache-airflow-providers-common-sql" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apache-airflow" }, + { name = "apache-airflow-providers-common-compat" }, + { name = "methodtools" }, + { name = "more-itertools" }, + { name = "sqlparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/e5/719d8cf9183ebc9ef35a8169e1d6fb74a75c2526fee7b62ffdac0c6c49ee/apache_airflow_providers_common_sql-1.29.0.tar.gz", hash = "sha256:12fcf5fb3b5863e5bc816f0ba5d446bdf6bea324e6ac237083d212e644359c66", size = 105275, upload-time = "2025-11-17T19:10:42.132Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosmtplib" }, + { name = "apache-airflow" }, + { name = "apache-airflow-providers-common-compat" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/3d/6a5f3bfb8767515b5b409ce8e135bc7a9152d74ec19e43f09e09bb5047a8/apache_airflow_providers_smtp-2.3.2.tar.gz", hash = "sha256:d1b8678198dfa4d6ab90044d5715ce995e40fd09f8108a363bd297ac9f65542f", size = 45364, upload-time = "2025-11-17T19:11:42.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/c1/3d037b871c840b093c0d4b84f6fa3cda0375e792d2b91899d503e9811a3a/apache_airflow_providers_smtp-2.3.2-py3-none-any.whl", hash = "sha256:39d7f7a9a09896d5572e92f77cde18bd2c3b9acd5afa2039b49717f5c034ade4", size = 25260, upload-time = "2025-11-17T19:09:51.588Z" }, +] + +[[package]] +name = "apache-airflow-providers-sqlite" +version = "4.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apache-airflow" }, + { name = "apache-airflow-providers-common-sql" }, +] +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" } +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" }, +] + +[[package]] +name = "apispec" +version = "6.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +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" } +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" }, +] + +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +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 = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.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" } +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" }, +] + +[[package]] +name = "cachelib" +version = "0.17.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" } +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" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colorlog" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/75/b44b05ae7d1e49b59a27a917c44e04ce9aa4cebdd05dac42ba7df06a91b4/cron_descriptor-2.1.0.tar.gz", hash = "sha256:ecddb8b2f6c5286398949aaefe185364666af74f33b01877c61378e1fd4e38e6", size = 50221, upload-time = "2026-06-02T16:26:28.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/41/c476c41cb88dbbba83e0685dd331b9d32599f1e4a94ca223b02437a3d83b/cron_descriptor-2.1.0-py3-none-any.whl", hash = "sha256:e280efae0e375e2cbc62846f833888f12c7921d2d11ab0dca598f94c51af8639", size = 74734, upload-time = "2026-06-02T16:26:26.919Z" }, +] + +[[package]] +name = "croniter" +version = "6.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "enervision-airflow" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "apache-airflow" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "apache-airflow", specifier = "==2.10.4" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.7" }, +] + +[[package]] +name = "flask" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, +] +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" } +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" }, +] + +[[package]] +name = "flask-appbuilder" +version = "4.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apispec", extra = ["yaml"] }, + { name = "click" }, + { name = "colorama" }, + { 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 = "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" }, +] + +[[package]] +name = "flask-caching" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachelib" }, + { name = "flask" }, +] +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" } +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" }, +] + +[[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]] +name = "fsspec" +version = "2026.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } +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 = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +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/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/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" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, +] + +[[package]] +name = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/c1/4c9a2e0e6b0aaf02781404cad2f79211f989f2c827cf672a4a48d1604d3e/grpcio-1.84.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:b5c6f20d657ae09ae4e30d9d3a21edd13f1219d58cc6f999b9d1bb63be9c1baa", size = 6415756, upload-time = "2026-09-14T06:57:39.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/131e7007bdee9acb77a8dbe8a16fa9fef75f88c1695242d8ee0993ac2d3d/grpcio-1.84.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:406583b4e8fb2282ebd392e12b963e601c1f82e07125a8c2cb5b144e7e024796", size = 12339195, upload-time = "2026-09-14T06:57:42.373Z" }, + { url = "https://files.pythonhosted.org/packages/db/d1/a7b7cda98fcab9b3d2916204a872d87371158a7a34e41768f524584fb64d/grpcio-1.84.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbdbcd06986ede3ce584083b1dc2afe6808e8943e5cf50ad11183c03aceda25a", size = 6984468, upload-time = "2026-09-14T06:57:45.035Z" }, + { url = "https://files.pythonhosted.org/packages/19/81/c5be83e3ac9416f73c4c51fe1ea9c41a0c42fc3509e3505faa46f5046abe/grpcio-1.84.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:23e6e8e8a75cff88e0a793bfd3becea03a13e2763ae90c1ff573bc19ca5b429a", size = 7749432, upload-time = "2026-09-14T06:57:47.395Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bf/258cd7c0a7ed92745dc93c31666d462d05b702807a689744bd49fb833bde/grpcio-1.84.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b44f0a0fc7bc6677d38cc80bca1a32814ce6c8f200fb8b3c1a61c9d77eaefbf3", size = 7156115, upload-time = "2026-09-14T06:57:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4b/7f829418dbfcf91b875e55e2973f1059a95decb4f081313416317ef04ec1/grpcio-1.84.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:210e4c32f907045eb8158273e60c6ab69a3947697df6245dbda381f26c59485b", size = 7708010, upload-time = "2026-09-14T06:57:52.496Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/9932e2fec6a04205f8bf3f8f4d2020479dcdac88feb6f93822ed31bf0eba/grpcio-1.84.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a71d24f40b0cc6798feaa978c7411dc1135b7018e9fc0442db611c139bf58344", size = 8759980, upload-time = "2026-09-14T06:57:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5c/b67407c6dbc480dfc0715f6eccdb1061e7c88d85f9a330a241d357a538c5/grpcio-1.84.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6c972474ce691aca74e58d17625450cef153dc4760364cadeb167983ea6d589", size = 8124904, upload-time = "2026-09-14T06:57:58.569Z" }, + { url = "https://files.pythonhosted.org/packages/02/37/2bfdae2df8dfcfc0df619b628e0c7153ce703adae827243f44720322ccc1/grpcio-1.84.0-cp312-cp312-win32.whl", hash = "sha256:0d532ade4486dad9b302ffa4d4683d67561051c26d17c4023322845e9fa10140", size = 4478915, upload-time = "2026-09-14T06:58:00.714Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +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 = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "inflection" +version = "0.5.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" } +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" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, +] + +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +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" } +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" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "lockfile" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/47/72cb04a58a35ec495f96984dddb48232b551aafb95bde614605b754fe6f7/lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799", size = 20874, upload-time = "2015-11-25T18:29:58.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa", size = 13564, upload-time = "2015-11-25T18:29:51.462Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "methodtools" +version = "0.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wirerope" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/3b/c21b74ac17befdf17b286494b02221b7a84affb1d410ff86e38ba0e14b13/methodtools-0.4.7.tar.gz", hash = "sha256:e213439dd64cfe60213f7015da6efe5dd4003fd89376db3baa09fe13ec2bb0ba", size = 3586, upload-time = "2023-02-05T13:17:54.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/4b/6497ffb463b1b75e04b348ef31070606d43e3c503fa295383538ded999c9/methodtools-0.4.7-py2.py3-none-any.whl", hash = "sha256:5e188c780b236adc12e75b5f078c5afb419ef99eb648569fc6d7071f053a1f11", size = 4038, upload-time = "2024-08-23T09:18:03.631Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "multidict" +version = "6.9.0" +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" } +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" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106", size = 6090, upload-time = "2026-07-16T15:25:34.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051", size = 6727, upload-time = "2026-07-16T15:25:14.445Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "ordered-set" +version = "4.1.0" +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" } +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" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathlib-abc" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342, upload-time = "2025-10-10T18:37:20.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070, upload-time = "2025-10-10T18:37:19.437Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +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 = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +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 = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +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 = "python-daemon" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lockfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/37/4f10e37bdabc058a32989da2daf29e57dc59dbc5395497f3d36d5f5e2694/python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4", size = 71576, upload-time = "2024-12-03T08:41:07.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/3c/b88167e2d6785c0e781ee5d498b07472aeb9b6765da3b19e7cc9e0813841/python_daemon-3.1.2-py3-none-any.whl", hash = "sha256:b906833cef63502994ad48e2eab213259ed9bb18d54fa8774dcba2ff7864cec6", size = 30872, upload-time = "2024-12-03T08:41:03.322Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-nvd3" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "python-slugify" }, +] +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" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/ca/5740963aa82f2c9dd025d71f1b6cce2159545f9e60dcb86a447d1cf92252/python_slugify-9.1.0.tar.gz", hash = "sha256:3f02e8a0639c61e37eca5a783aae09112796d15d331ee3d5372241b61ee05f1c", size = 63567, upload-time = "2026-09-18T22:30:42.72Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "rich-argparse" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/1064c43203a357d668cd42435f7a15fe6af51512d85b2104fecb937aa861/rich_argparse-1.8.0.tar.gz", hash = "sha256:679df3d832fa94ad6e4bdb07ded088cd7ea2dddc58ae9b2b46346a40b06cbc0c", size = 38940, upload-time = "2026-05-01T15:18:43.604Z" } +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 = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { 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 = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +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 = "sqlalchemy" +version = "1.4.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'" }, +] +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" } +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" }, +] + +[[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]] +name = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +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 = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +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 = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +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 = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + +[[package]] +name = "universal-pathlib" +version = "0.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec" }, + { name = "pathlib-abc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528, upload-time = "2026-02-22T14:40:57.316Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "werkzeug" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +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" } +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" }, +] + +[[package]] +name = "wirerope" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/f1/d0a4c936ba77eb1050da6ea5e7cd80aa36add343d9e5f7f9cf79a206c5b8/wirerope-1.0.0.tar.gz", hash = "sha256:7da8bb6feeff9dd939bd7141ef0dc392674e43ba662e20909d6729db81a7c8d0", size = 10930, upload-time = "2025-01-16T11:01:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/2d/3557ee32d8268b04ce8aada3212b0d49f2ddcf86dc200f3999a772262dc5/wirerope-1.0.0-py2.py3-none-any.whl", hash = "sha256:59346555c7b5dbd1c683a4e123f8bed30ca99df646f6867ea6439ceabf43c2f6", size = 9166, upload-time = "2025-01-16T11:01:23.507Z" }, +] + +[[package]] +name = "wrapt" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/a6/6375d56c44d590ef24acf0f8f5bf7ed768ff7a510b959306ec412611e90f/wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16", size = 164597, upload-time = "2026-09-10T23:12:16.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/1f/a2f3225c5ecf522684c1d051aea8ce8253f240e55be75826b787777afd6c/wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3", size = 98890, upload-time = "2026-09-10T23:10:07.876Z" }, + { url = "https://files.pythonhosted.org/packages/68/6c/eb45660fd4d92cce11ec923f55bb2e647a6c18d30e53734eb07a3c530e31/wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08", size = 98742, upload-time = "2026-09-10T23:10:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/17a89a580cb0082e61b8375074d5c9e5d38e4aa83ee19b6174ee472d17c2/wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9", size = 236301, upload-time = "2026-09-10T23:10:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/01/ca/4700eb008a34bf02de328806ddde15fc84c8d1e65d3dcafb92a935a50319/wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326", size = 237805, upload-time = "2026-09-10T23:10:12.594Z" }, + { url = "https://files.pythonhosted.org/packages/75/5d/26c1740299b29e190d5f4b4a99eb001401042a9d9ab338e3f8e1ce140ecb/wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2", size = 217037, upload-time = "2026-09-10T23:10:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/3a/55/ec72991153a2ae8b40238bc44cec7c3ddf7706ef6e2d314b0c6f5c7febce/wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b", size = 234659, upload-time = "2026-09-10T23:10:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/7cfa1e070dcda76ea56a3e252341cba1cd1e9412baf23cd7adfebf1114e2/wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c", size = 214590, upload-time = "2026-09-10T23:10:16.744Z" }, + { url = "https://files.pythonhosted.org/packages/79/10/248841cb30107f6f32c53a02662e1c3e0c7c06bea0b8ebfaaee94885dcee/wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735", size = 225103, upload-time = "2026-09-10T23:10:18.346Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/5b2bf7b35b008a39939a2908e85dba3b867596e535fc9f12ddf3ba1fcaf6/wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7", size = 93441, upload-time = "2026-09-10T23:10:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2a/47be56772bfb07ef242d6e924049688e38384af2b2fc99b0f31180988448/wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0", size = 98808, upload-time = "2026-09-10T23:10:21.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/40e4c9735626afd1dc0eeb310310278361f192800503cda0f5b3d8d24db4/wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88", size = 95240, upload-time = "2026-09-10T23:10:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" }, +] + +[[package]] +name = "wtforms" +version = "3.2.2" +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" } +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" }, +] From f23894086780e59fae4175b6d2ad3c1c88a94f62 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:21:10 +0200 Subject: [PATCH 191/205] =?UTF-8?q?fix(etl):=20borne=20la=20r=C3=A9ponse?= =?UTF-8?q?=20de=20l'API=20Mock=20avant=20=C3=A9criture=20en=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'API Mock est le seul item OWASP API10 du projet, et ce script en est le premier consommateur. Des quatre garde-fous exigés par la traçabilité OWASP, seul le timeout était en place. - plafonne la taille des réponses : MAX_SITES sites, au plus --limit mesures ; - borne chaque grandeur physique par PHYSICAL_BOUNDS, une valeur hors plage, d'un type inattendu, NaN ou infinie devenant NULL avec sa raison dans null_reasons et data_quality à degraded ; - ne recopie vers la base que les champs attendus, via build_site_row() et build_reading_row(), au lieu de passer les dictionnaires de l'API en paramètres SQL ; - écarte une data_quality que ck_reading_quality refuserait, plutôt que de faire échouer le lot entier ; - nomme la cible du ON CONFLICT, qui avalait jusqu'ici toute violation d'unicité, y compris celle de la clé primaire. raw_data conserve la réponse d'origine intacte : rien n'est perdu, seule son exploitation est bornée. --- apps/backend/app/etl/mock_api_import.py | 731 +++++---- .../backend/tests/etl/test_mock_api_import.py | 1456 ++++++++++------- docs/architecture/owasp-traceabilite.md | 3 +- 3 files changed, 1252 insertions(+), 938 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index f143f16..0d5d6be 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -1,315 +1,416 @@ -from __future__ import annotations - -import argparse -import asyncio -import json -from datetime import datetime -from typing import Any - -import httpx -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine - -from app.core.config import get_settings - -SOURCE_HISTORY = "api_history" - - -def create_mock_api_client() -> httpx.AsyncClient: - settings = get_settings() - - if settings.mock_api_username is None or settings.mock_api_password is None: - raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") - - return httpx.AsyncClient( - base_url=settings.mock_api_base_url.rstrip("/"), - auth=( - settings.mock_api_username, - settings.mock_api_password.get_secret_value(), - ), - timeout=settings.mock_api_timeout_seconds, - ) - - -async def fetch_sites( - client: httpx.AsyncClient, -) -> list[dict[str, Any]]: - response = await client.get("/api/v1/sites") - - response.raise_for_status() - - payload = response.json() - - if not isinstance(payload, list): - raise ValueError("La réponse /api/v1/sites doit être une liste.") - - return payload - - -async def upsert_sites( - connection: AsyncConnection, - sites: list[dict[str, Any]], -) -> None: - if not sites: - return - - await connection.execute( - text( - """ - INSERT INTO site ( - site_id, - site_type, - site_name, - location, - capacity_kw, - status - ) - VALUES ( - :site_id, - :site_type, - :site_name, - :location, - :capacity_kw, - :status - ) - ON CONFLICT (site_id) - DO UPDATE SET - site_type = EXCLUDED.site_type, - site_name = EXCLUDED.site_name, - location = EXCLUDED.location, - capacity_kw = EXCLUDED.capacity_kw, - status = EXCLUDED.status - """ - ), - sites, - ) - - -async def fetch_readings( - client: httpx.AsyncClient, - site_id: str, - start_time: datetime, - end_time: datetime, - limit: int = 1000, -) -> list[dict[str, Any]]: - response = await client.get( - "/api/v1/readings", - params={ - "site_id": site_id, - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat(), - "limit": limit, - }, - ) - - response.raise_for_status() - - payload = response.json() - - if not isinstance(payload, list): - raise ValueError("La réponse /api/v1/readings doit être une liste.") - - return payload - - -def build_reading_row( - reading: dict[str, Any], -) -> dict[str, Any]: - timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00")) - return { - "site_id": reading["site_id"], - "timestamp": timestamp, - "source": SOURCE_HISTORY, - "dataset_id": None, - "consumption_kw": reading.get("consumption_kw"), - "consumption_kwh": reading.get("consumption_kwh"), - "consumption_euros": None, - "voltage_v": reading.get("voltage_v"), - "current_a": reading.get("current_a"), - "power_factor": reading.get("power_factor"), - "temperature_celsius": reading.get("temperature_celsius"), - "humidity_percent": reading.get("humidity_percent"), - "solar_irradiance_wm2": None, - "is_working_hours": None, - "data_quality": reading.get("data_quality"), - "null_reasons": reading.get("null_reasons"), - "imputed_values": None, - "imputation_method": None, - "raw_data": json.dumps( - reading, - ensure_ascii=False, - ), - } - - -READING_INSERT = text( - """ - INSERT INTO reading ( - site_id, - timestamp, - source, - dataset_id, - consumption_kw, - consumption_kwh, - consumption_euros, - voltage_v, - current_a, - power_factor, - temperature_celsius, - humidity_percent, - solar_irradiance_wm2, - is_working_hours, - data_quality, - null_reasons, - imputed_values, - imputation_method, - raw_data - ) - VALUES ( - :site_id, - :timestamp, - :source, - :dataset_id, - :consumption_kw, - :consumption_kwh, - :consumption_euros, - :voltage_v, - :current_a, - :power_factor, - :temperature_celsius, - :humidity_percent, - :solar_irradiance_wm2, - :is_working_hours, - :data_quality, - :null_reasons, - CAST(:imputed_values AS jsonb), - :imputation_method, - CAST(:raw_data AS jsonb) - ) - ON CONFLICT DO NOTHING - """ -) - - -def build_reading_batch( - readings: list[dict[str, Any]], -) -> list[dict[str, Any]]: - return [build_reading_row(reading) for reading in readings] - - -async def import_mock_api_history( - start_time: datetime, - end_time: datetime, - limit: int, - dry_run: bool, -) -> None: - settings = get_settings() - - async with create_mock_api_client() as client: - sites = await fetch_sites(client) - - print(f"Sites récupérés : {len(sites)}") - - all_readings: list[dict[str, Any]] = [] - - for site in sites: - site_id = site["site_id"] - - readings = await fetch_readings( - client=client, - site_id=site_id, - start_time=start_time, - end_time=end_time, - limit=limit, - ) - - print(f"{site_id}: {len(readings)} lectures") - - all_readings.extend(readings) - - print(f"Lectures récupérées : {len(all_readings)}") - - if dry_run: - print("Dry-run terminé : aucune donnée écrite.") - return - - engine = create_async_engine( - str(settings.database_url), - pool_pre_ping=True, - ) - - try: - async with engine.begin() as connection: - await upsert_sites( - connection, - sites, - ) - - rows = build_reading_batch(all_readings) - - if rows: - await connection.execute( - READING_INSERT, - rows, - ) - - finally: - await engine.dispose() - - print("Import API Mock terminé.") - - -def parse_datetime(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision")) - - parser.add_argument( - "--start-time", - required=True, - type=parse_datetime, - ) - - parser.add_argument( - "--end-time", - required=True, - type=parse_datetime, - ) - - parser.add_argument( - "--limit", - type=int, - default=1000, - ) - - parser.add_argument( - "--dry-run", - action="store_true", - ) - - return parser.parse_args() - - -def main() -> None: - args = parse_args() - - if args.limit < 1 or args.limit > 1000: - raise ValueError("--limit doit être compris entre 1 et 1000.") - - if args.start_time >= args.end_time: - raise ValueError("--start-time doit être antérieur à --end-time.") - - asyncio.run( - import_mock_api_history( - start_time=args.start_time, - end_time=args.end_time, - limit=args.limit, - dry_run=args.dry_run, - ) - ) - - -if __name__ == "__main__": - main() +# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance. +# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie +# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs +# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille +# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL +# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par +# construction, et raw_data conserve de toute façon la réponse d'origine intacte. + +from __future__ import annotations + +import argparse +import asyncio +import json +from datetime import datetime +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings + +SOURCE_HISTORY = "api_history" + +MAX_SITES = 100 + +MAX_LIMIT = 1000 + +# Les quatre seules valeurs que la contrainte ck_reading_quality accepte. +ACCEPTED_QUALITIES = frozenset({"good", "partial", "degraded", "critical"}) + +PHYSICAL_BOUNDS: dict[str, tuple[float, float]] = { + "consumption_kw": (0.0, 100_000.0), + "consumption_kwh": (0.0, 100_000.0), + "voltage_v": (0.0, 1_000.0), + "current_a": (0.0, 10_000.0), + "power_factor": (0.0, 1.0), + "temperature_celsius": (-90.0, 60.0), + "humidity_percent": (0.0, 100.0), +} + +CAPACITY_BOUNDS = (0.0, 100_000.0) + + +def create_mock_api_client() -> httpx.AsyncClient: + settings = get_settings() + + if settings.mock_api_username is None or settings.mock_api_password is None: + raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") + + return httpx.AsyncClient( + base_url=settings.mock_api_base_url.rstrip("/"), + auth=( + settings.mock_api_username, + settings.mock_api_password.get_secret_value(), + ), + timeout=settings.mock_api_timeout_seconds, + ) + + +def read_text(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + + if not isinstance(value, str) or not value: + raise ValueError(f"Champ {key} absent ou invalide dans la réponse de l'API Mock.") + + return value + + +def optional_text(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def coerce_measure( + value: Any, + bounds: tuple[float, float], +) -> float | None: + if isinstance(value, bool) or not isinstance(value, int | float): + return None + + lower, upper = bounds + + # Écarte aussi NaN et les infinis, qu'aucune comparaison de bornes ne retient. + return float(value) if lower <= value <= upper else None + + +def resolve_quality( + value: Any, + rejected: list[str], +) -> str | None: + quality = value if isinstance(value, str) and value in ACCEPTED_QUALITIES else None + + if rejected: + return "critical" if quality == "critical" else "degraded" + + return quality + + +def resolve_null_reasons( + value: Any, + rejected: list[str], +) -> list[str]: + reported = [str(reason) for reason in value] if isinstance(value, list) else [] + + return reported + rejected + + +async def fetch_sites( + client: httpx.AsyncClient, +) -> list[dict[str, Any]]: + response = await client.get("/api/v1/sites") + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/sites doit être une liste.") + + if len(payload) > MAX_SITES: + raise ValueError(f"La réponse /api/v1/sites dépasse le plafond de {MAX_SITES} sites.") + + return payload + + +def build_site_row( + site: dict[str, Any], +) -> dict[str, Any]: + return { + "site_id": read_text(site, "site_id"), + "site_type": read_text(site, "site_type"), + "site_name": read_text(site, "site_name"), + "location": optional_text(site.get("location")), + "capacity_kw": coerce_measure(site.get("capacity_kw"), CAPACITY_BOUNDS), + "status": optional_text(site.get("status")), + } + + +async def upsert_sites( + connection: AsyncConnection, + sites: list[dict[str, Any]], +) -> None: + rows = [build_site_row(site) for site in sites] + + if not rows: + return + + await connection.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name, + location, + capacity_kw, + status + ) + VALUES ( + :site_id, + :site_type, + :site_name, + :location, + :capacity_kw, + :status + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name, + location = EXCLUDED.location, + capacity_kw = EXCLUDED.capacity_kw, + status = EXCLUDED.status + """ + ), + rows, + ) + + +async def fetch_readings( + client: httpx.AsyncClient, + site_id: str, + start_time: datetime, + end_time: datetime, + limit: int = MAX_LIMIT, +) -> list[dict[str, Any]]: + response = await client.get( + "/api/v1/readings", + params={ + "site_id": site_id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "limit": limit, + }, + ) + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/readings doit être une liste.") + + if len(payload) > limit: + raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.") + + return payload + + +def build_reading_row( + reading: dict[str, Any], +) -> dict[str, Any]: + measures: dict[str, float | None] = {} + rejected: list[str] = [] + + for name, bounds in PHYSICAL_BOUNDS.items(): + received = reading.get(name) + measures[name] = coerce_measure(received, bounds) + + if received is not None and measures[name] is None: + rejected.append(f"out_of_physical_bounds:{name}") + + return { + "site_id": read_text(reading, "site_id"), + "timestamp": parse_datetime(read_text(reading, "timestamp")), + "source": SOURCE_HISTORY, + "dataset_id": None, + **measures, + "consumption_euros": None, + "solar_irradiance_wm2": None, + "is_working_hours": None, + "data_quality": resolve_quality(reading.get("data_quality"), rejected), + "null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected), + "imputed_values": None, + "imputation_method": None, + "raw_data": json.dumps( + reading, + ensure_ascii=False, + ), + } + + +# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible +# nommée, DO NOTHING avalerait aussi une violation de clé primaire. +READING_INSERT = text( + """ + INSERT INTO reading ( + site_id, + timestamp, + source, + dataset_id, + consumption_kw, + consumption_kwh, + consumption_euros, + voltage_v, + current_a, + power_factor, + temperature_celsius, + humidity_percent, + solar_irradiance_wm2, + is_working_hours, + data_quality, + null_reasons, + imputed_values, + imputation_method, + raw_data + ) + VALUES ( + :site_id, + :timestamp, + :source, + :dataset_id, + :consumption_kw, + :consumption_kwh, + :consumption_euros, + :voltage_v, + :current_a, + :power_factor, + :temperature_celsius, + :humidity_percent, + :solar_irradiance_wm2, + :is_working_hours, + :data_quality, + :null_reasons, + CAST(:imputed_values AS jsonb), + :imputation_method, + CAST(:raw_data AS jsonb) + ) + ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0))) + DO NOTHING + """ +) + + +def build_reading_batch( + readings: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [build_reading_row(reading) for reading in readings] + + +async def import_mock_api_history( + start_time: datetime, + end_time: datetime, + limit: int, + dry_run: bool, +) -> None: + settings = get_settings() + + async with create_mock_api_client() as client: + sites = await fetch_sites(client) + + print(f"Sites récupérés : {len(sites)}") + + all_readings: list[dict[str, Any]] = [] + + for site in sites: + site_id = read_text(site, "site_id") + + readings = await fetch_readings( + client=client, + site_id=site_id, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + + print(f"{site_id}: {len(readings)} lectures") + + all_readings.extend(readings) + + print(f"Lectures récupérées : {len(all_readings)}") + + if dry_run: + print("Dry-run terminé : aucune donnée écrite.") + return + + engine = create_async_engine( + str(settings.database_url), + pool_pre_ping=True, + ) + + try: + async with engine.begin() as connection: + await upsert_sites( + connection, + sites, + ) + + rows = build_reading_batch(all_readings) + + if rows: + await connection.execute( + READING_INSERT, + rows, + ) + + finally: + await engine.dispose() + + print("Import API Mock terminé.") + + +def parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision")) + + parser.add_argument( + "--start-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--end-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--limit", + type=int, + default=MAX_LIMIT, + ) + + parser.add_argument( + "--dry-run", + action="store_true", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if args.limit < 1 or args.limit > MAX_LIMIT: + raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.") + + if args.start_time >= args.end_time: + raise ValueError("--start-time doit être antérieur à --end-time.") + + asyncio.run( + import_mock_api_history( + start_time=args.start_time, + end_time=args.end_time, + limit=args.limit, + dry_run=args.dry_run, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 36ad2d8..cdcff55 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -1,622 +1,834 @@ -import json -import sys -from datetime import datetime -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest -from httpx import AsyncClient, MockTransport, Request, Response -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -import app.etl.mock_api_import as mock_api_import -from app.etl.mock_api_import import ( - READING_INSERT, - SOURCE_HISTORY, - build_reading_batch, - build_reading_row, - fetch_readings, - fetch_sites, - upsert_sites, -) - - -def make_site() -> dict[str, Any]: - return { - "site_id": "SITE001", - "site_type": "office", - "site_name": "Bureau Paris La Défense", - "location": "Paris, France", - "capacity_kw": 200, - "status": "active", - } - - -def make_reading() -> dict[str, Any]: - return { - "timestamp": "2024-06-15T12:00:00Z", - "site_id": "SITE001", - "site_type": "office", - "consumption_kw": 87.34, - "consumption_kwh": 87.34, - "voltage_v": 401.2, - "current_a": 132.5, - "power_factor": 0.923, - "temperature_celsius": 22.1, - "humidity_percent": 58.4, - "null_reasons": [], - "data_quality": "good", - } - - -async def test_fetch_sites_returns_sites() -> None: - def handler(request: Request) -> Response: - assert request.url.path == "/api/v1/sites" - - return Response( - status_code=200, - json=[make_site()], - ) - - transport = MockTransport(handler) - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - sites = await fetch_sites(client) - - assert len(sites) == 1 - assert sites[0]["site_id"] == "SITE001" - assert sites[0]["site_type"] == "office" - - -async def test_fetch_readings_sends_expected_query_parameters() -> None: - captured_params: dict[str, str] = {} - - def handler(request: Request) -> Response: - nonlocal captured_params - - captured_params = dict(request.url.params) - - return Response( - status_code=200, - json=[make_reading()], - ) - - transport = MockTransport(handler) - - start_time = datetime.fromisoformat("2024-06-15T12:00:00") - end_time = datetime.fromisoformat("2024-06-15T13:00:00") - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - readings = await fetch_readings( - client=client, - site_id="SITE001", - start_time=start_time, - end_time=end_time, - limit=60, - ) - - assert len(readings) == 1 - assert captured_params["site_id"] == "SITE001" - assert captured_params["start_time"] == "2024-06-15T12:00:00" - assert captured_params["end_time"] == "2024-06-15T13:00:00" - assert captured_params["limit"] == "60" - - -async def test_fetch_readings_rejects_non_list_response() -> None: - def handler(request: Request) -> Response: - return Response( - status_code=200, - json={"unexpected": "payload"}, - ) - - transport = MockTransport(handler) - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - with pytest.raises( - ValueError, - match="La réponse /api/v1/readings doit être une liste", - ): - await fetch_readings( - client=client, - site_id="SITE001", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - ) - - -async def test_fetch_readings_raises_on_http_error() -> None: - def handler(request: Request) -> Response: - return Response( - status_code=404, - json={"detail": "Site non trouvé"}, - ) - - transport = MockTransport(handler) - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - with pytest.raises(httpx.HTTPStatusError): - await fetch_readings( - client=client, - site_id="SITE999", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - ) - - -def test_build_reading_row_respects_database_contract() -> None: - reading = make_reading() - - row = build_reading_row(reading) - - assert row["site_id"] == "SITE001" - assert row["source"] == SOURCE_HISTORY - assert row["source"] == "api_history" - assert row["dataset_id"] is None - - assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") - - assert row["consumption_kw"] == 87.34 - assert row["consumption_kwh"] == 87.34 - assert row["data_quality"] == "good" - assert row["null_reasons"] == [] - - assert row["imputed_values"] is None - assert row["imputation_method"] is None - - -def test_build_reading_row_keeps_null_values_and_quality() -> None: - reading = make_reading() - - reading["consumption_kw"] = None - reading["consumption_kwh"] = None - reading["voltage_v"] = None - reading["current_a"] = None - reading["power_factor"] = None - reading["data_quality"] = "degraded" - reading["null_reasons"] = [ - "consumption_sensor_failure", - "electrical_sensor_failure", - ] - - row = build_reading_row(reading) - - assert row["consumption_kw"] is None - assert row["consumption_kwh"] is None - assert row["voltage_v"] is None - assert row["current_a"] is None - assert row["power_factor"] is None - - assert row["data_quality"] == "degraded" - assert row["null_reasons"] == [ - "consumption_sensor_failure", - "electrical_sensor_failure", - ] - - assert row["imputed_values"] is None - assert row["imputation_method"] is None - - -def test_build_reading_row_keeps_raw_source_data() -> None: - reading = make_reading() - - row = build_reading_row(reading) - - raw_data = json.loads(row["raw_data"]) - - assert raw_data == reading - - -def test_build_reading_batch_transforms_all_readings() -> None: - first = make_reading() - - second = make_reading() - second["timestamp"] = "2024-06-15T12:01:00Z" - second["consumption_kw"] = 90.5 - - rows = build_reading_batch([first, second]) - - assert len(rows) == 2 - - assert rows[0]["site_id"] == "SITE001" - assert rows[0]["consumption_kw"] == 87.34 - - assert rows[1]["site_id"] == "SITE001" - assert rows[1]["consumption_kw"] == 90.5 - - -def test_create_mock_api_client_requires_credentials( - monkeypatch: pytest.MonkeyPatch, -) -> None: - settings = SimpleNamespace( - mock_api_username=None, - mock_api_password=None, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: settings, - ) - - with pytest.raises( - ValueError, - match="Les identifiants de l'API Mock ne sont pas configurés", - ): - mock_api_import.create_mock_api_client() - - -async def test_create_mock_api_client_uses_configuration( - monkeypatch: pytest.MonkeyPatch, -) -> None: - password = MagicMock() - password.get_secret_value.return_value = "test-password" - - settings = SimpleNamespace( - mock_api_base_url="https://mock.test/", - mock_api_username="test-user", - mock_api_password=password, - mock_api_timeout_seconds=10.0, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: settings, - ) - - client = mock_api_import.create_mock_api_client() - - try: - assert str(client.base_url) == "https://mock.test" - assert client.timeout.connect == 10.0 - finally: - await client.aclose() - - -async def test_upsert_sites_with_empty_list_does_nothing() -> None: - connection = AsyncMock() - - await upsert_sites( - connection, - [], - ) - - connection.execute.assert_not_awaited() - - -async def test_import_mock_api_history_dry_run_does_not_write( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def handler(request: Request) -> Response: - if request.url.path == "/api/v1/sites": - return Response( - status_code=200, - json=[make_site()], - ) - - if request.url.path == "/api/v1/readings": - return Response( - status_code=200, - json=[make_reading()], - ) - - return Response(status_code=404) - - transport = MockTransport(handler) - - client = AsyncClient( - transport=transport, - base_url="https://mock.test", - ) - - monkeypatch.setattr( - mock_api_import, - "create_mock_api_client", - lambda: client, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: SimpleNamespace( - database_url="postgresql+asyncpg://unused", - ), - ) - - create_engine_mock = MagicMock() - - monkeypatch.setattr( - mock_api_import, - "create_async_engine", - create_engine_mock, - ) - - await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - dry_run=True, - ) - - create_engine_mock.assert_not_called() - - -async def test_import_mock_api_history_loads_data( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def handler(request: Request) -> Response: - if request.url.path == "/api/v1/sites": - return Response( - status_code=200, - json=[make_site()], - ) - - if request.url.path == "/api/v1/readings": - return Response( - status_code=200, - json=[make_reading()], - ) - - return Response(status_code=404) - - transport = MockTransport(handler) - - client = AsyncClient( - transport=transport, - base_url="https://mock.test", - ) - - monkeypatch.setattr( - mock_api_import, - "create_mock_api_client", - lambda: client, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: SimpleNamespace( - database_url="postgresql+asyncpg://test:test@localhost/test", - ), - ) - - connection = AsyncMock() - - transaction_context = MagicMock() - transaction_context.__aenter__ = AsyncMock( - return_value=connection, - ) - transaction_context.__aexit__ = AsyncMock( - return_value=None, - ) - - engine = MagicMock() - engine.begin.return_value = transaction_context - engine.dispose = AsyncMock() - - create_engine_mock = MagicMock( - return_value=engine, - ) - - upsert_sites_mock = AsyncMock() - - monkeypatch.setattr( - mock_api_import, - "create_async_engine", - create_engine_mock, - ) - - monkeypatch.setattr( - mock_api_import, - "upsert_sites", - upsert_sites_mock, - ) - - await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - dry_run=False, - ) - - create_engine_mock.assert_called_once_with( - "postgresql+asyncpg://test:test@localhost/test", - pool_pre_ping=True, - ) - - upsert_sites_mock.assert_awaited_once_with( - connection, - [make_site()], - ) - - connection.execute.assert_awaited_once() - engine.dispose.assert_awaited_once() - - -def test_parse_datetime_accepts_z_suffix() -> None: - result = mock_api_import.parse_datetime( - "2024-06-15T12:00:00Z", - ) - - assert result == datetime.fromisoformat( - "2024-06-15T12:00:00+00:00", - ) - - -def test_parse_args_reads_cli_parameters( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T12:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "60", - "--dry-run", - ], - ) - - args = mock_api_import.parse_args() - - assert args.start_time == datetime.fromisoformat( - "2024-06-15T12:00:00+00:00", - ) - assert args.end_time == datetime.fromisoformat( - "2024-06-15T13:00:00+00:00", - ) - assert args.limit == 60 - assert args.dry_run is True - - -def test_main_rejects_limit_out_of_bounds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T12:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "0", - ], - ) - - with pytest.raises( - ValueError, - match="--limit doit être compris entre 1 et 1000", - ): - mock_api_import.main() - - -def test_main_rejects_invalid_period( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T14:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "60", - ], - ) - - with pytest.raises( - ValueError, - match="--start-time doit être antérieur à --end-time", - ): - mock_api_import.main() - - -def test_main_runs_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - start_time = datetime.fromisoformat( - "2024-06-15T12:00:00+00:00", - ) - end_time = datetime.fromisoformat( - "2024-06-15T13:00:00+00:00", - ) - - import_mock = AsyncMock() - - monkeypatch.setattr( - mock_api_import, - "parse_args", - lambda: SimpleNamespace( - start_time=start_time, - end_time=end_time, - limit=60, - dry_run=True, - ), - ) - - monkeypatch.setattr( - mock_api_import, - "import_mock_api_history", - import_mock, - ) - - mock_api_import.main() - - import_mock.assert_awaited_once_with( - start_time=start_time, - end_time=end_time, - limit=60, - dry_run=True, - ) - - -@pytest.mark.integration -async def test_reading_insert_is_idempotent( - session: AsyncSession, -) -> None: - reading = make_reading() - row = build_reading_row(reading) - - connection = await session.connection() - - await upsert_sites( - connection, - [make_site()], - ) - - await session.execute( - READING_INSERT, - [row], - ) - - await session.execute( - READING_INSERT, - [row], - ) - - result = await session.execute( - text( - """ - SELECT COUNT(*) - FROM reading - WHERE site_id = :site_id - AND timestamp = :timestamp - AND source = :source - """ - ), - { - "site_id": row["site_id"], - "timestamp": row["timestamp"], - "source": row["source"], - }, - ) - - assert result.scalar_one() == 1 - - await session.rollback() +import json +import sys +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from httpx import AsyncClient, MockTransport, Request, Response +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +import app.etl.mock_api_import as mock_api_import +from app.etl.mock_api_import import ( + MAX_SITES, + READING_INSERT, + SOURCE_HISTORY, + build_reading_batch, + build_reading_row, + build_site_row, + fetch_readings, + fetch_sites, + upsert_sites, +) + + +def make_site() -> dict[str, Any]: + return { + "site_id": "SITE001", + "site_type": "office", + "site_name": "Bureau Paris La Défense", + "location": "Paris, France", + "capacity_kw": 200, + "status": "active", + } + + +def make_reading() -> dict[str, Any]: + return { + "timestamp": "2024-06-15T12:00:00Z", + "site_id": "SITE001", + "site_type": "office", + "consumption_kw": 87.34, + "consumption_kwh": 87.34, + "voltage_v": 401.2, + "current_a": 132.5, + "power_factor": 0.923, + "temperature_celsius": 22.1, + "humidity_percent": 58.4, + "null_reasons": [], + "data_quality": "good", + } + + +async def test_fetch_sites_returns_sites() -> None: + def handler(request: Request) -> Response: + assert request.url.path == "/api/v1/sites" + + return Response( + status_code=200, + json=[make_site()], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + sites = await fetch_sites(client) + + assert len(sites) == 1 + assert sites[0]["site_id"] == "SITE001" + assert sites[0]["site_type"] == "office" + + +async def test_fetch_sites_rejects_non_list_response() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json={"unexpected": "payload"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="La réponse /api/v1/sites doit être une liste", + ): + await fetch_sites(client) + + +async def test_fetch_readings_sends_expected_query_parameters() -> None: + captured_params: dict[str, str] = {} + + def handler(request: Request) -> Response: + nonlocal captured_params + + captured_params = dict(request.url.params) + + return Response( + status_code=200, + json=[make_reading()], + ) + + transport = MockTransport(handler) + + start_time = datetime.fromisoformat("2024-06-15T12:00:00") + end_time = datetime.fromisoformat("2024-06-15T13:00:00") + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + readings = await fetch_readings( + client=client, + site_id="SITE001", + start_time=start_time, + end_time=end_time, + limit=60, + ) + + assert len(readings) == 1 + assert captured_params["site_id"] == "SITE001" + assert captured_params["start_time"] == "2024-06-15T12:00:00" + assert captured_params["end_time"] == "2024-06-15T13:00:00" + assert captured_params["limit"] == "60" + + +async def test_fetch_readings_rejects_non_list_response() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json={"unexpected": "payload"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="La réponse /api/v1/readings doit être une liste", + ): + await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +async def test_fetch_readings_raises_on_http_error() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=404, + json={"detail": "Site non trouvé"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises(httpx.HTTPStatusError): + await fetch_readings( + client=client, + site_id="SITE999", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +def test_build_reading_row_respects_database_contract() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + assert row["site_id"] == "SITE001" + assert row["source"] == SOURCE_HISTORY + assert row["source"] == "api_history" + assert row["dataset_id"] is None + + assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + + assert row["consumption_kw"] == 87.34 + assert row["consumption_kwh"] == 87.34 + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_null_values_and_quality() -> None: + reading = make_reading() + + reading["consumption_kw"] = None + reading["consumption_kwh"] = None + reading["voltage_v"] = None + reading["current_a"] = None + reading["power_factor"] = None + reading["data_quality"] = "degraded" + reading["null_reasons"] = [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + row = build_reading_row(reading) + + assert row["consumption_kw"] is None + assert row["consumption_kwh"] is None + assert row["voltage_v"] is None + assert row["current_a"] is None + assert row["power_factor"] is None + + assert row["data_quality"] == "degraded" + assert row["null_reasons"] == [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_raw_source_data() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + raw_data = json.loads(row["raw_data"]) + + assert raw_data == reading + + +def test_build_reading_batch_transforms_all_readings() -> None: + first = make_reading() + + second = make_reading() + second["timestamp"] = "2024-06-15T12:01:00Z" + second["consumption_kw"] = 90.5 + + rows = build_reading_batch([first, second]) + + assert len(rows) == 2 + + assert rows[0]["site_id"] == "SITE001" + assert rows[0]["consumption_kw"] == 87.34 + + assert rows[1]["site_id"] == "SITE001" + assert rows[1]["consumption_kw"] == 90.5 + + +def test_create_mock_api_client_requires_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = SimpleNamespace( + mock_api_username=None, + mock_api_password=None, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + with pytest.raises( + ValueError, + match="Les identifiants de l'API Mock ne sont pas configurés", + ): + mock_api_import.create_mock_api_client() + + +async def test_create_mock_api_client_uses_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + password = MagicMock() + password.get_secret_value.return_value = "test-password" + + settings = SimpleNamespace( + mock_api_base_url="https://mock.test/", + mock_api_username="test-user", + mock_api_password=password, + mock_api_timeout_seconds=10.0, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + client = mock_api_import.create_mock_api_client() + + try: + assert str(client.base_url) == "https://mock.test" + assert client.timeout.connect == 10.0 + finally: + await client.aclose() + + +async def test_upsert_sites_with_empty_list_does_nothing() -> None: + connection = AsyncMock() + + await upsert_sites( + connection, + [], + ) + + connection.execute.assert_not_awaited() + + +async def test_import_mock_api_history_dry_run_does_not_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://unused", + ), + ) + + create_engine_mock = MagicMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=True, + ) + + create_engine_mock.assert_not_called() + + +async def test_import_mock_api_history_loads_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://test:test@localhost/test", + ), + ) + + connection = AsyncMock() + + transaction_context = MagicMock() + transaction_context.__aenter__ = AsyncMock( + return_value=connection, + ) + transaction_context.__aexit__ = AsyncMock( + return_value=None, + ) + + engine = MagicMock() + engine.begin.return_value = transaction_context + engine.dispose = AsyncMock() + + create_engine_mock = MagicMock( + return_value=engine, + ) + + upsert_sites_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + monkeypatch.setattr( + mock_api_import, + "upsert_sites", + upsert_sites_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=False, + ) + + create_engine_mock.assert_called_once_with( + "postgresql+asyncpg://test:test@localhost/test", + pool_pre_ping=True, + ) + + upsert_sites_mock.assert_awaited_once_with( + connection, + [make_site()], + ) + + connection.execute.assert_awaited_once() + engine.dispose.assert_awaited_once() + + +def test_parse_datetime_accepts_z_suffix() -> None: + result = mock_api_import.parse_datetime( + "2024-06-15T12:00:00Z", + ) + + assert result == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + + +def test_parse_args_reads_cli_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + "--dry-run", + ], + ) + + args = mock_api_import.parse_args() + + assert args.start_time == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + assert args.end_time == datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + assert args.limit == 60 + assert args.dry_run is True + + +def test_main_rejects_limit_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "0", + ], + ) + + with pytest.raises( + ValueError, + match="--limit doit être compris entre 1 et 1000", + ): + mock_api_import.main() + + +def test_main_rejects_invalid_period( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T14:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + ], + ) + + with pytest.raises( + ValueError, + match="--start-time doit être antérieur à --end-time", + ): + mock_api_import.main() + + +def test_main_runs_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + start_time = datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + end_time = datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + + import_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "parse_args", + lambda: SimpleNamespace( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ), + ) + + monkeypatch.setattr( + mock_api_import, + "import_mock_api_history", + import_mock, + ) + + mock_api_import.main() + + import_mock.assert_awaited_once_with( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ) + + +async def test_fetch_sites_rejects_a_response_above_the_cap() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json=[make_site() for _ in range(MAX_SITES + 1)], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match=f"dépasse le plafond de {MAX_SITES} sites", + ): + await fetch_sites(client) + + +async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json=[make_reading(), make_reading(), make_reading()], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="dépasse la limite demandée de 2", + ): + await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=2, + ) + + +def test_build_reading_row_neutralises_values_outside_physical_bounds() -> None: + reading = make_reading() + + reading["power_factor"] = 42.0 + reading["temperature_celsius"] = 1e30 + reading["humidity_percent"] = -1.0 + + row = build_reading_row(reading) + + assert row["power_factor"] is None + assert row["temperature_celsius"] is None + assert row["humidity_percent"] is None + + assert row["null_reasons"] == [ + "out_of_physical_bounds:power_factor", + "out_of_physical_bounds:temperature_celsius", + "out_of_physical_bounds:humidity_percent", + ] + + assert row["data_quality"] == "degraded" + + assert json.loads(row["raw_data"])["power_factor"] == 42.0 + + +def test_build_reading_row_rejects_a_measure_that_is_not_a_number() -> None: + reading = make_reading() + + reading["consumption_kw"] = "87.34" + + row = build_reading_row(reading) + + assert row["consumption_kw"] is None + assert "out_of_physical_bounds:consumption_kw" in row["null_reasons"] + + +def test_build_reading_row_drops_a_quality_the_database_refuses() -> None: + reading = make_reading() + + reading["data_quality"] = "unknown" + + row = build_reading_row(reading) + + assert row["data_quality"] is None + + +def test_build_reading_row_requires_an_identifier() -> None: + reading = make_reading() + + del reading["site_id"] + + with pytest.raises( + ValueError, + match="Champ site_id absent ou invalide", + ): + build_reading_row(reading) + + +def test_build_site_row_keeps_only_the_expected_columns() -> None: + site = make_site() + + site["unexpected"] = "valeur hostile" + site["capacity_kw"] = -5.0 + site["status"] = 12 + + row = build_site_row(site) + + assert set(row) == { + "site_id", + "site_type", + "site_name", + "location", + "capacity_kw", + "status", + } + + assert row["capacity_kw"] is None + assert row["status"] is None + + +async def test_upsert_sites_sends_only_the_expected_columns() -> None: + connection = AsyncMock() + + site = make_site() + site["unexpected"] = "valeur hostile" + + await upsert_sites( + connection, + [site], + ) + + rows = connection.execute.await_args.args[1] + + assert "unexpected" not in rows[0] + assert rows[0]["site_id"] == "SITE001" + + +@pytest.mark.integration +async def test_reading_insert_is_idempotent( + session: AsyncSession, +) -> None: + reading = make_reading() + row = build_reading_row(reading) + + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + result = await session.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE site_id = :site_id + AND timestamp = :timestamp + AND source = :source + """ + ), + { + "site_id": row["site_id"], + "timestamp": row["timestamp"], + "source": row["source"], + }, + ) + + assert result.scalar_one() == 1 + + await session.rollback() + + +@pytest.mark.integration +async def test_out_of_bounds_reading_is_stored_neutralised( + session: AsyncSession, +) -> None: + reading = make_reading() + reading["power_factor"] = 42.0 + + row = build_reading_row(reading) + + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + result = await session.execute( + text( + """ + SELECT power_factor, data_quality, null_reasons, raw_data ->> 'power_factor' + FROM reading + WHERE site_id = :site_id + AND timestamp = :timestamp + AND source = :source + """ + ), + { + "site_id": row["site_id"], + "timestamp": row["timestamp"], + "source": row["source"], + }, + ) + + stored = result.one() + + await session.rollback() + + assert stored[0] is None + assert stored[1] == "degraded" + assert stored[2] == ["out_of_physical_bounds:power_factor"] + assert stored[3] == "42.0" diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 34e6853..7e9c19a 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -41,6 +41,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints | En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | | Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | | Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | +| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs | | CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des @@ -53,7 +54,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. | | **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | -| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | +| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | | **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | | **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | | **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. | From de697b080d0ba7a90511b0f3c154b9b5d9ddd897 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:21:18 +0200 Subject: [PATCH 192/205] =?UTF-8?q?docs:=20r=C3=A9tablit=20la=20hi=C3=A9ra?= =?UTF-8?q?rchie=20des=20titres=20et=20les=20motifs=20du=20document=20Data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40-data.md était passé à quatre titres de niveau 1 et etl/README.md à cinq, alors que les huit autres documents d'architecture n'en ont qu'un. Les sections ajoutées redescendent d'un niveau. La réécriture de la section « Tables d'authentification » avait aussi vidé quatre choix de modélisation de leur raison, dont le renvoi à l'ADR 0004 sur audit_log.actor_id. Ces motifs sont rétablis, et les deux tables de réinitialisation reçoivent le leur. Documente enfin la frontière de confiance avec l'API Mock : les quatre garde-fous, les plages de PHYSICAL_BOUNDS, et ce qu'il reste à faire. --- docs/architecture/40-data.md | 83 +++++++++++++++++++++++---------- etl/README.md | 90 +++++++++++++++++++++++++----------- 2 files changed, 122 insertions(+), 51 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 65a4d62..40a98e6 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -96,8 +96,8 @@ Les flèches pleines représentent les traitements actuellement implémentés. Les flèches pointillées représentent les éléments encore prévus comme cibles. -Les lectures futures de l'API et de Grafana visent l'agrégat continu plutôt que la table brute -lorsque cette partie TimescaleDB sera mise en place. +Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout +l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. ## Tables d'authentification @@ -170,21 +170,28 @@ erDiagram } ``` -Plusieurs choix de modélisation portent une intention précise : +Six choix de modélisation portent une intention et se défendent seuls : - **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de - `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif. -- **`credentials_changed_at`, une seule colonne**, couvre notamment le changement de mot de passe, - le changement de rôle et la désactivation. -- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. -- **`audit_log.actor_id` n'a aucune clé étrangère** afin de conserver les informations d'audit - même si l'entité d'origine évolue. -- `password_reset_token` ne stocke que l'empreinte du jeton et jamais sa valeur directement. -- `password_reset_attempt` est séparée de `audit_log`, car son volume peut être piloté - par des demandes externes répétées. + `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition + au rôle PostgreSQL qui portera le cantonnement de l'ETL. +- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le + changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui + lit un audit. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il + glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. +- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont + dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur + d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). +- **`password_reset_token` ne stocke que l'empreinte du jeton**, jamais sa valeur. Une fuite de + la table ne donne donc rien à rejouer. +- **`password_reset_attempt` est séparée de `audit_log`** : son volume est piloté par le + demandeur, comme celui de `login_attempt`, donc elle doit pouvoir se purger. -`audit_log` porte des déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. -Elle n'est donc **pas** une hypertable. +`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est +donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils +refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est +piloté par l'attaquant. ## Gabarit de révision créant une hypertable @@ -234,7 +241,8 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà. ## Questions ouvertes -Elles portent maintenant principalement sur l'exploitation du schéma : +Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma et l'ingestion sont +livrés : ce qui suit porte sur leur exploitation, plus sur leur forme. - **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure. - **Quels agrégats continus** créer et sur quelles fenêtres. @@ -287,7 +295,7 @@ Elles servent à l'analyse des données et ne sont pas considérées comme des a - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. -# Ingestion des données historiques +## Ingestion des données historiques Statut : `Fait`. @@ -301,7 +309,7 @@ Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisat Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. -## Architecture du flux historique +### Architecture du flux historique ```text Dataset CSV + métadonnées JSON @@ -351,7 +359,7 @@ source = "csv" dataset_id = identifiant du dataset ``` -## Résultats validés pour l'historique +### Résultats validés pour l'historique Le chargement de référence a permis d'obtenir : @@ -366,7 +374,7 @@ aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`. -# Ingestion depuis l'API Mock +## Ingestion depuis l'API Mock Statut : `Fait`. @@ -378,7 +386,7 @@ Le traitement est implémenté dans : apps/backend/app/etl/mock_api_import.py ``` -## Endpoints utilisés +### Endpoints utilisés Le pipeline récupère les informations des sites depuis : @@ -410,7 +418,7 @@ Les paramètres de ligne de commande disponibles pour l'import sont : --dry-run ``` -## Flux d'ingestion API Mock +### Flux d'ingestion API Mock ```text API Mock @@ -454,7 +462,32 @@ La réponse source reçue depuis l'API est conservée dans : raw_data ``` -## Qualité des données de l'API Mock +### Frontière de confiance avec l'API Mock + +L'API Mock de l'école n'a aucune authentification et expose un endpoint mutatif à quiconque. Sa +réponse est donc traitée comme une entrée hostile, conformément à API10 dans +[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte, +c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. + +Quatre garde-fous, tous dans `mock_api_import.py` : + +| Garde-fou | Mise en œuvre | +|---|---| +| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut | +| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site | +| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur | +| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus | + +Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa +trace dans `null_reasons` sous la forme `out_of_physical_bounds:`, et `data_quality` +descend à `degraded`. Une `data_quality` que `ck_reading_quality` refuserait devient `NULL` +plutôt que de faire échouer le lot entier. Dans tous les cas `raw_data` conserve la réponse +d'origine intacte : rien n'est perdu, seule son exploitation est bornée. + +Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP +lui-même demanderait une lecture en flux, et reste à faire. + +### Qualité des données de l'API Mock Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion. @@ -475,7 +508,7 @@ imputed_values = NULL imputation_method = NULL ``` -## Validation de l'import API Mock +### Validation de l'import API Mock Un scénario de validation a été exécuté pour les 7 sites sur la période : @@ -526,7 +559,7 @@ Les tests automatisés couvrent également : - la conservation des données sources ; - l'idempotence en base. -# Évolution prévue +## Évolution prévue La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow. @@ -563,4 +596,4 @@ Les scripts Python resteront responsables de l'extraction, de la validation, de et du chargement des données. Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle -de Machine Learning. \ No newline at end of file +de Machine Learning. diff --git a/etl/README.md b/etl/README.md index 15c376b..719b828 100644 --- a/etl/README.md +++ b/etl/README.md @@ -81,9 +81,9 @@ L'API Mock est utilisée pour compléter les données historiques avec des mesur | mypy | Vérification du typage | | Pytest | Tests automatisés | -# Import du dataset historique +## Import du dataset historique -## Fonctionnement du pipeline historique +### Fonctionnement du pipeline historique Le script d'import se trouve dans : @@ -115,14 +115,14 @@ CSV + métadonnées JSON PostgreSQL / TimescaleDB ``` -### 1. Extraction +#### 1. Extraction Le pipeline charge : - `all_sites_combined.csv` avec Pandas ; - `dataset_metadata.json` avec le module JSON de Python. -### 2. Validation +#### 2. Validation Avant toute écriture en base, le pipeline contrôle notamment : @@ -136,7 +136,7 @@ Avant toute écriture en base, le pipeline contrôle notamment : Une incohérence détectée pendant cette étape interrompt l'import avant le chargement. -### 3. Dry-run +#### 3. Dry-run Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL. @@ -149,7 +149,7 @@ Il permet notamment de vérifier : - les valeurs NULL ; - l'empreinte SHA-256. -### 4. Traçabilité +#### 4. Traçabilité Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé. @@ -161,7 +161,7 @@ Empreinte SHA-256 du dataset validé : Cette empreinte participe à la traçabilité du dataset chargé. -### 5. Transformation +#### 5. Transformation Les timestamps sont normalisés avec la timezone : @@ -180,7 +180,7 @@ imputed_values = NULL imputation_method = NULL ``` -### 6. Chargement +#### 6. Chargement Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB. @@ -207,7 +207,7 @@ dataset_id = identifiant du dataset Cette représentation respecte les contraintes définies dans le schéma de la base. -## Dataset validé +### Dataset validé Le dataset traité contient : @@ -226,7 +226,7 @@ Valeurs manquantes identifiées : | `humidity_percent` | 3 423 | | `solar_irradiance_wm2` | 3 964 | -## Exécution historique en dry-run +### Exécution historique en dry-run Depuis le dossier : @@ -246,7 +246,7 @@ uv run python -m app.etl.historical_import ` Aucune donnée n'est écrite dans la base pendant cette exécution. -## Chargement historique réel +### Chargement historique réel Depuis `apps/backend/` : @@ -268,7 +268,7 @@ Chargement : 2000/122647 Chargement : 122647/122647 ``` -## Résultats obtenus pour le dataset historique +### Résultats obtenus pour le dataset historique Après le chargement initial, les contrôles en base ont confirmé : @@ -285,7 +285,7 @@ Le premier import a créé : nouvelles lectures : 122647 ``` -## Idempotence du dataset historique +### Idempotence du dataset historique Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence. @@ -299,7 +299,7 @@ nouvelles lectures : 0 Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé. -## Vérifications SQL du dataset historique +### Vérifications SQL du dataset historique Depuis la racine du projet, vérifier le nombre d'enregistrements avec : @@ -327,9 +327,9 @@ Résultat attendu pour le dataset historique : csv | 122647 ``` -# Import depuis l'API Mock +## Import depuis l'API Mock -## Fonctionnement +### Fonctionnement Le script d'import de l'API Mock se trouve dans : @@ -386,7 +386,7 @@ limit Le paramètre `limit` doit être compris entre 1 et 1000. -## Configuration de l'API Mock +### Configuration de l'API Mock La connexion à l'API Mock est configurée avec les variables d'environnement suivantes : @@ -401,7 +401,7 @@ Les identifiants réels ne sont pas versionnés dans Git. Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution. -## Transformation des mesures API +### Transformation des mesures API Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec : @@ -422,7 +422,7 @@ raw_data afin de préserver la donnée reçue et faciliter la traçabilité. -## Qualité des données API +### Qualité des données API Les valeurs `NULL` fournies par l'API sont conservées telles quelles. @@ -444,6 +444,9 @@ degraded critical ``` +Ce sont les quatre seules valeurs que la contrainte `ck_reading_quality` accepte. Toute autre +valeur renvoyée par l'API est remplacée par `NULL` plutôt que de faire échouer le lot entier. + Aucune imputation n'est réalisée pendant l'ingestion : ```text @@ -453,7 +456,42 @@ imputation_method = NULL Cette stratégie permet de distinguer une véritable valeur nulle ou manquante d'une consommation égale à zéro et de conserver les informations liées aux défaillances de capteurs. -## Dry-run de l'API Mock +### Bornes physiques et frontière de confiance + +La réponse de l'API Mock est traitée comme une entrée hostile : l'API n'a pas +d'authentification et expose un endpoint mutatif à quiconque. Voir API10 dans +`docs/architecture/owasp-traceabilite.md`. + +Les plages acceptées sont déclarées dans `PHYSICAL_BOUNDS` : + +| Grandeur | Plage acceptée | +|---|---| +| `consumption_kw` | 0 à 100 000 | +| `consumption_kwh` | 0 à 100 000 | +| `voltage_v` | 0 à 1 000 | +| `current_a` | 0 à 10 000 | +| `power_factor` | 0 à 1 | +| `temperature_celsius` | -90 à 60 | +| `humidity_percent` | 0 à 100 | +| `capacity_kw` | 0 à 100 000 | + +Une valeur hors plage, d'un type inattendu, `NaN` ou infinie devient `NULL` : + +```text +null_reasons += "out_of_physical_bounds:" +data_quality = "degraded" +``` + +L'import ne s'interrompt pas pour autant : le mock émet des anomalies par construction, et +`raw_data` conserve la réponse d'origine. + +La taille des réponses est plafonnée : au plus `MAX_SITES` sites, et au plus `--limit` mesures +par site. Au-delà, l'import échoue au lieu de charger. + +Enfin, seuls les champs attendus sont recopiés vers la base. Une clé supplémentaire renvoyée par +l'API n'atteint jamais une colonne. + +### Dry-run de l'API Mock Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL. @@ -467,7 +505,7 @@ uv run python -m app.etl.mock_api_import ` --dry-run ``` -## Chargement réel depuis l'API Mock +### Chargement réel depuis l'API Mock Depuis `apps/backend/` : @@ -478,7 +516,7 @@ uv run python -m app.etl.mock_api_import ` --limit 60 ``` -## Résultat validé pour l'API Mock +### Résultat validé pour l'API Mock Le scénario de validation utilisé couvre la période : @@ -511,7 +549,7 @@ Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé - la conservation de `null_reasons` ; - la conservation de la donnée source dans `raw_data`. -## Idempotence de l'import API Mock +### Idempotence de l'import API Mock Le même import a été exécuté plusieurs fois afin de vérifier qu'une mesure déjà présente n'est pas créée une seconde fois. @@ -519,7 +557,7 @@ L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur l Un test d'intégration automatisé vérifie également ce comportement. -# Tests et qualité +## Tests et qualité Les tests automatisés des pipelines ETL sont situés dans : @@ -599,7 +637,7 @@ Lors de la validation de l'import API Mock : La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %. -# Suite du pipeline Data +## Suite du pipeline Data Deux sources de données sont maintenant prises en charge : @@ -631,4 +669,4 @@ Airflow permettra de planifier les traitements, gérer leur ordre d'exécution, Airflow ne remplacera pas la logique ETL Python existante. Les scripts actuels resteront responsables de l'extraction, de la validation, de la transformation et du chargement. -Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. \ No newline at end of file +Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. From 0a2ed5ad8f7b153ea7689d6545b66c4dfad38608 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:25:48 +0200 Subject: [PATCH 193/205] docs: distingue l'ingestion des mesures de celle des alertes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La #114 arrive sur dev avec un ADR 0006 qui note que l'ingestion de l'API Mock /alerts reste à faire. « Les deux sources sont implémentées » se lisait comme couvrant aussi les alertes. --- docs/architecture/40-data.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 823e92c..47fdc8a 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -12,8 +12,10 @@ d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible` décrivent les éléments prévus mais pas encore réalisés. -L'ingestion des deux sources de données du MVP est maintenant implémentée. L'orchestration -Airflow, les agrégats continus, la compression et la rétention restent des cibles. +L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et +l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir +l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). L'orchestration Airflow, les +agrégats continus, la compression et la rétention restent des cibles. ## Trois emplacements, trois rôles From 3c01ab3ecccef17e9e6dc12d6bb0954d9582f974 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:50:12 +0200 Subject: [PATCH 194/205] =?UTF-8?q?docs(ml):=20=C3=A9crit=20ML-START.md=20?= =?UTF-8?q?et=20r=C3=A9pare=20les=20renvois=20cass=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le document était référencé 11 fois, dont 4 depuis le code (config.py, data.py, train.py, score.py, features.py), et n'avait jamais été écrit. Deux chemins contradictoires coexistaient : `../ML-START.md` depuis ml/README.md et docs/architecture/, `docs/ML-START.md` depuis le code. Le chemin retenu est celui du code, majoritaire et le seul qu'un lecteur du module rencontre. Il couvre les trois sections que les renvois annoncent : mécanisme d'accès aux données et pourquoi ce n'est pas l'API, étapes d'un run de scoring, frontière entre FastAPI et LightGBM. Ferme C34 de la grille d'auto-évaluation. --- docs/ML-START.md | 176 ++++++++++++++++++++++++++++++++ docs/architecture/20-backend.md | 2 +- ml/README.md | 2 +- 3 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 docs/ML-START.md diff --git a/docs/ML-START.md b/docs/ML-START.md new file mode 100644 index 0000000..e4d9f93 --- /dev/null +++ b/docs/ML-START.md @@ -0,0 +1,176 @@ +# ML-START : accès aux données, scoring, frontière API et ML + +Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`, +`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md) +et par les vues d'architecture. Il répond à trois questions, et à elles seules : + +1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ; +2. **ce que fait un run de scoring**, étape par étape ; +3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là. + +Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md). +Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas. + +--- + +## 1. Mécanisme d'accès aux données + +### Deux sources, un seul schéma de sortie + +`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes** +(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`, +`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) : + +| Fonction | Source | Usage | +|---|---|---| +| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée | +| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement | +| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring | + +L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur +CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une +divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production +sous forme de prédictions silencieusement fausses. + +### Connexion directe à PostgreSQL, pas l'API + +Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois +raisons, à défendre telles quelles : + +- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire + passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer + trois fois pour un `SELECT`. +- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du + schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la + disponibilité du service, pour lire des données dont il connaît déjà la forme. +- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte + applicatif porteur d'un rôle métier. + +### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL` + +La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est +pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du +schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner +l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()` +lève donc plutôt que de retomber sur une valeur par défaut. + +**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`, +restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement, +`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente +avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md). + +### Le seul endroit qui construit les features + +`enervision_ml.features.build_features` est **l'unique** constructeur de features, à +l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une +fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à +ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**. +Ne jamais réécrire cette logique ailleurs : importer le module. + +Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais +un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des +lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait +un surapprentissage. + +--- + +## 2. Les étapes d'un run de scoring + +`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la +consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans +`prediction`. + +| # | Étape | Point de vigilance | +|---|---|---| +| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` | +| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 | +| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire | +| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | | + +### Ce que le run écrit, et ce qu'il n'écrase pas + +La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run +insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra +possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui +n'existe pas encore. + +Trois contraintes de cohérence sont portées par la base et non par le code applicatif : +`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ; +`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à +`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. + +### `model_reference` est un hachage, pas un nom de fichier + +`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque +entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction` +porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une +prédiction douteuse, de savoir quel modèle l'a produite. + +### Mode CSV : rien n'est écrit en base + +En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe +dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode +sert à valider le pipeline sans base joignable. + +### Limite assumée + +La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture +réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans +le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule, +sur une feature parmi une dizaine, pour une prévision à un seul pas. + +--- + +## 3. La frontière entre l'API et le ML + +```mermaid +flowchart LR + subgraph ml["ml/ · projet Python indépendant"] + train["enervision_ml.train
LightGBM + MLflow"] + score["enervision_ml.score
prévision à un pas"] + end + subgraph db["PostgreSQL + TimescaleDB"] + reading[("reading, site")] + prediction[("prediction")] + end + subgraph api["apps/backend · FastAPI"] + route["GET /api/v1/predictions"] + end + + reading -- "SQL direct, ML_DATABASE_URL" --> train + reading -- "fenêtre récente" --> score + train -- "models/*.txt + run MLflow" --> score + score -- "INSERT" --> prediction + prediction -- "lecture seule" --> route +``` + +**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.** +`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul +à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury : + +- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée + (`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde + ou une minute. +- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python + séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni + leur surface de vulnérabilités, pour un script lancé hors du chemin de requête. +- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API + continue de servir la dernière prévision connue, avec son `created_at` et son + `model_reference`, au lieu de rendre une erreur. +- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de + `prediction` et leurs contraintes de cohérence, vérifiables en SQL. + +Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est +celle du dernier run de scoring. Tant que l'orchestration Airflow n'existe pas (`etl/airflow/` est +vide), ce run est lancé à la main. C'est la dette la plus visible du module, et elle est portée +par les issues #44 et #45. + +--- + +## Voir aussi + +- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests +- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés +- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions +- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions` +- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c6189a6..3502f02 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -193,7 +193,7 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte `ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. -[ML-START.md](../../ML-START.md) section 3. +[ML-START.md](../ML-START.md) section 3. `POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni diff --git a/ml/README.md b/ml/README.md index c7814fe..21ddd85 100644 --- a/ml/README.md +++ b/ml/README.md @@ -2,7 +2,7 @@ Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet : [ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et -[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees). +[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees). | Element | Choix | |--------------|-----------------------------------------------| From f0ad8e99907587a73a0c6252ee2a2ac0226a0d9e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:50:25 +0200 Subject: [PATCH 195/205] ci(security): branche Bandit sur apps/backend et sur le module ML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le pipeline auditait les dépendances (pip-audit, npm audit, Dependabot) mais jamais le code lui-même : aucun SAST, aucun DAST. C'était le seul rouge de BC03 qui se fermait en une étape de workflow. Le job bloque à partir de MEDIUM/MEDIUM, et une seconde passe sans seuil publie les constats LOW sans bloquer : sans elle, un LOW disparaîtrait du journal sans trace. Le périmètre est le code livré (`app`, `enervision_ml`) et non les tests, qui emploient légitimement des secrets factices et des `assert`. Relevé au 21/09 : zéro constat tous niveaux confondus sur 5 904 lignes. Couvre #39. Ferme C18 de la grille d'auto-évaluation. --- .github/workflows/backend.yml | 27 +++++++++++++++++++++++++++ .github/workflows/ml.yml | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 8ad1302..f02eef8 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -140,3 +140,30 @@ jobs: # Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe. shell: bash run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps + + sast: + name: Analyse statique de sécurité + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des + # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. + - name: Analyse le code livré (bloquant à partir de MEDIUM) + run: uvx bandit --recursive app --severity-level medium --confidence-level medium + + # Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. + - name: Rapport complet, tous niveaux + continue-on-error: true + run: uvx bandit --recursive app diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml index b85fec7..4700a97 100644 --- a/.github/workflows/ml.yml +++ b/.github/workflows/ml.yml @@ -57,3 +57,27 @@ jobs: # synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). - name: Tests run: uv run pytest + + sast: + name: Analyse statique de sécurité + runs-on: ubuntu-latest + defaults: + run: + working-directory: ml + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: ml/uv.lock + + - name: Analyse le code livré (bloquant à partir de MEDIUM) + run: uvx bandit --recursive enervision_ml --severity-level medium --confidence-level medium + + - name: Rapport complet, tous niveaux + continue-on-error: true + run: uvx bandit --recursive enervision_ml From 5545c166fd4aad178c4add25b6a6ba14c0bc0f37 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:50:25 +0200 Subject: [PATCH 196/205] docs(architecture): ajoute la vue CI/CD et corrige trois affirmations fausses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La documentation du pipeline est explicitement notée par EC03 (C20) et n'existait pas. L'index des vues justifiait son absence par un manque de matière : quatre workflows et quatorze jobs en sont assez. Trois affirmations de 00-vue-ensemble.md étaient devenues fausses, ce qui coûte plus cher qu'une absence puisqu'on les lit et qu'on construit dessus : - la CI/CD y était déclarée `Cible` / `Rien` alors que quatre workflows tournent ; - le flux bout en bout y était `Cible` avec "aucun maillon n'existe, à l'exception de la base", alors que tout le chemin de lecture et deux ingestions existent ; - l'analyse de dépendances y était listée comme absente alors que pip-audit, npm audit et Dependabot sont en place. Seule celle des images manque. Ferme C20 de la grille d'auto-évaluation. --- docs/architecture/00-vue-ensemble.md | 12 +- docs/architecture/50-cicd.md | 169 +++++++++++++++++++++++++++ docs/architecture/README.md | 8 +- 3 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/50-cicd.md diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 2f54762..480ef5d 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -77,15 +77,17 @@ 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | -| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien | +| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 4 workflows, 14 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. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) | ## Flux bout en bout -Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base. +Statut : `En cours`. Tout le chemin de lecture existe (base, API, frontend), ainsi que l'ingestion +par import depuis un CSV historique et depuis l'API Mock. **Le seul maillon absent est +l'orchestration** : Airflow ne tourne pas, l'ingestion et le scoring sont lancés à la main. ```mermaid sequenceDiagram @@ -153,7 +155,9 @@ consolidée. - **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore. - **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas l'infrastructure. -- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD. +- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place + (`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir + [50-cicd.md](50-cicd.md). - **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir [31-contrat-authentification.md](31-contrat-authentification.md). diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md new file mode 100644 index 0000000..deadf87 --- /dev/null +++ b/docs/architecture/50-cicd.md @@ -0,0 +1,169 @@ +# Intégration et livraison continues + +Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est +vérifié, ce qui bloque, et ce qui ne l'est pas. + +| Étage | Sert à | Statut | +|---|---|---| +| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` | +| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` | + +Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image +publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet +étage, et elle est nommée ici plutôt que découverte en soutenance. + +## Vue d'ensemble + +```mermaid +flowchart TB + push["push ou pull_request"] + + subgraph back["Backend · .github/workflows/backend.yml"] + bv["verification
ruff, mypy, pytest --cov-fail-under=85"] + bi["integration
TimescaleDB réel + alembic upgrade head"] + bd["security-audit
uv export | pip-audit"] + bs["sast
bandit"] + end + + subgraph front["Frontend · frontend.yml"] + fb["build
npm ci, npm run build"] + ft["test
couverture lcov"] + fd["security-audit
npm audit --audit-level=high"] + end + + subgraph mlw["ML · ml.yml"] + mv["verification
ruff, mypy, pytest"] + ms["sast
bandit"] + end + + subgraph sq["SonarQube · sonarqube.yml"] + sb1["build-front / test-front"] + sb2["build-back / test-back"] + sscan["sonarqube
quality gate SonarCloud"] + end + + push --> bv & bi & bd & bs + push --> fb --> ft + push --> fd + push --> mv & ms + push --> sb1 & sb2 --> sscan + sscan -.-> cd["deploy
issue #21"] +``` + +## Déclenchement + +Les quatre 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/**`, +chacun incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline +déclenche le pipeline. + +**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la +CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est +délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans +rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push. + +`backend.yml` et `ml.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. + +## Ce qui bloque un merge + +| Gate | Où | Seuil | Effet d'un échec | +|---|---|---|---| +| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque | +| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque | +| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque | +| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque | +| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque | +| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque | +| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque | +| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque | +| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque | +| Build `npm run build` | frontend | compilation | Bloque | + +Deux seuils portent une décision qu'il faut savoir défendre : + +- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une + dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les + `moderate` sont invisibles en CI, et qu'elles se regardent à la main. +- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW + sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Au + 21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes + analysées. + +## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres` + +`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que +`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête +volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste +rendrait ce job vert sur une base qui n'est pas la nôtre. + +Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas +monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb` +avant `alembic upgrade head`. + +La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une +partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %. + +## SonarCloud, et l'incident qui a immobilisé trois PR + +Le workflow `sonarqube.yml` exécute 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. + +**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont +restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que +la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** : +`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que +`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient +comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par +ailleurs que la racine. + +Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test, +`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur +toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas +contournée** en désactivant la gate ou en excluant les fichiers gênants. + +## Dependabot + +`.github/dependabot.yml` déclare **cinq entrées hebdomadaires groupées** : `npm` sur +`/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, et `docker` sur les deux +dossiers d'application. Les mises à jour arrivent en PR, donc elles traversent les mêmes gates que +n'importe quel changement : une montée de version qui casse les tests ne se merge pas. + +## Stratégie de branche et conventions + +| Règle | Détail | +|---|---| +| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` | +| Messages de commit | Conventional Commits | +| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public | +| Revue | Toute PR passe par une revue écrite avant merge | +| ADR | Toute décision structurante porte son ADR dans la même PR | +| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** | + +## Secrets + +Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions. +Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le +workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le +run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement (issue #22). + +## Ce qui manque, et pourquoi + +| Manque | Issue | Conséquence assumée | +|---|---|---| +| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine | +| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances | +| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI | +| Tests de charge | #47 | Aucun garde-fou de performance | +| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | + +## Reproduire la CI en local + +`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job +`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration +demandent une base : `make db-up` puis `uv run pytest -m integration`. + +Le SAST se rejoue à l'identique : `uvx bandit --recursive app --severity-level medium +--confidence-level medium` depuis `apps/backend`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 78a82c9..b31b71a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -15,10 +15,12 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. | [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | +| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque | -L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents -ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en -sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne. +La CI/CD a désormais son document : quatre workflows et quatorze jobs, c'est assez de matière pour +qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a +toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura +de la matière. Un fichier vide de plus n'aide personne. La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans [00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la From 901ceffd72809ec6b72eda4b0519108db8764688 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 11:18:02 +0200 Subject: [PATCH 197/205] fix(etl): fiabilise airflow-init, borne les DAGs ML et ajoute la CI Airflow --- .github/workflows/airflow.yml | 84 +++++++++++++++++++++++++ docker-compose.yml | 45 ++++++++----- docs/architecture/10-infra.md | 25 +++++++- docs/architecture/20-backend.md | 4 +- docs/architecture/owasp-traceabilite.md | 2 +- etl/airflow/.python-version | 1 + etl/airflow/Dockerfile | 7 +-- etl/airflow/dags/ml_score.py | 14 ++++- etl/airflow/dags/ml_train.py | 25 +++++--- etl/airflow/tests/test_dags.py | 38 +++++++++-- 10 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/airflow.yml create mode 100644 etl/airflow/.python-version diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml new file mode 100644 index 0000000..a79f97f --- /dev/null +++ b/.github/workflows/airflow.yml @@ -0,0 +1,84 @@ +name: Airflow + +# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 +# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de +# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). +# +# Piège : l'image COPY les fichiers de dépendances et le code de ml/. Une modification de ml/ +# peut donc casser sa construction, d'où ces chemins dans les déclencheurs. + +on: + push: + paths: + - "etl/airflow/**" + - "ml/pyproject.toml" + - "ml/uv.lock" + - "ml/enervision_ml/**" + - ".github/workflows/airflow.yml" + pull_request: + paths: + - "etl/airflow/**" + - "ml/pyproject.toml" + - "ml/uv.lock" + - "ml/enervision_ml/**" + - ".github/workflows/airflow.yml" + +permissions: + contents: read + +concurrency: + group: airflow-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint et intégrité des DAGs + runs-on: ubuntu-latest + defaults: + run: + working-directory: etl/airflow + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: etl/airflow/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + - name: Vérifie le formatage + run: uv run ruff format --check . + + - name: Analyse statique + run: uv run ruff check --output-format=github . + + # Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et + # vérifie import, planification, plafonds d'exécution et commande de chaque tâche. + - name: Tests d'intégrité des DAGs + run: uv run pytest + + image: + name: Construction de l'image + runs-on: ubuntu-latest + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Construit l'image (contexte à la racine, elle COPY ml/) + run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . + + # Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent + # (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé. + - name: Vérifie que le pipeline ML s'importe sans réseau + run: > + docker run --rm --network none enervision-airflow:ci + bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help" diff --git a/docker-compose.yml b/docker-compose.yml index fc02f90..5f6c9c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,17 +16,20 @@ x-airflow-common: &airflow-common environment: &airflow-common-env AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__CORE__LOAD_EXAMPLES: "false" - AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?} - AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:?} + # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de + # filtrer les services : une variable requise manquante casserait aussi `make db-up`, + # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par + # `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. + AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} + AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow - # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003/CLAUDE.md) : + # 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 volumes: - ./etl/airflow/dags:/opt/airflow/dags - ./etl/airflow/plugins:/opt/airflow/plugins - - ./etl/airflow/include:/opt/airflow/include - airflow_logs:/opt/airflow/logs - airflow_ml_state:/opt/ml/state restart: unless-stopped @@ -98,25 +101,35 @@ services: - "${FRONTEND_PORT:-3000}:80" restart: unless-stopped - # Conteneur unique, jamais redemarre : migre la base de metadonnees puis cree le premier compte - # (idempotent, `|| true` sur la creation qui echoue si le compte existe deja). `webserver` et - # `scheduler` attendent qu'il se termine avec succes avant de demarrer. + # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont + # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte + # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume + # `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son + # succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, + # jamais par `argv` (ni `ps`, ni `docker compose config`). + # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous + # refuse en plus les deux cles de chiffrement vides. airflow-init: <<: *airflow-common restart: "no" + environment: + <<: *airflow-common-env + _AIRFLOW_DB_MIGRATE: "true" + _AIRFLOW_WWW_USER_CREATE: "true" + _AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin} + _AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-} + _AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr} + depends_on: + db: + condition: service_healthy command: - bash - -c - | - airflow db migrate - airflow users create \ - --username "${AIRFLOW_ADMIN_USERNAME:-admin}" \ - --password "${AIRFLOW_ADMIN_PASSWORD:?}" \ - --firstname Admin \ - --lastname EnerVision \ - --role Admin \ - --email "${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}" \ - || true + set -euo pipefail + : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" + : "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" + exec airflow version airflow-webserver: <<: *airflow-common diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 7bd751c..1c44eb0 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -40,13 +40,15 @@ seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste ave des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se lancent donc pas ensemble. -Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : +Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : - `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message. - `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans le compose. Voir [`db/README.md`](../../db/README.md). +- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : + c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). ### Airflow (`ml_train`/`ml_score`, issue #115) @@ -65,6 +67,27 @@ synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow. +`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, +`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une +migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et +`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin +passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). + +Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier +entier avant de filtrer les services, une variable requise manquante casserait `make db-up`, +`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est +`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask ou mot de passe vides). + +**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision +prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un +cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le +déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1` +(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes. + +CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et +tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/`, une modification de `ml/` +peut donc la casser) avant de vérifier que le pipeline s'y importe sans réseau. + Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier `make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute `docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois : diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c6189a6..b9abfa4 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -256,8 +256,8 @@ auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spi explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul instant réel, pas une variation). -Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par -Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans +La détection est un script lancé à la main, pas encore ordonnancé par Airflow (contrairement à +`enervision_ml.score`, orchestré par le DAG `ml_score` depuis l'issue #115) : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans `apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104) débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 7e9c19a..132e107 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -57,7 +57,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | | **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | | **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | -| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. | +| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv`. | | **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. | ## Ce qu'il faut répondre, et ne pas répondre diff --git a/etl/airflow/.python-version b/etl/airflow/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/etl/airflow/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/etl/airflow/Dockerfile b/etl/airflow/Dockerfile index 52e1877..058928f 100644 --- a/etl/airflow/Dockerfile +++ b/etl/airflow/Dockerfile @@ -1,8 +1,7 @@ # Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14, -# distinct du Python 3.12 qui fait tourner Airflow lui-meme, pour que les DAGs puissent lancer -# `uv run python -m enervision_ml.train`/`.score` en sous-processus (cf. docs/architecture/ -# 20-backend.md, section Détection d'alertes internes pour le meme raisonnement applique a -# app/detection). Airflow ne devient jamais un consommateur direct de LightGBM/MLflow. +# distinct 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` en +# sous-processus. Airflow ne devient jamais un consommateur direct de LightGBM/MLflow. FROM apache/airflow:2.10.4-python3.12 # LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans diff --git a/etl/airflow/dags/ml_score.py b/etl/airflow/dags/ml_score.py index 94d7708..8d6cfeb 100644 --- a/etl/airflow/dags/ml_score.py +++ b/etl/airflow/dags/ml_score.py @@ -8,7 +8,7 @@ ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la ta from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from airflow.models.dag import DAG from airflow.operators.bash import BashOperator @@ -21,13 +21,21 @@ with DAG( schedule="@hourly", start_date=datetime(2026, 1, 1), catchup=False, + # Deux scorings qui se chevauchent inseraient en meme temps dans `prediction` (pas de contrainte + # d'unicite sur `(site_id, target_at)`, chaque run garde sa ligne). + max_active_runs=1, tags=["ml"], ) as dag: - # `--frozen --no-dev` : cf. `ml_train.py`, meme raisonnement. + # `--no-sync`, `env -u VIRTUAL_ENV` : cf. `ml_train.py`, meme raisonnement. BashOperator( task_id="score", bash_command=( - "cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.score " + "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.score " f"--model {MODEL_PATH}" ), + # Un incident transitoire sur Postgres ne doit pas faire perdre le creneau horaire. + retries=2, + retry_delay=timedelta(minutes=2), + # Bien en dessous du pas horaire : un scoring pendu ne doit pas empieter sur le suivant. + execution_timeout=timedelta(minutes=30), ) diff --git a/etl/airflow/dags/ml_train.py b/etl/airflow/dags/ml_train.py index f4ec36b..d866480 100644 --- a/etl/airflow/dags/ml_train.py +++ b/etl/airflow/dags/ml_train.py @@ -1,14 +1,15 @@ """DAG d'entrainement du modele LightGBM (issue #115). -Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise -(cf. `docs/architecture/20-backend.md`). Declenchement manuel depuis l'UI ou la CLI Airflow en -attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que ce DAG -ecrit, il ne reentraine jamais rien lui-meme. +Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise, en +particulier tant que `train.py` ecrase le modele sans comparer ses metriques a l'ancien (cf. +`docs/architecture/10-infra.md`, section Airflow). Declenchement manuel depuis l'UI ou la CLI +Airflow en attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que +ce DAG ecrit, il ne reentraine jamais rien lui-meme. """ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from airflow.models.dag import DAG from airflow.operators.bash import BashOperator @@ -22,15 +23,21 @@ with DAG( schedule=None, start_date=datetime(2026, 1, 1), catchup=False, + # Deux entrainements simultanes ecriraient le meme fichier modele. + max_active_runs=1, tags=["ml"], ) as dag: - # `--frozen --no-dev` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image - # (groupe `dev` exclu). Sans `--no-dev` ici, `uv run` resynchronise ruff/mypy a chaque - # execution : un acces reseau evitable, sur le chemin d'execution d'une tache planifiee. + # `--no-sync` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image, `uv run` + # ne le resynchronise pas (sinon `enervision-ml` est reconstruit a chaque tache). + # `env -u VIRTUAL_ENV` : l'image de base positionne celui d'Airflow, que `uv` signale a chaque + # execution sans qu'il change quoi que ce soit. BashOperator( task_id="train", bash_command=( - "cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.train " + "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train " f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}" ), + # Un entrainement complet dure quelques minutes ; une connexion pendue ne doit pas + # immobiliser un slot du scheduler indefiniment. + execution_timeout=timedelta(hours=1), ) diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 9b03f49..58f6ab9 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -1,6 +1,7 @@ """Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition.""" +from datetime import timedelta from pathlib import Path import pytest @@ -22,14 +23,12 @@ def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None: assert set(dagbag.dag_ids) == {"ml_train", "ml_score"} -def test_ml_train_has_no_schedule() -> None: - dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) +def test_ml_train_has_no_schedule(dagbag: DagBag) -> None: assert dagbag.dags["ml_train"].timetable.summary == "None" -def test_ml_score_runs_every_hour() -> 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. - dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *" @@ -50,3 +49,34 @@ def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> N assert chemin_modele in entrainement assert chemin_modele in scoring + + +@pytest.mark.parametrize("dag_id", ["ml_train", "ml_score"]) +def test_no_two_runs_of_a_dag_overlap(dagbag: DagBag, dag_id: str) -> None: + # Deux entrainements ecriraient le meme fichier modele, deux scorings inseriraient en meme + # temps dans `prediction`. + assert dagbag.dags[dag_id].max_active_runs == 1 + + +@pytest.mark.parametrize(("dag_id", "task_id"), [("ml_train", "train"), ("ml_score", "score")]) +def test_every_task_has_an_execution_timeout(dagbag: DagBag, dag_id: str, task_id: str) -> None: + # Sans plafond, une connexion pendue immobilise un slot du scheduler indefiniment. + assert dagbag.dags[dag_id].get_task(task_id).execution_timeout is not None + + +def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag) -> None: + timeout = dagbag.dags["ml_score"].get_task("score").execution_timeout + assert timeout is not None + assert timeout < timedelta(hours=1) + + +def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None: + assert dagbag.dags["ml_score"].get_task("score").retries >= 1 + + +@pytest.mark.parametrize(("dag_id", "task_id"), [("ml_train", "train"), ("ml_score", "score")]) +def test_tasks_never_resync_the_baked_environment( + dagbag: DagBag, dag_id: str, task_id: str +) -> None: + # Sans `--no-sync`, `uv run` reconstruit `enervision-ml` a chaque execution. + assert "--no-sync" in dagbag.dags[dag_id].get_task(task_id).bash_command From 777cd0ac64186c0f1958edac3a1981fe4870abf0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 11:38:43 +0200 Subject: [PATCH 198/205] =?UTF-8?q?fix(frontend):=20affiche=20since=20comm?= =?UTF-8?q?e=20la=20derni=C3=A8re=20lecture=20re=C3=A7ue,=20pas=20comme=20?= =?UTF-8?q?un=20d=C3=A9but=20de=20panne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le schéma backend dit que since est l'horodatage de la dernière lecture du site, identique pour tous ses capteurs en panne et sans rapport avec le début de la panne. Le template annonçait « depuis », ce que l'exploitant lit comme une date de début de panne. Un site sans aucune lecture renvoie ses cinq capteurs en échec avec since à null : le template affichait « depuis » suivi d'une chaîne vide. Ce cas dit maintenant « aucune lecture reçue ». Format de date explicite plutôt que 'short' : aucune locale n'est enregistrée dans app.config.ts, donc 'short' rendait la date au format en-US. --- apps/frontend/src/app/app.routes.ts | 12 ++--- .../src/app/features/dashboard/dashboard.html | 2 +- .../sensor-status/sensor-status.html | 14 ++--- .../sensor-status/sensor-status.spec.ts | 51 ++++++++++++++----- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index de0315f..40e814f 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -24,10 +24,10 @@ export const routes: Routes = [ import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, { - path: 'monitoring/sensors', - canActivate: [authGuard], - data: { role: 'admin' }, - loadComponent: () => - import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), -}, + path: 'monitoring/sensors', + canActivate: [authGuard], + data: { role: 'admin' }, + loadComponent: () => + import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), + }, ]; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 2f48539..f9a3fb2 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -12,7 +12,7 @@
@if (auth.principal()?.role === 'admin') { Supervision des capteurs - } + } Voir les sites @for (entry of sensorEntries; track entry[0]) { + @let diagnostic = sensorOf(site.sensors, entry[0]);
  • - + {{ entry[1] }} - @if (sensorOf(site.sensors, entry[0]).status === 'failing') { + @if (diagnostic.status === 'failing') { - depuis {{ sensorOf(site.sensors, entry[0]).since | date: 'short' }} + @if (diagnostic.since; as since) { + dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }} + } @else { + aucune lecture reçue + } }
  • diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts index 189e241..2e6e84e 100644 --- a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts @@ -79,21 +79,44 @@ describe('SensorStatusView', () => { expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null }); }); - it('affiche la date depuis quand un capteur est en panne', () => { - const sensors: SiteSensors = { - ...OK_SENSORS, - temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, - }; - sensorsMock.getStatus.mockReturnValue( - of({ - timestamp: '2026-09-18T08:00:00', - sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], - }) - ); + it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => { + const sensors: SiteSensors = { + ...OK_SENSORS, + temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], + }) + ); - const fixture = TestBed.createComponent(SensorStatusView); - fixture.detectChanges(); + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('depuis'); + expect(fixture.nativeElement.textContent).toContain('dernière lecture le'); + expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00'); + }); + + it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => { + const sensors: SiteSensors = { + consumption: { status: 'failing', since: null }, + electrical: { status: 'failing', since: null }, + temperature: { status: 'failing', since: null }, + humidity: { status: 'failing', since: null }, + network: { status: 'failing', since: null }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue'); + expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le'); }); }); From bc755286169c042def0113eec71d8d27e08b310d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 12:11:32 +0200 Subject: [PATCH 199/205] =?UTF-8?q?fix(infra):=20l=C3=A8ve=20les=20points?= =?UTF-8?q?=20de=20revue=20du=20reverse=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose interpole tout le fichier avant n'importe quelle sous-commande : la garde `${PUBLIC_HOST:?}` de l'overlay cassait `stack-down` et `stack-logs` autant que le démarrage. La valeur retombe sur `enervision.local`, et `stack-up` vérifie à la place que le certificat présent couvre l'hôte demandé, ce qui est la condition réelle à tenir. La CSP `script-src 'self'` bloquait le gestionnaire `onload` que l'inlining du CSS critique d'Angular pose sur la feuille de styles : l'application se serait affichée sans style derrière le proxy. `inlineCritical` passe à faux, le build de production ne produit plus aucun script en ligne. La zone de limitation resserrée ne couvre plus que les routes qui vérifient un secret. Derrière le NAT de l'école, où une seule adresse porte toute la promotion, `/auth/me` et `/auth/refresh` y auraient produit des 429 en usage normal. Enfin `certbot/certbot` est épinglé en v5.8.0 pour que Dependabot puisse le suivre, le proxy attend une API saine plutôt que démarrée, et la redirection vers `$host` est actée comme risque accepté : figer un nom canonique couperait l'accès par adresse IP, seule voie ouverte sur la machine cible. --- Makefile | 18 ++++++++------ README.md | 3 ++- apps/frontend/angular.json | 5 ++++ docker-compose.prod.yml | 14 +++++++---- ...-terminaison-tls-et-reverse-proxy-nginx.md | 24 ++++++++++++++++++- docs/architecture/30-frontend.md | 7 ++++++ infra/proxy/README.md | 13 ++++++++-- infra/proxy/conf.d/enervision.conf | 6 ++++- 8 files changed, 73 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 763e5f5..5be293d 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ AIRFLOW := etl/airflow COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml # Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose. -# Le `ifdef` évite d'exporter une valeur vide, qui masquerait alors celle du fichier `.env`. -ifdef PUBLIC_HOST +# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`. +PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1) +PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local) export PUBLIC_HOST -endif ifdef ACME_EMAIL export ACME_EMAIL endif @@ -119,11 +119,13 @@ docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser - PUBLIC_HOST=$${PUBLIC_HOST:-enervision.local} ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) + ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) -stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... requis +stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin @test -f infra/proxy/tls/fullchain.pem \ || { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; } + @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \ + || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; } $(COMPOSE_PROD) up -d --build stack-down: ## Arrête la stack complète en conservant les données @@ -132,9 +134,11 @@ stack-down: ## Arrête la stack complète en conservant les données stack-logs: ## Suit les journaux du reverse proxy $(COMPOSE_PROD) logs -f proxy -tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST et ACME_EMAIL requis +tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis + @test "$(PUBLIC_HOST)" != enervision.local \ + || { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; } $(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \ - -d $${PUBLIC_HOST:?PUBLIC_HOST=... requis} \ + -d $(PUBLIC_HOST) \ --email $${ACME_EMAIL:?ACME_EMAIL=... requis} \ --agree-tos --no-eff-email --deploy-hook /deploy-hook.sh $(COMPOSE_PROD) exec proxy nginx -s reload diff --git a/README.md b/README.md index 7e1181e..e560c94 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ curl -s localhost:8000/api/v1/health/ready ## Stack complète derrière le reverse proxy -Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine : +Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine. +L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** : ```bash make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 981779f..8e508c2 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -34,6 +34,11 @@ }, "configurations": { "production": { + "optimization": { + "styles": { + "inlineCritical": false + } + }, "budgets": [ { "type": "initial", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4f2d425..868ae17 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,6 +3,8 @@ # rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide. # Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul # moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy. +# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour +# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi. name: enervision @@ -35,8 +37,8 @@ services: APP_ENV: prod APP_DEBUG: "false" APP_TRUST_PROXY_HEADERS: "true" - APP_CORS_ORIGINS: https://${PUBLIC_HOST:?PUBLIC_HOST est requis pour la stack complète} - APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST}/reset-password + APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local} + APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password frontend: ports: !reset null @@ -44,8 +46,10 @@ services: proxy: image: nginx:1.28-alpine depends_on: - - backend - - frontend + backend: + condition: service_healthy + frontend: + condition: service_started ports: - "80:80" - "443:443" @@ -57,7 +61,7 @@ services: restart: unless-stopped certbot: - image: certbot/certbot + image: certbot/certbot:v5.8.0 profiles: ["acme"] volumes: - letsencrypt:/etc/letsencrypt diff --git a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md index 1d5eab4..014f95e 100644 --- a/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md +++ b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md @@ -73,8 +73,30 @@ Swagger, il publiera les métriques avec. `$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP du proxy, c'est-à-dire globalement. +- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire + `request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de + `request.client` du backend, en dernier recours quand l'en-tête est absent. - Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20 - requêtes par seconde sur l'API, 30 par minute sur `/api/v1/auth/`. + requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret, + `login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en + sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule + adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration. +- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires + d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela : + ``. La feuille serait restée en + `media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans + `angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de + composants injectés à l'exécution. +- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient + donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être + amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique + couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`. +- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle + sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe + donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent + couvre l'hôte demandé, ce qui est la condition réelle à tenir. +- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend + sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502. - La ligne API8 transport de `owasp-traceabilite.md` se referme. - **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index ee0b6f8..14a7012 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -134,6 +134,13 @@ Compose. `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le rafraîchissement sur 401. Détail complet dans [31-contrat-authentification.md](31-contrat-authentification.md). +- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les + gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un + (``), ce qui aurait laissé l'application sans + style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration + de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier + affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à + l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire. ## Tests diff --git a/infra/proxy/README.md b/infra/proxy/README.md index cb33d5f..558e8b9 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -12,15 +12,22 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la configuration est montée en volume par `docker-compose.prod.yml`. +L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4 +ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports. + ## Routage | Chemin | Destination | Remarque | |---|---|---| | `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS | -| `/api/v1/auth/` | `backend:8000` | Limitation de débit resserrée, 30 requêtes par minute | -| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel | +| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute | +| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde | | `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues | +La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh` +partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une +seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal. + L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le proxy demanderait une authentification propre, qui n'est pas la leur. @@ -68,6 +75,8 @@ greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ## Vérifier la configuration sans démarrer la stack +`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin. + ```bash docker run --rm \ -v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \ diff --git a/infra/proxy/conf.d/enervision.conf b/infra/proxy/conf.d/enervision.conf index cbadfaa..d3f067e 100644 --- a/infra/proxy/conf.d/enervision.conf +++ b/infra/proxy/conf.d/enervision.conf @@ -5,6 +5,8 @@ # Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de # nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la # variable et le résolveur interne de Docker : la résolution redevient dynamique. +# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de +# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007. server { listen 80 default_server; @@ -46,7 +48,9 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 60s; - location /api/v1/auth/ { + # Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de + # l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page. + location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ { limit_req zone=auth burst=20 nodelay; set $cible_api http://backend:8000; proxy_pass $cible_api$request_uri; From ae58a896d974a03a0e72f7f68e3a0588e5dad029 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 12:13:55 +0200 Subject: [PATCH 200/205] =?UTF-8?q?feat(etl):=20ordonnance=20la=20d=C3=A9t?= =?UTF-8?q?ection=20d'alertes=20et=20les=20recommandations=20par=20un=20DA?= =?UTF-8?q?G=20Airflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le DAG `alertes` enchaîne `app.detection.internal_alerts` puis `app.cli generate-recommendations`, à la quinzième minute de chaque heure. Le décalage laisse finir `ml_score`, qui écrit à l'heure pile les prédictions dont la règle `anomaly` a besoin, sans créer de dépendance entre les deux DAGs : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais entraîné ne doit pas priver le parc de ses alertes. L'image Airflow porte un second environnement uv, `/opt/backend/.venv`, puisque la logique vit dans le backend (ADR 0006) et qu'aucune route HTTP ne l'expose. Le `UV_PROJECT_ENVIRONMENT` global hérité de l'issue #115 disparaît : il vaut pour tous les projets, donc `uv run` depuis `/opt/ml` résolvait le venv du backend. uv prend `/.venv` par défaut, se placer dans le dossier suffit. La CI vérifie maintenant que les deux environnements s'importent sans réseau. Le conteneur reçoit `DATABASE_URL` en asyncpg et une `APP_SECRET_KEY` distincte de celle de l'API, alimentée par `AIRFLOW_APP_SECRET_KEY` : la détection ne signe aucun jeton, et Airflow permet d'exécuter du code depuis son interface. --- .env.example | 5 +++ .github/workflows/airflow.yml | 20 +++++++++-- Makefile | 5 ++- docker-compose.yml | 7 ++++ etl/airflow/Dockerfile | 26 ++++++++++---- etl/airflow/dags/alertes.py | 65 ++++++++++++++++++++++++++++++++++ etl/airflow/tests/test_dags.py | 64 +++++++++++++++++++++++++++++---- 7 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 etl/airflow/dags/alertes.py diff --git a/.env.example b/.env.example index e0abef3..b3ff600 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,8 @@ AIRFLOW_ADMIN_USERNAME=admin # comptes `app_user` d'EnerVision. AIRFLOW_ADMIN_PASSWORD=change_me AIRFLOW_ADMIN_EMAIL=admin@enervision.fr +# `APP_SECRET_KEY` du backend, que le DAG `alertes` lance en sous-processus. Distincte de +# celle de l'API : la détection ne signe aucun jeton, et Airflow exécute du code depuis son +# interface (cf. ADR 0008). Générer la vôtre : +# python -c "import secrets; print(secrets.token_urlsafe(48))" +AIRFLOW_APP_SECRET_KEY=change_me diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index a79f97f..9616da2 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -4,8 +4,9 @@ name: Airflow # (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de # ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). # -# Piège : l'image COPY les fichiers de dépendances et le code de ml/. Une modification de ml/ -# peut donc casser sa construction, d'où ces chemins dans les déclencheurs. +# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une +# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans +# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API. on: push: @@ -14,6 +15,9 @@ on: - "ml/pyproject.toml" - "ml/uv.lock" - "ml/enervision_ml/**" + - "apps/backend/pyproject.toml" + - "apps/backend/uv.lock" + - "apps/backend/app/**" - ".github/workflows/airflow.yml" pull_request: paths: @@ -21,6 +25,9 @@ on: - "ml/pyproject.toml" - "ml/uv.lock" - "ml/enervision_ml/**" + - "apps/backend/pyproject.toml" + - "apps/backend/uv.lock" + - "apps/backend/app/**" - ".github/workflows/airflow.yml" permissions: @@ -73,7 +80,7 @@ jobs: - name: Récupère le dépôt uses: actions/checkout@v4 - - name: Construit l'image (contexte à la racine, elle COPY ml/) + - name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/) run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . # Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent @@ -82,3 +89,10 @@ jobs: run: > docker run --rm --network none enervision-airflow:ci bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help" + + # `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et + # l'import du module prouve que l'environnement /opt/backend est complet. + - name: Vérifie que la détection d'alertes s'importe 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" diff --git a/Makefile b/Makefile index 66f4298..51b6e67 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ AIRFLOW := etl/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 recommendations \ + ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \ airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs help: ## Liste les cibles disponibles @@ -83,6 +83,9 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) +detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels + cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),) + recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),) diff --git a/docker-compose.yml b/docker-compose.yml index 5f6c9c0..73ffa64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,12 @@ x-airflow-common: &airflow-common # memes identifiants que le backend en attendant. ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db + # Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en + # dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`. + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + # Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et + # Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008). + APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-} volumes: - ./etl/airflow/dags:/opt/airflow/dags - ./etl/airflow/plugins:/opt/airflow/plugins @@ -129,6 +135,7 @@ 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}" + : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" exec airflow version airflow-webserver: diff --git a/etl/airflow/Dockerfile b/etl/airflow/Dockerfile index 058928f..b934588 100644 --- a/etl/airflow/Dockerfile +++ b/etl/airflow/Dockerfile @@ -1,7 +1,8 @@ -# Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14, -# distinct 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` en -# sous-processus. Airflow ne devient jamais un consommateur direct de LightGBM/MLflow. +# Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python +# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte +# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, +# `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un +# consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008. FROM apache/airflow:2.10.4-python3.12 # LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans @@ -16,7 +17,8 @@ RUN apt-get update \ # `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des # permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il # serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000). -RUN mkdir -p /opt/ml/state && chown -R airflow:root /opt/ml +# `/opt/backend` ne porte aucun volume, mais `WORKDIR` le creerait root meme sous `USER airflow`. +RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend USER airflow # L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de @@ -24,9 +26,10 @@ USER airflow # (apps/backend/Dockerfile). COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv +# Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run` +# dans l'un resoudrait le venv de l'autre. Par defaut, uv prend `/.venv`, donc le bon. ENV UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy \ - UV_PROJECT_ENVIRONMENT=/opt/ml/.venv + UV_LINK_MODE=copy WORKDIR /opt/ml @@ -36,4 +39,13 @@ RUN uv sync --locked --no-install-project --no-dev COPY --chown=airflow:root ml/enervision_ml ./enervision_ml RUN uv sync --locked --no-dev +WORKDIR /opt/backend + +# `packages = ["app"]` : le reste de apps/backend (alembic, tests) n'a rien a faire dans l'image. +COPY --chown=airflow:root apps/backend/pyproject.toml apps/backend/uv.lock ./ +RUN uv sync --locked --no-install-project --no-dev + +COPY --chown=airflow:root apps/backend/app ./app +RUN uv sync --locked --no-dev + WORKDIR /opt/airflow diff --git a/etl/airflow/dags/alertes.py b/etl/airflow/dags/alertes.py new file mode 100644 index 0000000..f221ab1 --- /dev/null +++ b/etl/airflow/dags/alertes.py @@ -0,0 +1,65 @@ +"""DAG de détection des alertes et de génération des recommandations (issue #116). + +Ordonnance ce que `docs/architecture/20-backend.md` et l'ADR 0006 décrivent encore comme lancé à +la main. Toute la logique reste dans `apps/backend`, ce DAG ne fait que l'appeler, sur le patron +de `ml_score` (cf. `docs/architecture/10-infra.md`, section Airflow, et l'ADR 0008 pour +l'environnement `/opt/backend` que l'image embarque désormais). + +Planifié à la quinzième minute plutôt qu'à l'heure pile : la règle `anomaly` compare une lecture +à la `prediction` du même instant, que `ml_score` (`@hourly`) vient d'écrire. Aucune dépendance +déclarée entre les deux DAGs pour autant, quatre règles sur cinq ne touchent pas au modèle et un +modèle jamais entraîné ne doit pas priver le parc de ses alertes. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.models.dag import DAG +from airflow.operators.bash import BashOperator + +# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et +# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +# Les deux tâches sont idempotentes en base (`ON CONFLICT DO NOTHING` sur +# `uq_alert_source_reference` et `uq_recommendation_alert_rule`) : reprendre ne duplique rien. +TENTATIVES = 2 +DELAI_ENTRE_TENTATIVES = timedelta(minutes=2) +# La somme des deux plafonds reste sous le pas horaire : une exécution pendue ne doit pas +# empiéter sur la suivante. +PLAFOND_PAR_TACHE = timedelta(minutes=15) + +with DAG( + dag_id="alertes", + description=( + "Détecte les alertes internes puis génère les recommandations " + "(app.detection.internal_alerts, app.cli)." + ), + schedule="15 * * * *", + start_date=datetime(2026, 1, 1), + catchup=False, + # Deux exécutions simultanées analyseraient la même fenêtre de 48h, et la génération relit + # l'intégralité de la table `alert` à chaque passage. + max_active_runs=1, + tags=["alertes"], +) as dag: + detection = BashOperator( + task_id="detection", + bash_command=f"{COMMANDE_BACKEND} app.detection.internal_alerts", + retries=TENTATIVES, + retry_delay=DELAI_ENTRE_TENTATIVES, + execution_timeout=PLAFOND_PAR_TACHE, + ) + + recommandations = BashOperator( + task_id="recommandations", + bash_command=f"{COMMANDE_BACKEND} app.cli generate-recommendations", + retries=TENTATIVES, + retry_delay=DELAI_ENTRE_TENTATIVES, + execution_timeout=PLAFOND_PAR_TACHE, + ) + + # `recommendation.alert_id` est une clé étrangère `NOT NULL` : la génération n'a rien à lire + # tant que la détection n'a pas écrit. + detection >> recommandations diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 58f6ab9..ccfbe5d 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -9,6 +9,14 @@ from airflow.models.dagbag import DagBag DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" +DAG_IDS = ["ml_train", "ml_score", "alertes"] +TACHES = [ + ("ml_train", "train"), + ("ml_score", "score"), + ("alertes", "detection"), + ("alertes", "recommandations"), +] + @pytest.fixture(scope="module") def dagbag() -> DagBag: @@ -20,7 +28,7 @@ def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None: def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None: - assert set(dagbag.dag_ids) == {"ml_train", "ml_score"} + assert set(dagbag.dag_ids) == set(DAG_IDS) def test_ml_train_has_no_schedule(dagbag: DagBag) -> None: @@ -32,6 +40,12 @@ def test_ml_score_runs_every_hour(dagbag: DagBag) -> None: assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *" +def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None: + # Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction` + # du meme instant, que `ml_score` ecrit a l'heure pile. + assert dagbag.dags["alertes"].timetable.summary == "15 * * * *" + + def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: tache = dagbag.dags["ml_train"].get_task("train") assert "enervision_ml.train" in tache.bash_command @@ -42,6 +56,28 @@ def test_ml_score_task_calls_the_scoring_module(dagbag: DagBag) -> None: assert "enervision_ml.score" in tache.bash_command +def test_alertes_detection_task_calls_the_backend_detection(dagbag: DagBag) -> None: + tache = dagbag.dags["alertes"].get_task("detection") + assert "app.detection.internal_alerts" in tache.bash_command + + +def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> None: + tache = dagbag.dags["alertes"].get_task("recommandations") + assert "app.cli generate-recommendations" in tache.bash_command + + +@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_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None: + # `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire + # tant que la detection n'a pas ecrit. + assert dagbag.dags["alertes"].get_task("detection").downstream_task_ids == {"recommandations"} + + def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None: entrainement = dagbag.dags["ml_train"].get_task("train").bash_command scoring = dagbag.dags["ml_score"].get_task("score").bash_command @@ -51,14 +87,14 @@ def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> N assert chemin_modele in scoring -@pytest.mark.parametrize("dag_id", ["ml_train", "ml_score"]) +@pytest.mark.parametrize("dag_id", DAG_IDS) def test_no_two_runs_of_a_dag_overlap(dagbag: DagBag, dag_id: str) -> None: # Deux entrainements ecriraient le meme fichier modele, deux scorings inseriraient en meme - # temps dans `prediction`. + # temps dans `prediction`, deux detections analyseraient la meme fenetre. assert dagbag.dags[dag_id].max_active_runs == 1 -@pytest.mark.parametrize(("dag_id", "task_id"), [("ml_train", "train"), ("ml_score", "score")]) +@pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_every_task_has_an_execution_timeout(dagbag: DagBag, dag_id: str, task_id: str) -> None: # Sans plafond, une connexion pendue immobilise un slot du scheduler indefiniment. assert dagbag.dags[dag_id].get_task(task_id).execution_timeout is not None @@ -70,13 +106,29 @@ def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag) assert timeout < timedelta(hours=1) +def test_alertes_execution_timeouts_stay_below_its_hourly_step(dagbag: DagBag) -> None: + # Les deux taches s'enchainent : c'est leur somme qui doit tenir dans le pas horaire. + plafonds = [ + dagbag.dags["alertes"].get_task(task_id).execution_timeout + for task_id in ("detection", "recommandations") + ] + assert all(plafond is not None for plafond in plafonds) + assert sum(plafonds, timedelta()) < timedelta(hours=1) + + def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None: assert dagbag.dags["ml_score"].get_task("score").retries >= 1 -@pytest.mark.parametrize(("dag_id", "task_id"), [("ml_train", "train"), ("ml_score", "score")]) +@pytest.mark.parametrize("task_id", ["detection", "recommandations"]) +def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str) -> None: + # Les deux commandes sont idempotentes en base, une reprise ne duplique rien. + assert dagbag.dags["alertes"].get_task(task_id).retries >= 1 + + +@pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_tasks_never_resync_the_baked_environment( dagbag: DagBag, dag_id: str, task_id: str ) -> None: - # Sans `--no-sync`, `uv run` reconstruit `enervision-ml` a chaque execution. + # Sans `--no-sync`, `uv run` reconstruit le projet a chaque execution. assert "--no-sync" in dagbag.dags[dag_id].get_task(task_id).bash_command From 26868801853bd83eb66a3085480383b7929d98de Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 12:14:06 +0200 Subject: [PATCH 201/205] =?UTF-8?q?docs:=20acte=20l'ordonnancement=20des?= =?UTF-8?q?=20alertes=20par=20l'ADR=200008=20et=20met=20=C3=A0=20jour=20le?= =?UTF-8?q?s=20vues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ADR 0008 décide qu'Airflow exécute le code du backend en sous-processus plutôt que d'appeler l'API, et assume ce que cela coûte : une image plus lourde, la CI Airflow déclenchée par les changements du backend, une clé applicative de plus. Les vues suivent. Trois DAGs dans 10-infra.md et dans la vue d'ensemble, avec le motif du décalage horaire. La détection n'est plus « lancée à la main » dans 20-backend.md. La génération des recommandations gagne son troisième déclencheur dans 40-data.md. La dette de cantonnement ETL et ML porte l'aggravation comme l'atténuation. L'affirmation selon laquelle `etl/airflow/` ne contient que des `.gitkeep`, fausse depuis l'issue #115, disparaît. L'index des décisions omettait les ADR 0005 et 0006, il les récupère au passage. --- README.md | 4 +- docs/README.md | 3 + ...0008-airflow-execute-le-code-du-backend.md | 79 +++++++++++++++++++ docs/architecture/00-vue-ensemble.md | 9 ++- docs/architecture/10-infra.md | 44 ++++++++--- docs/architecture/20-backend.md | 19 +++-- docs/architecture/40-data.md | 14 ++-- docs/architecture/README.md | 7 +- docs/architecture/owasp-traceabilite.md | 2 +- etl/README.md | 4 +- 10 files changed, 153 insertions(+), 32 deletions(-) create mode 100644 docs/adr/0008-airflow-execute-le-code-du-backend.md diff --git a/README.md b/README.md index 33426fb..ec5ffd9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | -| ETL | Apache Airflow | `etl/airflow` | A initialiser | +| ETL | Apache Airflow | `etl/airflow` | Trois DAGs | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | @@ -47,7 +47,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'ingestion et d'agregation +│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes) │ ├── plugins/ Operateurs et hooks maison │ ├── include/ Requetes SQL et ressources des DAGs │ └── tests/ Tests d'integrite des DAGs diff --git a/docs/README.md b/docs/README.md index 17859f5..bbd6978 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,3 +11,6 @@ | [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | | [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête | | [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | +| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global | +| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | +| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement | diff --git a/docs/adr/0008-airflow-execute-le-code-du-backend.md b/docs/adr/0008-airflow-execute-le-code-du-backend.md new file mode 100644 index 0000000..98e50c1 --- /dev/null +++ b/docs/adr/0008-airflow-execute-le-code-du-backend.md @@ -0,0 +1,79 @@ +# 0008 - Airflow exécute le code du backend en sous-processus + +- Statut : accepté +- Date : 2026-09-21 + +## Contexte + +L'issue #116 demande un DAG d'alertes. Ce qu'il a à ordonnancer existe déjà et n'est pas à +réécrire : `AlertService.detect()` et ses cinq règles (#104), puis le moteur de recommandations +(#38). Les deux vivent dans `apps/backend/app/`, et +l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a précisément décidé qu'ils y restent parce +qu'ils s'appuient sur les repositories ORM de l'API plutôt que sur du SQL brut. Les deux +sont décrits par la documentation comme « lancés à la main ». + +L'image Airflow livrée par #115 ne porte que `ml/`, dans un environnement `uv` distinct +(`/opt/ml/.venv`, Python 3.14) de celui d'Airflow lui-même (Python 3.12, contraint par +apache-airflow 2.10). Les DAGs `ml_train` et `ml_score` shellent vers cet environnement. Rien +d'équivalent n'existe pour `apps/backend` : un `BashOperator` sur +`python -m app.detection.internal_alerts` échouerait en `ModuleNotFoundError`. + +## Décision + +**L'image Airflow porte un troisième environnement, `/opt/backend/.venv`**, construit depuis le +`pyproject.toml`, le `uv.lock` et le paquet `app/` du backend. Le DAG `alertes` shelle vers lui +exactement comme `ml_score` shelle vers `/opt/ml/.venv`. + +Trois raisons : + +- **Le patron existe et vient d'être revu.** #115 a posé `BashOperator` + `uv run --no-sync` + + `env -u VIRTUAL_ENV`, avec les tests d'intégrité qui le verrouillent. Introduire une seconde + forme d'appel dans le même dossier `dags/` coûterait plus cher à lire qu'un second environnement + dans le même `Dockerfile`. +- **Aucune surface réseau n'est ajoutée.** La détection n'a pas de route HTTP, contrairement à la + génération de recommandations (`POST /recommendations/generate`, rôle `admin`). En créer une pour + qu'Airflow l'appelle donnerait à l'ordonnanceur un compte administrateur de l'API, en plus des + identifiants PostgreSQL complets qu'il détient déjà, et ferait dépendre la production d'alertes + de la disponibilité du conteneur `backend`. +- **La logique reste où l'ADR 0006 l'a mise.** Le DAG n'apprend rien du domaine : ni les seuils, ni + les cinq règles, ni les clés d'idempotence. Il ne sait que l'heure à laquelle appeler. + +## Conséquences + +- **Airflow reçoit une `APP_SECRET_KEY` délibérément distincte de celle de l'API.** La + configuration du backend refuse de se construire sans elle (`app/core/config.py`), et + `internal_alerts.main()` appelle `get_settings()` avant toute requête pour échouer tôt. Mais la + détection ne signe ni ne vérifie aucun jeton, et Airflow permet d'exécuter du code arbitraire + depuis son interface : un Airflow compromis ne doit pas livrer la clé de signature des JWT. D'où + `AIRFLOW_APP_SECRET_KEY`, avec sa propre garde dans `airflow-init`. +- **`DATABASE_URL`, en dialecte asyncpg, rejoint `ML_DATABASE_URL`** dans l'environnement du + conteneur. Le cantonnement des rôles PostgreSQL reste la dette de + l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md), et cette décision l'alourdit d'un + consommateur de plus. +- **La CI Airflow se déclenche sur les changements du backend.** L'image le `COPY` : sans + `apps/backend/app/**`, `pyproject.toml` et `uv.lock` dans les déclencheurs du workflow, une + dépendance modifiée casserait la construction sans que rien ne le signale avant le déploiement. + En contrepartie, l'image grossit de ce que pèsent SQLAlchemy, asyncpg et pandas. +- **Aucune variable ne départage les deux environnements, et c'est voulu.** `uv` place par défaut + le venv d'un projet dans `/.venv` : `cd /opt/ml` ou `cd /opt/backend` suffit à choisir le + bon. L'image ne pose donc plus de `UV_PROJECT_ENVIRONMENT` global, hérité de #115 : il vaudrait + pour les deux projets, et `uv run` dans l'un résoudrait le venv de l'autre. Le symptôme n'est pas + une construction ratée mais un `ModuleNotFoundError` à la première tâche, d'où la vérification + d'import sans réseau que la CI fait maintenant sur chacun des deux. +- Airflow lui-même reste étranger au domaine : ni LightGBM, ni SQLAlchemy, ni FastAPI n'entrent + dans son interpréteur. C'est la propriété que #115 avait établie, et elle tient toujours. + +## Alternatives écartées + +- **Route HTTP `POST /alerts/detect` réservée `admin`, appelée par le DAG.** L'image ne bougeait + pas, mais Airflow détenait alors un compte administrateur de l'API, la détection devenait + tributaire du conteneur `backend`, et l'API gagnait une route d'écriture dont aucun client + humain n'a l'usage. À rouvrir si un jour un tiers doit déclencher la détection. +- **`DockerOperator` lançant l'image du backend.** Demande la socket Docker de l'hôte dans le + conteneur Airflow, c'est-à-dire un équivalent root sur la machine, pour un service qui permet + déjà d'exécuter du code depuis son interface. Le provider n'est d'ailleurs pas installé. +- **Réécrire les cinq règles en SQL dans le DAG.** Contredit frontalement l'ADR 0006, duplique le + domaine, et fait diverger les deux copies au premier changement de seuil. +- **Monter `apps/backend` en volume plutôt que le copier.** L'environnement ne serait plus figé à + la construction, `uv` resynchroniserait au premier lancement, et la CI ne prouverait plus rien + de ce qui tourne réellement. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index b031a50..5eda630 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -67,9 +67,10 @@ 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 : deux DAGs orchestrent l'entraînement et -le scoring du modèle ML (issue #115), cf. plus bas et [20-backend.md](20-backend.md). Le reste du -périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit. +Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour +l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la +génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le +reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit. Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -84,7 +85,7 @@ collecteur ne vient le lire. | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | 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. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG | +| 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` | `Cible` | Rien | ## Flux bout en bout diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 1c44eb0..a74855a 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -50,7 +50,7 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin - `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). -### Airflow (`ml_train`/`ml_score`, issue #115) +### Airflow (issues #115 et #116) Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make airflow-up`, pas dans `make dev`) : @@ -59,13 +59,30 @@ airflow-up`, pas dans `make dev`) : |---|---|---| | `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | | `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | -| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --frozen --no-dev python -m enervision_ml...`), c'est lui qui a besoin du volume `airflow_ml_state` | +| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` | Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : -l'image doit pouvoir `COPY` `ml/pyproject.toml`/`ml/uv.lock`/`ml/enervision_ml` pour se -synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync --locked` à la -construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers -ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow. +l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser +deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à +la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent +vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow. +Le choix et ses contreparties sont dans +l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). + +| DAG | Planification | Ce qu'il lance, et où | +|---|---|---| +| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` | +| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` | +| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` | + +**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la +`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring +finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni +tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais +entraîné ne doit pas priver le parc de ses alertes. Ses deux tâches s'enchaînent en revanche +(`recommendation.alert_id` est une clé étrangère `NOT NULL`), et toutes deux sont rejouables sans +risque : l'idempotence est portée par la base, `uq_alert_source_reference` et +`uq_recommendation_alert_rule`. `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 @@ -76,7 +93,15 @@ 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 ou mot de passe vides). +`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou +`AIRFLOW_APP_SECRET_KEY` vides). + +Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en +dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est +**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire +sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet +déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature +des JWT. **Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un @@ -85,8 +110,9 @@ déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `ma (pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes. CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et -tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/`, une modification de `ml/` -peut donc la casser) avant de vérifier que le pipeline s'y importe sans réseau. +tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une +modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs) +avant de vérifier que les deux environnements s'y importent sans réseau. Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier `make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index b9abfa4..2c52815 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -256,12 +256,19 @@ auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spi explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul instant réel, pas une variation). -La détection est un script lancé à la main, pas encore ordonnancé par Airflow (contrairement à -`enervision_ml.score`, orchestré par le DAG `ml_score` depuis l'issue #115) : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans -`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une -connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104) -débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait -jusqu'ici rien à référencer côté `source="enervision"`. +La détection s'exécute dans `apps/backend`, puisque les règles s'appuient sur les repositories ORM +de l'API plutôt que sur une connexion SQL directe (contrairement à +`app/etl/historical_import.py`) : `uv run python -m app.detection.internal_alerts [--site-id ...] +[--now ...]`, ou `make detect-alerts`. Cette issue (#104) débloquait #38 (moteur de règles pour +recommandations), dont la FK `alert_id` `NOT NULL` n'avait jusqu'ici rien à référencer côté +`source="enervision"`. + +Depuis l'issue #116, le lancement n'est plus manuel : le DAG Airflow `alertes` enchaîne cette +détection et la génération des recommandations, toutes les heures à la quinzième minute. Airflow +exécute le code du backend en sous-processus, dans son propre environnement, ce que décide +l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md) ; le détail de l'ordonnancement est +dans [10-infra.md](10-infra.md). La ligne de commande reste le moyen de rejouer une fenêtre +passée, ce que `--now` permet et que le DAG ne fait pas. ### `/health/ready` diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 47fdc8a..82f769b 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -14,8 +14,10 @@ décrivent les éléments prévus mais pas encore réalisés. L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir -l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). L'orchestration Airflow, les -agrégats continus, la compression et la rétention restent des cibles. +l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`, +elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes` +(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la +rétention restent des cibles. ## Trois emplacements, trois rôles @@ -290,10 +292,10 @@ Les anomalies historiques décrites dans les JSON sont conservées dans `dataset Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles. 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` -ou par `make recommendations`, à partir des alertes déjà en base. Le couple -`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune -ligne. +(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`, +par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en +base. Le couple `(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes +n'ajoute aucune ligne. ### Relations entre les tables diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 78a82c9..1e3a260 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -17,8 +17,11 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents -ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en -sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne. +ci-dessus, tant que `monitoring/` ne contient que des `.gitkeep`. Elles en sortiront le jour où +elles auront de la matière. Un fichier vide de plus n'aide personne. + +L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et +leurs contraintes sont décrits dans [10-infra.md](10-infra.md). La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans [00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 132e107..967791d 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -57,7 +57,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | | **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | | **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | -| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv`. | +| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. | | **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. | ## Ce qu'il faut répondre, et ne pas répondre diff --git a/etl/README.md b/etl/README.md index b442ae3..698ffca 100644 --- a/etl/README.md +++ b/etl/README.md @@ -663,8 +663,8 @@ 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`), mais il orchestre pour l'instant le pipeline ML (`ml_train`/`ml_score`, issue #115), pas encore ces deux imports : orchestrer `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) reste à faire. +Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire. -Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py` et `ml_score.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel). +Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. From f8d08c86863682abf13a757cdebc2e699eb69794 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 13:28:23 +0200 Subject: [PATCH 202/205] fix(etl): borne le DAG alertes sur son pire cas et couvre sa seconde commande en CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execution_timeout` plafonne une tentative, pas la tâche. Avec deux reprises, quinze minutes par tentative autorisaient quarante-neuf minutes par tâche et quatre-vingt-dix-huit pour l'enchaînement, quand le commentaire annonçait une somme tenant sous le pas horaire. Le plafond passe à cinq minutes, ce qui borne le pire cas à trente-huit minutes, et le test d'intégrité calcule désormais ce pire cas plutôt que la somme des plafonds : reprises et délais d'attente compris, c'est la durée qu'un `max_active_runs=1` fait payer à l'exécution suivante. La CI vérifie aussi `app.cli generate-recommendations --help` sans réseau. C'est la seconde commande du DAG, et son import tire FastAPI, les repositories et les services, donc une part de l'environnement `/opt/backend` que la détection seule ne touche pas. `10-infra.md` nomme enfin ce que le décalage de quinze minutes ne garantit pas : le plafond de `ml_score` valant trente minutes, un scoring qui déborde prive la règle `anomaly` de la prédiction de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre la couvre encore. --- .github/workflows/airflow.yml | 9 ++++++--- docs/architecture/10-infra.md | 15 +++++++++++---- etl/airflow/dags/alertes.py | 6 +++--- etl/airflow/tests/test_dags.py | 21 ++++++++++++++------- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index 9616da2..d5a722a 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -91,8 +91,11 @@ 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. - - name: Vérifie que la détection d'alertes s'importe sans réseau + # l'import du module prouve que l'environnement /opt/backend est complet. Les deux + # commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui. + - name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau run: > docker run --rm --network none enervision-airflow:ci - bash -c "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help" + 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" diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index b3ecfd2..c6121e7 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -80,10 +80,17 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). `prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais -entraîné ne doit pas priver le parc de ses alertes. Ses deux tâches s'enchaînent en revanche -(`recommendation.alert_id` est une clé étrangère `NOT NULL`), et toutes deux sont rejouables sans -risque : l'idempotence est portée par la base, `uq_alert_source_reference` et -`uq_recommendation_alert_rule`. +entraîné ne doit pas priver le parc de ses alertes. Le décalage est donc une convention et non une +garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive +`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre +la couvre encore. Les quatre autres règles ne s'en aperçoivent pas. + +Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT +NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base, +`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes +d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises, +l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1` +rend contraignant. `airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une diff --git a/etl/airflow/dags/alertes.py b/etl/airflow/dags/alertes.py index f221ab1..4c043d2 100644 --- a/etl/airflow/dags/alertes.py +++ b/etl/airflow/dags/alertes.py @@ -26,9 +26,9 @@ COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync pytho # `uq_alert_source_reference` et `uq_recommendation_alert_rule`) : reprendre ne duplique rien. TENTATIVES = 2 DELAI_ENTRE_TENTATIVES = timedelta(minutes=2) -# La somme des deux plafonds reste sous le pas horaire : une exécution pendue ne doit pas -# empiéter sur la suivante. -PLAFOND_PAR_TACHE = timedelta(minutes=15) +# `execution_timeout` vaut par tentative : c'est le pire cas des deux tâches enchaînées, reprises +# et délais compris, qui doit tenir sous le pas horaire. Les tests d'intégrité en font le calcul. +PLAFOND_PAR_TACHE = timedelta(minutes=5) with DAG( dag_id="alertes", diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index ccfbe5d..555849d 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -5,6 +5,7 @@ from datetime import timedelta from pathlib import Path import pytest +from airflow.models.baseoperator import BaseOperator from airflow.models.dagbag import DagBag DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" @@ -106,14 +107,20 @@ def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag) assert timeout < timedelta(hours=1) -def test_alertes_execution_timeouts_stay_below_its_hourly_step(dagbag: DagBag) -> None: - # Les deux taches s'enchainent : c'est leur somme qui doit tenir dans le pas horaire. - plafonds = [ - dagbag.dags["alertes"].get_task(task_id).execution_timeout - for task_id in ("detection", "recommandations") +def duree_au_pire(tache: BaseOperator) -> timedelta: + # `execution_timeout` plafonne une tentative, pas la tache : deux reprises occupent trois + # plafonds et deux delais d'attente. + assert tache.execution_timeout is not None + return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay + + +def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None: + # Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le + # pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante. + taches = [ + dagbag.dags["alertes"].get_task(task_id) for task_id in ("detection", "recommandations") ] - assert all(plafond is not None for plafond in plafonds) - assert sum(plafonds, timedelta()) < timedelta(hours=1) + assert sum((duree_au_pire(tache) for tache in taches), timedelta()) < timedelta(hours=1) def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None: From 62d81e901da58bbca2d343474965fc41fc00f253 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 13:41:17 +0200 Subject: [PATCH 203/205] =?UTF-8?q?fix(ci,docs):=20l=C3=A8ve=20les=20point?= =?UTF-8?q?s=20de=20revue=20du=20SAST=20et=20de=20la=20vue=20CI/CD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ML-START.md affirmait que l'orchestration Airflow n'existait pas : `ml_score` tourne en `@hourly` depuis l'issue #115, seuls le mode `--csv` et un lancement local restent manuels. 50-cicd.md : Dependabot compte six entrées sur cinq écosystèmes et non cinq entrées, le filtre d'`airflow.yml` couvre aussi `apps/backend/` depuis le DAG `alertes`, et les issues #21 (job de déploiement) et #22 (secrets) sont distinguées au lieu d'être citées l'une pour l'autre. Le `continue-on-error` du second passage Bandit est nommé pour ce qu'il est : le job reste vert même avec un constat LOW. Bandit est épinglé à 1.9.4 dans les deux jobs `sast` : sans épingle, une nouvelle version passe la CI au rouge sans qu'une ligne du dépôt ait changé, et le rejeu à l'identique documenté n'existe pas. Le `cache-dependency-glob` part : `uvx` n'installe pas le projet, le verrou n'alimentait aucune clé de cache. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/backend.yml | 9 ++++---- .github/workflows/ml.yml | 9 ++++---- docs/ML-START.md | 7 +++--- docs/architecture/50-cicd.md | 40 +++++++++++++++++++++++------------ 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index f02eef8..34837cd 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -152,18 +152,17 @@ jobs: - name: Récupère le dépôt uses: actions/checkout@v4 + # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc + # aucune clé de cache ; la seule roue téléchargée est celle de Bandit. - name: Installe uv uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: apps/backend/uv.lock # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. - name: Analyse le code livré (bloquant à partir de MEDIUM) - run: uvx bandit --recursive app --severity-level medium --confidence-level medium + run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium # Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. - name: Rapport complet, tous niveaux continue-on-error: true - run: uvx bandit --recursive app + run: uvx bandit==1.9.4 --recursive app diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml index 4700a97..00189e2 100644 --- a/.github/workflows/ml.yml +++ b/.github/workflows/ml.yml @@ -69,15 +69,14 @@ jobs: - name: Récupère le dépôt uses: actions/checkout@v4 + # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc + # aucune clé de cache ; la seule roue téléchargée est celle de Bandit. - name: Installe uv uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: ml/uv.lock - name: Analyse le code livré (bloquant à partir de MEDIUM) - run: uvx bandit --recursive enervision_ml --severity-level medium --confidence-level medium + run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium - name: Rapport complet, tous niveaux continue-on-error: true - run: uvx bandit --recursive enervision_ml + run: uvx bandit==1.9.4 --recursive enervision_ml diff --git a/docs/ML-START.md b/docs/ML-START.md index e4d9f93..68518ac 100644 --- a/docs/ML-START.md +++ b/docs/ML-START.md @@ -161,9 +161,10 @@ flowchart LR `prediction` et leurs contraintes de cohérence, vérifiables en SQL. Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est -celle du dernier run de scoring. Tant que l'orchestration Airflow n'existe pas (`etl/airflow/` est -vide), ce run est lancé à la main. C'est la dette la plus visible du module, et elle est portée -par les issues #44 et #45. +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. --- diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 5b307c3..79313a3 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -60,12 +60,17 @@ flowchart TB 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/**` **et sur `ml/**`**, chacun incluant son propre fichier de -workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline. +`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun +incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline +déclenche le pipeline. -Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock` et -`ml/enervision_ml/**` parce que l'image Airflow copie le code et les dépendances du module ML. -Une modification de `ml/` peut donc casser la construction de cette image, et le filtre le voit. +Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, +`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et +`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux +modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes` +y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). +Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre +le voit. **Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est @@ -102,9 +107,14 @@ Deux seuils portent une décision qu'il faut savoir défendre : dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les `moderate` sont invisibles en CI, et qu'elles se regardent à la main. - **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW - sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Au + sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le + revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert** + même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au 21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes analysées. +- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle, + une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et + le rejeu à l'identique promis plus bas n'existerait pas. ## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres` @@ -142,10 +152,11 @@ contournée** en désactivant la gate ou en excluant les fichiers gênants. ## Dependabot -`.github/dependabot.yml` déclare **cinq entrées hebdomadaires groupées** : `npm` sur -`/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, et `docker` sur les deux -dossiers d'application. Les mises à jour arrivent en PR, donc elles traversent les mêmes gates que -n'importe quel changement : une montée de version qui casse les tests ne se merge pas. +`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** : +`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les +deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc +elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse +les tests ne se merge pas. ## Stratégie de branche et conventions @@ -163,7 +174,9 @@ n'importe quel changement : une montée de version qui casse les tests ne se mer Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions. Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le -run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement (issue #22). +run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de +déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par +l'issue #22. ## Ce qui manque, et pourquoi @@ -181,5 +194,6 @@ run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploi `verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration demandent une base : `make db-up` puis `uv run pytest -m integration`. -Le SAST se rejoue à l'identique : `uvx bandit --recursive app --severity-level medium ---confidence-level medium` depuis `apps/backend`. +Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium +--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis +`ml`. From 2adfdf0eb0e47ea1f0d622b6a71cc58e1928f2b6 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 14:08:04 +0200 Subject: [PATCH 204/205] fix(backend): supprime les vulnerabilites Sonar du Dockerfile et allege les tests d'exception --- apps/backend/Dockerfile | 9 +- apps/backend/tests/db/test_data_schema.py | 48 +- .../tests/etl/test_historical_import.py | 484 +++++++++--------- .../tests/repositories/test_audit_log.py | 4 +- .../repositories/test_password_reset_token.py | 7 +- .../tests/repositories/test_refresh_token.py | 10 +- apps/backend/tests/repositories/test_user.py | 12 +- apps/backend/tests/services/test_reading.py | 10 +- apps/backend/tests/services/test_user.py | 4 +- apps/backend/tests/test_cli.py | 8 +- 10 files changed, 312 insertions(+), 284 deletions(-) diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index e152c5e..6a8a770 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -11,13 +11,14 @@ WORKDIR /app RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project --no-dev + uv sync --locked --no-install-project --no-dev --no-build +# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le +# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire +# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que +# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541). COPY . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev - FROM python:3.14-slim AS runtime diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py index c564042..52295aa 100644 --- a/apps/backend/tests/db/test_data_schema.py +++ b/apps/backend/tests/db/test_data_schema.py @@ -112,8 +112,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 +149,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 +169,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 +219,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 +264,8 @@ 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) 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/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_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_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_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_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( From 3c378c177fc9d4c9770ff9a293dbf32b1f7ef4a4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 14:12:38 +0200 Subject: [PATCH 205/205] ci(ml,etl): analyse ml/ et etl/airflow dans SonarCloud avec un rapport de couverture ML --- .github/workflows/sonarqube.yml | 39 ++++++++++++++++++++++- docs/architecture/50-cicd.md | 17 +++++++--- ml/README.md | 2 +- ml/pyproject.toml | 12 ++++++- ml/uv.lock | 55 +++++++++++++++++++++++++++++++++ sonar-project.properties | 10 +++--- 6 files changed, 124 insertions(+), 11 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index b5a07eb..e41f7e4 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" @@ -108,8 +112,36 @@ jobs: name: backend-coverage path: apps/backend/coverage.xml + test-ml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: ml/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + working-directory: ml + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + working-directory: ml + + - name: Lancement des tests et génération du rapport de couverture (ML) + run: uv run pytest --cov-report=xml + working-directory: ml + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: ml-coverage + path: ml/coverage.xml + sonarqube: - needs: [build-front, build-back, test-front, test-back] + needs: [build-front, build-back, test-front, test-back, test-ml] name: SonarQube runs-on: ubuntu-latest steps: @@ -126,6 +158,11 @@ jobs: with: name: backend-coverage path: apps/backend + - name: Téléchargement du rapport de couverture (ML) + uses: actions/download-artifact@v4 + with: + name: ml-coverage + path: ml - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v8 env: diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 79313a3..4fcd851 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -44,6 +44,7 @@ flowchart TB subgraph sq["SonarQube · sonarqube.yml"] sb1["build-front / test-front"] sb2["build-back / test-back"] + sb3["test-ml"] sscan["sonarqube
    quality gate SonarCloud"] end @@ -132,10 +133,18 @@ partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %. ## SonarCloud, et l'incident qui a immobilisé trois PR -Le workflow `sonarqube.yml` exécute 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 diff --git a/ml/README.md b/ml/README.md index 21ddd85..7fc09b3 100644 --- a/ml/README.md +++ b/ml/README.md @@ -103,7 +103,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`, 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/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/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