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/.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/.env.example b/.env.example new file mode 100644 index 0000000..e909bd1 --- /dev/null +++ b/.env.example @@ -0,0 +1,55 @@ +# 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 déjà pris par une autre base du poste. +POSTGRES_PORT=5433 +# `basic` renvoie des statistiques d'usage à Timescale. +TIMESCALEDB_TELEMETRY=off + +APP_ENV=local +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 +FRONTEND_PORT=3000 + +# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur. +MAILPIT_SMTP_PORT=1025 +MAILPIT_UI_PORT=8025 + +# API Mock EnerVision +APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr +APP_MOCK_API_USERNAME=change_me +APP_MOCK_API_PASSWORD=change_me +APP_MOCK_API_TIMEOUT_SECONDS=10 + +# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le +# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus. +AIRFLOW_PORT=8080 +# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +AIRFLOW_FERNET_KEY=change_me +# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la +# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" +AIRFLOW_WEBSERVER_SECRET_KEY=change_me +AIRFLOW_ADMIN_USERNAME=admin +# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les +# comptes `app_user` d'EnerVision. +AIRFLOW_ADMIN_PASSWORD=change_me +AIRFLOW_ADMIN_EMAIL=admin@enervision.fr +# `APP_SECRET_KEY` du backend, que le DAG `alertes` lance en sous-processus. Distincte de +# celle de l'API : la détection ne signe aucun jeton, et Airflow exécute du code depuis son +# interface (cf. ADR 0008). Générer la vôtre : +# python -c "import secrets; print(secrets.token_urlsafe(48))" +AIRFLOW_APP_SECRET_KEY=change_me + +# Stack complète derrière le reverse proxy (docker-compose.prod.yml). +# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat. +PUBLIC_HOST=enervision.local +ACME_EMAIL= diff --git a/.github/ISSUE_TEMPLATE/.gitkeep b/.github/ISSUE_TEMPLATE/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a925ee4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: + # Frontend — npm + - package-ecosystem: "npm" + directory: "/apps/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + frontend-dependencies: + patterns: + - "*" + + # Backend — uv (lit pyproject.toml / uv.lock) + - package-ecosystem: "uv" + directory: "/apps/backend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + backend-dependencies: + patterns: + - "*" + + # Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + # Si un Dockerfile existe pour le backend + - package-ecosystem: "docker" + directory: "/apps/backend" + schedule: + interval: "weekly" + + - package-ecosystem: "docker" + 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/.github/workflows/.gitkeep b/.github/workflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml new file mode 100644 index 0000000..d5a722a --- /dev/null +++ b/.github/workflows/airflow.yml @@ -0,0 +1,101 @@ +name: Airflow + +# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 +# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de +# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). +# +# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une +# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans +# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API. + +on: + push: + paths: + - "etl/airflow/**" + - "ml/pyproject.toml" + - "ml/uv.lock" + - "ml/enervision_ml/**" + - "apps/backend/pyproject.toml" + - "apps/backend/uv.lock" + - "apps/backend/app/**" + - ".github/workflows/airflow.yml" + pull_request: + paths: + - "etl/airflow/**" + - "ml/pyproject.toml" + - "ml/uv.lock" + - "ml/enervision_ml/**" + - "apps/backend/pyproject.toml" + - "apps/backend/uv.lock" + - "apps/backend/app/**" + - ".github/workflows/airflow.yml" + +permissions: + contents: read + +concurrency: + group: airflow-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint et intégrité des DAGs + runs-on: ubuntu-latest + defaults: + run: + working-directory: etl/airflow + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: etl/airflow/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + - name: Vérifie le formatage + run: uv run ruff format --check . + + - name: Analyse statique + run: uv run ruff check --output-format=github . + + # Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et + # vérifie import, planification, plafonds d'exécution et commande de chaque tâche. + - name: Tests d'intégrité des DAGs + run: uv run pytest + + image: + name: Construction de l'image + runs-on: ubuntu-latest + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/) + run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . + + # Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent + # (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé. + - name: Vérifie que le pipeline ML s'importe sans réseau + run: > + docker run --rm --network none enervision-airflow:ci + bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help" + + # `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et + # l'import du module prouve que l'environnement /opt/backend est complet. Les deux + # commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui. + - name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau + run: > + docker run --rm --network none enervision-airflow:ci + bash -c "cd /opt/backend + && env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help + && env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help" diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..34837cd --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,168 @@ +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 + + # 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 + + 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 + + sast: + name: Analyse statique de sécurité + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc + # aucune clé de cache ; la seule roue téléchargée est celle de Bandit. + - name: Installe uv + uses: astral-sh/setup-uv@v5 + + # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des + # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. + - name: Analyse le code livré (bloquant à partir de MEDIUM) + run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium + + # Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. + - name: Rapport complet, tous niveaux + continue-on-error: true + run: uvx bandit==1.9.4 --recursive app diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..daaa6e3 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,64 @@ +name: Frontend + +on: + push: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" + pull_request: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" + +permissions: + contents: read + +jobs: + build: + 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 + + security-audit: + name: Audit des dépendances + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison. + - run: npm audit --audit-level=high --package-lock-only + working-directory: apps/frontend + + test: + needs: build + 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 diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml new file mode 100644 index 0000000..00189e2 --- /dev/null +++ b/.github/workflows/ml.yml @@ -0,0 +1,82 @@ +name: ML + +# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf. +# .github/workflows/backend.yml, même contrainte). + +on: + push: + paths: + - "ml/**" + - ".github/workflows/ml.yml" + pull_request: + paths: + - "ml/**" + - ".github/workflows/ml.yml" + +permissions: + contents: read + +concurrency: + group: ml-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint, typage et tests + 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: 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 enervision_ml tests + + # Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees + # synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). + - 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 + + # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc + # aucune clé de cache ; la seule roue téléchargée est celle de Bandit. + - name: Installe uv + uses: astral-sh/setup-uv@v5 + + - name: Analyse le code livré (bloquant à partir de MEDIUM) + run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium + + - name: Rapport complet, tous niveaux + continue-on-error: true + run: uvx bandit==1.9.4 --recursive enervision_ml diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..e41f7e4 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,169 @@ +name: SonarQube + +on: + push: + 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" + + +# 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 + 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 + working-directory: apps/backend + + + 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 : Lancement des tests et génénration du rapport de couverture (Back) + 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.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, test-ml] + 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: 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: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc7162c --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +test-results/ +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 est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI +*.tfstate +*.tfstate.* +*.tfplan +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +*.tfvars +!*.tfvars.example +kubeconfig + +# 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/raw/* +!data/raw/.gitkeep +*.sqlite3 +monitoring/grafana/data/ +monitoring/prometheus/data/ + +# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux +ml/data/ +ml/models/* +!ml/models/.gitkeep +ml/mlruns/ +ml/mlartifacts/ +ml/mlflow.db + +# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests) +etl/airflow/tests/.airflow_home/ + +# TLS : certificats du reverse proxy, générés par script ou par certbot +infra/proxy/tls/*.pem + +# IDE et OS +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9d4beb4 --- /dev/null +++ b/Makefile @@ -0,0 +1,172 @@ +BACKEND := apps/backend +FRONTEND := apps/frontend +ML := ml +AIRFLOW := etl/airflow +COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml + +# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose. +# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`. +PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1) +PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local) +export PUBLIC_HOST +ifdef ACME_EMAIL +export ACME_EMAIL +endif + +.DEFAULT_GOAL := help +.PHONY: help install install-backend install-frontend install-ml install-airflow \ + dev dev-backend dev-frontend \ + lint format typecheck test test-cov test-integration check \ + openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ + ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \ + airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \ + tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs + +help: ## Liste les cibles disponibles + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow + +install-backend: ## Installe les dépendances du backend + cd $(BACKEND) && uv sync --all-groups + +install-frontend: ## Installe les dépendances du frontend + cd $(FRONTEND) && npm ci + +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 & \ + $(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 + cd $(BACKEND) && uv run ruff check . + +format: ## Formate et corrige le backend + cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix . + +typecheck: ## Vérifie le typage du backend + cd $(BACKEND) && uv run mypy app + +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 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: ## Exécute les tests exigeant une base joignable + cd $(BACKEND) && uv run pytest -m integration + +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 + +ml-lint: ## Analyse statique du pipeline ML + cd $(ML) && uv run ruff check . + +ml-typecheck: ## Vérifie le typage du pipeline ML + cd $(ML) && uv run mypy enervision_ml tests + +ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base ni serveur MLflow) + cd $(ML) && uv run pytest + +ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML + +ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL + cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),) + +ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel + cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) + +detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels + cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),) + +recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel + cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),) + +airflow-lint: ## Analyse statique des DAGs Airflow + cd $(AIRFLOW) && uv run ruff check . + +airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue + cd $(AIRFLOW) && uv run pytest + +airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow + +airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. + docker compose up -d airflow-init airflow-webserver airflow-scheduler + @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" + +airflow-down: ## Arrête le webserver et le scheduler Airflow + docker compose stop airflow-webserver airflow-scheduler + +airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) + docker compose logs -f airflow-scheduler + +docker-build: ## Construit l'image du backend + docker build -t enervision-backend:local $(BACKEND) + +tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser + ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) + +stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin + @test -f infra/proxy/tls/fullchain.pem \ + || { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; } + @openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \ + || { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; } + $(COMPOSE_PROD) up -d --build + +stack-down: ## Arrête la stack complète en conservant les données + $(COMPOSE_PROD) stop + +stack-logs: ## Suit les journaux du reverse proxy + $(COMPOSE_PROD) logs -f proxy + +tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis + @test "$(PUBLIC_HOST)" != enervision.local \ + || { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; } + $(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \ + -d $(PUBLIC_HOST) \ + --email $${ACME_EMAIL:?ACME_EMAIL=... requis} \ + --agree-tos --no-eff-email --deploy-hook /deploy-hook.sh + $(COMPOSE_PROD) exec proxy nginx -s reload + +tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy + $(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh + $(COMPOSE_PROD) exec proxy nginx -s reload + +db-up: ## Démarre la base PostgreSQL TimescaleDB + docker compose up -d db + +db-down: ## Arrête la base en conservant ses données + docker compose stop db + +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 + 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 + +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/README.md b/README.md index 7278dbd..cc139dc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,124 @@ # EnerVision -## Jalons définis -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 +Monorepo de la plateforme EnerVision : collecte, stockage, analyse et restitution de +series temporelles energetiques, deployee sur une machine on-premise. -## Outil de collaboration utilisé -GitHub +## 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 22, Node 24 LTS | `apps/frontend` | Tableau de bord | +| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | +| ETL | Apache Airflow | `etl/airflow` | Trois DAGs | +| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | +| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | +| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | +| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | +| ML | LightGBM, MLflow | `ml` | Entrainement initialise | + +Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend +sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints +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). + +## 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'orchestration (pipeline ML, alertes) +│ ├── plugins/ Operateurs et hooks maison +│ ├── include/ Requetes SQL et ressources des DAGs +│ └── tests/ Tests d'integrite des DAGs +├── infra/ +│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage +│ └── terraform/ +│ ├── modules/ Modules reutilisables +│ └── environments/ Racines Terraform, une par environnement +├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow +├── 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, 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 et du frontend +make migrate # applique les migrations Alembic +make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200 +make 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 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 : + +```bash +curl -s localhost:8000/api/v1/health/ready +``` + +## Stack complète derrière le reverse proxy + +Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine. +L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** : + +```bash +make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration +make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié +``` + +Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun +nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans +[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans +[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). + +## Conventions + +- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. +- 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/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..da73dbb --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,24 @@ +APP_ENV=local +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 + +# 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 +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/.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..6a8a770 --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,43 @@ +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 --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 + + +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..2aebb52 --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,159 @@ +# 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. + +`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`, +`make format`, `make typecheck`, `make test`, `make check`, `make openapi`, `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 +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). + +`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. + +## Structure + +``` +app/ +├── api/ +│ ├── 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 Agrégation des routes de la version 1 +│ └── endpoints/ Un module par ressource exposée +├── core/ +│ ├── config.py Settings Pydantic, source unique de configuration +│ ├── 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 +├── 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 +├── cli.py Commandes hors HTTP, dont l'amorcage du premier admin +└── 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 | 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/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` | +| `/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` | +| `/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 | + +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 + +```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`. + +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 +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/TESTING.md b/apps/backend/TESTING.md new file mode 100644 index 0000000..30fcc5a --- /dev/null +++ b/apps/backend/TESTING.md @@ -0,0 +1,193 @@ +# 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 + +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 + +`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. + +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 +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 +``` + +## 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 `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 +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/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..a1a4adc --- /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.replace("%", "%%")) + +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/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/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/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/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/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/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py new file mode 100644 index 0000000..8fb3694 --- /dev/null +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -0,0 +1,218 @@ +"""Création des six tables Data et de l'hypertable reading. + +Revision ID: e6d2026091501 +Revises: 821f71be74c0 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "e6d2026091501" +down_revision = "821f71be74c0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "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), + 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_dataset_positive_id"), + sa.PrimaryKeyConstraint("dataset_id"), + sa.UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), + ) + op.create_table( + "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), + 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( + "prediction", + 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_prediction_status", + ), + sa.CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_prediction_energy_period", + ), + sa.CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_prediction_metric" + ), + sa.CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["site.site_id"], name="fk_prediction_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("prediction_id"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), + ) + op.create_index( + "ix_prediction_site_target", "prediction", ["site_id", "target_at"], unique=False + ) + op.create_table( + "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.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_reading_dataset_source", + ), + sa.CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_reading_quality", + ), + sa.CheckConstraint( + "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_reading_imputation", + ), + sa.ForeignKeyConstraint( + ["dataset_id"], ["dataset.dataset_id"], name="fk_reading_dataset", ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["site.site_id"], name="fk_reading_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("reading_id", "timestamp"), + ) + op.create_index("ix_reading_dataset_id", "reading", ["dataset_id"], unique=False) + op.create_index( + "ix_reading_site_timestamp", "reading", ["site_id", "timestamp"], unique=False + ) + op.create_index( + "uq_reading_source", + "reading", + ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], + unique=True, + ) + op.execute( + "SELECT create_hypertable('reading', by_range('timestamp'), create_default_indexes => FALSE)" + ) + op.create_table( + "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), + 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_alert_severity" + ), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), + sa.CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" + ), + sa.ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["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" + ), + ) + op.create_index("ix_alert_site_timestamp", "alert", ["site_id", "timestamp"], unique=False) + op.create_table( + "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), + 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"], ["alert.alert_id"], name="fk_recommendation_alert", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("recommendation_id"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + 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/__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..6662f81 --- /dev/null +++ b/apps/backend/app/api/deps.py @@ -0,0 +1,298 @@ +# 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, 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.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 +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.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.prediction import PredictionRepository +from app.repositories.reading import ReadingRepository +from app.repositories.recommendation import RecommendationRepository +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.site import SiteRepository +from app.repositories.user import UserRepository +from app.services.alert import AlertService +from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy +from app.services.prediction import PredictionService +from app.services.reading import ReadingService +from app.services.recommendation import RecommendationService +from app.services.sensor import SensorService +from app.services.site import SiteService +from app.services.stats import StatsService +from app.services.user import UserService + +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_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), + attempts=LoginAttemptRepository(session), + refresh_tokens=RefreshTokenRepository(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, + ), + 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, + ) + + +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)] + + +def get_site_service(session: SessionDep) -> SiteService: + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] + + +def get_alert_service(session: SessionDep) -> AlertService: + return AlertService( + alerts=AlertRepository(session), + readings=ReadingRepository(session), + predictions=PredictionRepository(session), + sites=SiteRepository(session), + ) + + +AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] + + +def get_recommendation_service(session: SessionDep) -> RecommendationService: + return RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) + + +RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] + + +def get_stats_service(session: SessionDep) -> StatsService: + return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] + + +def get_reading_service(session: SessionDep) -> ReadingService: + return ReadingService(readings=ReadingRepository(session)) + + +ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)] + + +def get_sensor_service(session: SessionDep) -> SensorService: + return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)] + + +def get_prediction_service(session: SessionDep) -> PredictionService: + return PredictionService( + sites=SiteRepository(session), predictions=PredictionRepository(session) + ) + + +PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)] + + +async def get_current_principal( + credentials: CredentialsDep, + session: SessionDep, + 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") + # 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") + + 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/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/openapi.py b/apps/backend/app/api/openapi.py new file mode 100644 index 0000000..8ca8c08 --- /dev/null +++ b/apps/backend/app/api/openapi.py @@ -0,0 +1,186 @@ +# 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`.", + }, + { + "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`.", + }, + { + "name": "recommendations", + "description": ( + "Consultation des recommandations issues des alertes. Accessible à partir du rôle " + "`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." + ), + }, + { + "name": "stats", + "description": "Statistiques agrégées de consommation. Accessible à partir du rôle " + "`lecteur`.", + }, + { + "name": "readings", + "description": ( + "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, " + "24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du " + "rôle `lecteur`." + ), + }, + { + "name": "sensors", + "description": "État de santé des capteurs par site. Réservé au rôle `admin`.", + }, + { + "name": "predictions", + "description": ( + "Dernière prévision de consommation par site, calculée hors ligne par le pipeline " + "de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." + ), + }, +] + +cookie_de_rafraichissement = APIKeyCookie( + 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`." + ), + }, +} + +# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits +# insuffisants, seulement pour le mot de passe provisoire. +REPONSES_LECTEUR: Final[Reponses] = { + **REPONSES_AUTHENTIFIEES, + 403: { + "model": ErrorResponse, + "description": ( + "Mot de passe provisoire à changer (`detail` vaut `password_change_required`)." + ), + }, +} + +REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { + 403: { + "model": ErrorResponse, + "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/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/__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/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/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..9fd374f --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,365 @@ +# 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, BackgroundTasks, Depends, HTTPException, Request, Response, status + +from app.api.deps import ( + AuthServiceDep, + CurrentPrincipalDep, + SettingsDep, + get_client_ip, + require_trusted_origin, +) +from app.api.openapi import ( + REPONSE_LIMITE, + REPONSE_ORIGINE_REFUSEE, + 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 ( + ForgotPasswordRequest, + LoginRequest, + PasswordChangeRequest, + PrincipalResponse, + ResetPasswordRequest, + ResetTokenValidationResponse, + TokenResponse, +) +from app.schemas.errors import ErrorResponse +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + InvalidOrExpiredResetTokenError, + RateLimitedError, + SessionRejectedError, +) + +router = APIRouter() +logger = get_logger(__name__) + +DETAIL_IDENTIFIANTS = "Identifiants invalides" +DETAIL_SESSION = "Session invalide" +DETAIL_LIEN_RESET = "Lien invalide ou expiré" + +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 = { + **REPONSE_ORIGINE_REFUSEE, + 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_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.", + }, +} + +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 +) -> 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", + responses=REPONSES_LOGIN, +) +async def login( + payload: LoginRequest, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + 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 repond(response, settings, session) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Fait tourner la session", + dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], + responses=REPONSES_REFRESH, +) +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), Depends(cookie_de_rafraichissement)], + responses=REPONSES_LOGOUT, +) +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)], + responses=REPONSES_LOGOUT_ALL, +) +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é", + responses=REPONSES_AUTHENTIFIEES, +) +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)], + responses=REPONSES_MOT_DE_PASSE, +) +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) + + +@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, + background_tasks: BackgroundTasks, + 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"), + background_tasks=background_tasks, + ) + 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.get( + "/reset-password/validate", + response_model=ResetTokenValidationResponse, + summary="Vérifie sans le consommer si un lien de réinitialisation est encore valide", + responses=REPONSE_VALIDATION, +) +async def validate_reset_token(token: str, service: AuthServiceDep) -> ResetTokenValidationResponse: + return ResetTokenValidationResponse(valid=await service.is_reset_token_valid(token=token)) + + +@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/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..57e4187 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, HTTPException, status +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() + +TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'") + + +@router.get("/live", summary="Sonde de vivacité") +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 disponibilité", responses=REPONSE_INDISPONIBLE) +async def readiness(session: SessionDep) -> ReadinessStatus: + try: + version: str | None = await session.scalar(TIMESCALEDB_VERSION) + # `# 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, + detail="Base de données injoignable", + ) from None + + 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", + ) + + logger.debug("Extension TimescaleDB en version %s", version) + return ReadinessStatus(status="ready", database="reachable", timescaledb="loaded") diff --git a/apps/backend/app/api/v1/endpoints/predictions.py b/apps/backend/app/api/v1/endpoints/predictions.py new file mode 100644 index 0000000..61a534a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/predictions.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.api.deps import LecteurDep, PredictionServiceDep +from app.schemas.prediction import PredictionSummaryResponse + +router = APIRouter() + + +@router.get( + "", + response_model=PredictionSummaryResponse, + summary="Dernière prédiction de consommation par site", +) +async def get_predictions( + _: LecteurDep, service: PredictionServiceDep +) -> PredictionSummaryResponse: + resume = await service.summary() + return PredictionSummaryResponse.model_validate(resume) diff --git a/apps/backend/app/api/v1/endpoints/readings.py b/apps/backend/app/api/v1/endpoints/readings.py new file mode 100644 index 0000000..c98ff4a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/readings.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query, status + +from app.api.deps import LecteurDep, ReadingServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.schemas.errors import ErrorResponse +from app.schemas.reading import ReadingResponse +from app.services.reading import FenetreInverseeError, FenetreTropLargeError + +router = APIRouter() + +REPONSES_FENETRE: Reponses = { + **REPONSE_VALIDATION, + 400: { + "model": ErrorResponse, + "description": ( + "Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre " + "les deux supérieur à 90 jours." + ), + }, +} + + +@router.get( + "", + response_model=list[ReadingResponse], + summary="Liste l'historique des lectures", + responses=REPONSES_FENETRE, +) +async def list_readings( + _: LecteurDep, + service: ReadingServiceDep, + site_id: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int = Query(500, ge=1, le=2000), + offset: int = Query(0, ge=0), +) -> list[ReadingResponse]: + try: + lectures = await service.list_history( + site_id=site_id, start=start, end=end, limit=limit, offset=offset + ) + except FenetreInverseeError as erreur: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="`start` doit être strictement antérieur à `end`", + ) from erreur + except FenetreTropLargeError as erreur: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours", + ) from erreur + return [ReadingResponse.model_validate(lecture) for lecture in lectures] 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..180808a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses +from app.schemas.errors import ErrorResponse +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."}, +} + + +@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) + + +@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/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/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py new file mode 100644 index 0000000..5687b33 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -0,0 +1,52 @@ +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 SiteCurrentResponse, SiteResponse +from app.services.site import SiteNotFoundError + +router = APIRouter() + +REPONSES_INTROUVABLE: Reponses = { + **REPONSE_VALIDATION, + 404: {"model": ErrorResponse, "description": "Aucun site ne porte cet identifiant."}, +} + + +@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", + responses=REPONSES_INTROUVABLE, +) +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) + + +@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/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/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..794a10a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,142 @@ +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, + UserResponse, + UserUpdateRequest, +) +from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoundError + +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]: + 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", + responses=REPONSES_CREATION, +) +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", + responses=REPONSES_MODIFICATION, +) +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", + responses=REPONSES_INTROUVABLE, +) +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 new file mode 100644 index 0000000..6079acf --- /dev/null +++ b/apps/backend/app/api/v1/router.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter + +from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR +from app.api.v1.endpoints import ( + alerts, + auth, + health, + predictions, + readings, + recommendations, + sensors, + sites, + stats, + 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 +) +api_router.include_router( + recommendations.router, + prefix="/recommendations", + tags=["recommendations"], + responses=REPONSES_LECTEUR, +) +api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR) +api_router.include_router( + readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR +) +api_router.include_router( + sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN +) +api_router.include_router( + predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR +) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py new file mode 100644 index 0000000..5822e08 --- /dev/null +++ b/apps/backend/app/cli.py @@ -0,0 +1,201 @@ +# 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 json +import secrets +import string +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.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" + + +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", + ) + + +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. +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) + + 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" + ) + + contrat = sous_commandes.add_parser( + "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 + + +def genere_mot_de_passe() -> str: + tirage = secrets.SystemRandom() + classes = [ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + SPECIAL_CHARACTERS, + ] + 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 = 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) < 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 + + +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 + + 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( + 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/__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..e622ea7 --- /dev/null +++ b/apps/backend/app/core/config.py @@ -0,0 +1,128 @@ +from functools import lru_cache +from typing import Literal, Self + +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 +REFRESH_COOKIE_DEFAUT = "ev_refresh" +SENTINELLES_INTERDITES = frozenset( + {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} +) + + +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 + + 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) + refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000) + + refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT + 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) + + 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 + + @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" + + @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: + return Settings() diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py new file mode 100644 index 0000000..1221085 --- /dev/null +++ b/apps/backend/app/core/cookies.py @@ -0,0 +1,61 @@ +# 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 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: + return f"{SECURE_PREFIX}{settings.refresh_cookie_name}" + return settings.refresh_cookie_name 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/logging.py b/apps/backend/app/core/logging.py new file mode 100644 index 0000000..14cac3e --- /dev/null +++ b/apps/backend/app/core/logging.py @@ -0,0 +1,92 @@ +# 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" + dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "redaction": {"()": "app.core.logging.RedactingFilter"}, + }, + "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, + "filters": ["redaction"], + "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/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/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/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..1830f3e --- /dev/null +++ b/apps/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base déclarative commune à tous les modèles.""" 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/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/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..22d9b03 --- /dev/null +++ b/apps/backend/app/etl/historical_import.py @@ -0,0 +1,621 @@ +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +from pathlib import Path +from typing import Any, cast + +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 = "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: + 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( + 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(f"Colonnes obligatoires absentes : {sorted(missing_columns)}") + + expected_records = int(metadata["total_records"]) + + if len(frame) != 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( + f"Sites incohérents. Attendus={sorted(expected_sites)}, 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 = cast( + list[dict[str, Any]], + 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]] = [] + + records = cast( + list[dict[str, Any]], + chunk.to_dict(orient="records"), + ) + + for record in 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. + # 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( + 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(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()) + + 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(f"Chargement : {loaded}/{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(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() + + +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/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py new file mode 100644 index 0000000..0d5d6be --- /dev/null +++ b/apps/backend/app/etl/mock_api_import.py @@ -0,0 +1,416 @@ +# 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/app/main.py b/apps/backend/app/main.py new file mode 100644 index 0000000..1380a84 --- /dev/null +++ b/apps/backend/app/main.py @@ -0,0 +1,119 @@ +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.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 +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 +from app.core.logging import configure_logging, get_logger +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"] +STATIC_DIR = Path(__file__).parent / "static" +LOGO_URL = "/static/logo-icon.png" + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + logger.info( + "Démarrage 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) + + documentee = resolved.api_docs_are_exposed + application = FastAPI( + title=resolved.name, + version=resolved.version, + summary=SUMMARY, + description=DESCRIPTION, + openapi_tags=TAGS, + debug=resolved.debug, + lifespan=lifespan, + 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. + openapi_original = application.openapi + + def openapi_avec_logo() -> dict[str, object]: + schema = openapi_original() + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + return 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: + # 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=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, + 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/models/__init__.py b/apps/backend/app/models/__init__.py new file mode 100644 index 0000000..167d7ce --- /dev/null +++ b/apps/backend/app/models/__init__.py @@ -0,0 +1,25 @@ +# 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.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 + +__all__ = [ + "Alert", + "AppUser", + "AuditLog", + "Dataset", + "LoginAttempt", + "PasswordResetAttempt", + "PasswordResetToken", + "Prediction", + "Reading", + "Recommendation", + "RefreshToken", + "Site", +] diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py new file mode 100644 index 0000000..d389880 --- /dev/null +++ b/apps/backend/app/models/audit_log.py @@ -0,0 +1,66 @@ +# 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" + 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" + 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/energy.py b/apps/backend/app/models/energy.py new file mode 100644 index 0000000..285ad26 --- /dev/null +++ b/apps/backend/app/models/energy.py @@ -0,0 +1,210 @@ +"""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__ = "dataset" + __table_args__ = ( + 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) + 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__ = "site" + + 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__ = "reading" + __table_args__ = ( + CheckConstraint( + "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_reading_dataset_source", + ), + CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + 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_reading_imputation", + ), + 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("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("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) + 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_reading_source", + Reading.site_id, + Reading.timestamp, + Reading.source, + func.coalesce(Reading.dataset_id, text("0")), + unique=True, +) + + +class Prediction(Base): + __tablename__ = "prediction" + __table_args__ = ( + 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_prediction_metric", + ), + CheckConstraint( + "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_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_prediction_status", + ), + ) + + prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + 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)) + 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__ = "alert" + __table_args__ = ( + 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"], + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", + ondelete="RESTRICT", + ), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), + CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" + ), + CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" + ), + ) + + 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("site.site_id", name="fk_alert_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__ = "recommendation" + __table_args__ = ( + 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("alert.alert_id", name="fk_recommendation_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()) 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/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/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/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/__init__.py b/apps/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py new file mode 100644 index 0000000..f495a3b --- /dev/null +++ b/apps/backend/app/repositories/alert.py @@ -0,0 +1,55 @@ +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 + + +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() + + 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/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/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..67eafbd --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -0,0 +1,78 @@ +# 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, select, 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) + + # Piège : simple SELECT, volontairement pas atomique avec la consommation. Sert seulement + # au feedback UX (jeton encore valide ?) ; `consume()` reste la seule source de vérité. + async def exists_valid(self, token_hash: bytes) -> bool: + requete = select(PasswordResetToken.id).where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.consumed_at.is_(None), + PasswordResetToken.expires_at > func.clock_timestamp(), + ) + return (await self._session.execute(requete)).first() is not None + + 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/repositories/prediction.py b/apps/backend/app/repositories/prediction.py new file mode 100644 index 0000000..5939899 --- /dev/null +++ b/apps/backend/app/repositories/prediction.py @@ -0,0 +1,49 @@ +from collections.abc import Sequence +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Prediction + + +class PredictionRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_since( + self, *, since: datetime, site_id: str | None = None + ) -> Sequence[Prediction]: + # Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de + # `predicted_value` à comparer à une lecture réelle (détection d'anomalie). + # Piège : `prediction` n'a pas d'unicité sur `(site_id, target_at)` (cf. + # `enervision_ml.score`, qui insère toujours une nouvelle ligne plutôt que d'écraser la + # précédente). `prediction_id` en dernier départage donc les égalités de `target_at` par + # ordre croissant : `_detect_anomaly` construit un dict qui garde le dernier rencontré, + # c'est-à-dire le run le plus récent plutôt qu'une ligne choisie au hasard par le plan + # d'exécution. + requete = ( + select(Prediction) + .where(Prediction.target_at >= since, Prediction.status == "available") + .order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id) + ) + if site_id is not None: + requete = requete.where(Prediction.site_id == site_id) + return (await self._session.scalars(requete)).all() + + async def latest_by_site(self) -> Sequence[Prediction]: + # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule + # ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que + # `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par + # `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus + # récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring. + requete = ( + select(Prediction) + .distinct(Prediction.site_id) + .order_by( + Prediction.site_id, + Prediction.target_at.desc(), + Prediction.prediction_id.desc(), + ) + ) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py new file mode 100644 index 0000000..82a8565 --- /dev/null +++ b/apps/backend/app/repositories/reading.py @@ -0,0 +1,70 @@ +from collections.abc import Sequence +from datetime import datetime + +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. `reading_id` départage + # les égalités de timestamp, que `uq_reading_source` autorise à `source` différente. + requete = ( + select(Reading) + .distinct(Reading.site_id) + .order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc()) + ) + return (await self._session.execute(requete)).scalars().all() + + async def latest_for_site(self, site_id: str) -> Reading | None: + # Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la + # `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard. + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(1) + ) + lecture: Reading | None = await self._session.scalar(requete) + return lecture + + async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]: + # Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de + # comparer chaque lecture à celle qui la précède immédiatement pour le même site. + # `reading_id` en dernier départage : `uq_reading_source` autorise deux lignes au même + # `site_id`+`timestamp` quand la `source` diffère (même piège que `latest_for_site`), sans + # quoi l'ordre entre elles ne serait pas garanti d'un appel à l'autre. + requete = ( + select(Reading) + .where(Reading.timestamp >= since) + .order_by(Reading.site_id, Reading.timestamp, Reading.reading_id) + ) + if site_id is not None: + requete = requete.where(Reading.site_id == site_id) + return (await self._session.scalars(requete)).all() + + async def list_history( + self, + *, + start: datetime, + end: datetime, + site_id: str | None = None, + limit: int, + offset: int, + ) -> Sequence[Reading]: + requete = ( + select(Reading) + .where(Reading.timestamp >= start, Reading.timestamp < end) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(limit) + .offset(offset) + ) + if site_id is not None: + requete = requete.where(Reading.site_id == site_id) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py new file mode 100644 index 0000000..144957b --- /dev/null +++ b/apps/backend/app/repositories/recommendation.py @@ -0,0 +1,51 @@ +from collections.abc import Sequence +from dataclasses import asdict, dataclass + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Recommendation + + +@dataclass(frozen=True, slots=True) +class NouvelleRecommandation: + alert_id: int + action: str + explanation: str + rule_reference: str + + +TAILLE_DE_LOT = 1000 + + +class RecommendationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + 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 + + # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une + # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + creees = 0 + # Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre + # colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait. + for debut in range(0, len(nouvelles), TAILLE_DE_LOT): + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees += len((await self._session.scalars(requete)).all()) + return creees 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/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..c7abbe8 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -0,0 +1,20 @@ +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) + site: Site | None = await self._session.scalar(requete) + return site diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py new file mode 100644 index 0000000..eaac079 --- /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.clock_timestamp(), + ) + ) + + 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.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.clock_timestamp()) + ) 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/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/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 0000000..f345ab7 --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -0,0 +1,95 @@ +# 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. +# Contrainte : `SPECIAL_CHARACTERS` doit rester identique à `password.validator.ts` côté +# frontend. `\w`/`\d` divergent entre Python (Unicode) et JavaScript (ASCII) : une classe +# explicite, plutôt qu'une négation, évite qu'un mot de passe soit accepté d'un côté et +# rejeté de l'autre (ex. "Sécurité1", où "é" comptait comme "spécial" pour Python seul). + +import re +from typing import Literal, Self +from uuid import UUID + +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 = 8 +PASSWORD_MAX_LENGTH = 128 + +SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?" + +_MAJUSCULE = re.compile(r"[A-ZÀ-ÖØ-Þ]") +_MINUSCULE = re.compile(r"[a-zà-öø-þ]") +_CHIFFRE = re.compile(r"[0-9]") +_SPECIAL = re.compile(r"[" + re.escape(SPECIAL_CHARACTERS) + r"]") + + +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 + 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) + + @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) + + 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 ResetTokenValidationResponse(BaseModel): + valid: bool + + +class TokenResponse(BaseModel): + access_token: str + token_type: Literal["bearer"] = "bearer" # noqa: S105 + expires_in: int + principal: PrincipalResponse 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 diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py new file mode 100644 index 0000000..e7ddd4c --- /dev/null +++ b/apps/backend/app/schemas/health.py @@ -0,0 +1,19 @@ +from typing import Literal + +from pydantic import BaseModel + + +class LivenessStatus(BaseModel): + status: Literal["ok"] + service: str + version: str + 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: Literal["loaded"] diff --git a/apps/backend/app/schemas/prediction.py b/apps/backend/app/schemas/prediction.py new file mode 100644 index 0000000..b7eeea2 --- /dev/null +++ b/apps/backend/app/schemas/prediction.py @@ -0,0 +1,43 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class PredictionTargetMetric(StrEnum): + CONSUMPTION_KWH = "consumption_kwh" + CONSUMPTION_KW = "consumption_kw" + + +class PredictionStatus(StrEnum): + AVAILABLE = "available" + INSUFFICIENT_DATA = "insufficient_data" + ERROR = "error" + + +class SitePredictionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + target_at: datetime + target_metric: PredictionTargetMetric + period_minutes: int | None + predicted_value: float | None + status: PredictionStatus + failure_reason: str | None + model_reference: str + created_at: datetime + + +class SitePredictionSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + prediction: SitePredictionResponse | None + + +class PredictionSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + sites: list[SitePredictionSummaryResponse] diff --git a/apps/backend/app/schemas/reading.py b/apps/backend/app/schemas/reading.py new file mode 100644 index 0000000..5deef21 --- /dev/null +++ b/apps/backend/app/schemas/reading.py @@ -0,0 +1,45 @@ +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ReadingSource(StrEnum): + CSV = "csv" + API_CURRENT = "api_current" + API_HISTORY = "api_history" + + +class ReadingDataQuality(StrEnum): + GOOD = "good" + PARTIAL = "partial" + DEGRADED = "degraded" + CRITICAL = "critical" + + +class ReadingResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + reading_id: int + site_id: str + timestamp: datetime + source: ReadingSource + consumption_kw: float | None + consumption_kwh: float | None + # Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant) + # sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat` + # naïf perdrait la précision que ce choix visait à garder. + consumption_euros: Decimal | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + solar_irradiance_wm2: float | None + is_working_hours: bool | None + data_quality: ReadingDataQuality | None + null_reasons: list[str] | None + imputed_values: dict[str, Any] | None + imputation_method: str | None diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py new file mode 100644 index 0000000..bb22d02 --- /dev/null +++ b/apps/backend/app/schemas/recommendation.py @@ -0,0 +1,20 @@ +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 + + +class RecommendationGenerationResponse(BaseModel): + alerts_examined: int + recommendations_created: int + already_present: int 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/schemas/site.py b/apps/backend/app/schemas/site.py new file mode 100644 index 0000000..56a61b7 --- /dev/null +++ b/apps/backend/app/schemas/site.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Literal + +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 + + +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/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/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/__init__.py b/apps/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py new file mode 100644 index 0000000..44a1db7 --- /dev/null +++ b/apps/backend/app/services/alert.py @@ -0,0 +1,323 @@ +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta + +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, + 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, heure puis `reading_id` (cf. `ReadingRepository.list_since`) : + # deux lignes consécutives du même site sont donc deux mesures consécutives dans le temps, + # sauf lorsqu'elles partagent le même horodatage (deux `source` différentes pour le même + # instant, permises par `uq_reading_source`) : ce n'est alors pas une variation réelle, on + # l'ignore plutôt que de générer une fausse alerte figée par son `source_alert_id`. + alertes = [] + precedente: Reading | None = None + for lecture in lectures: + if ( + precedente is None + or precedente.site_id != lecture.site_id + or precedente.timestamp == lecture.timestamp + ): + precedente = lecture + continue + avant, apres = precedente.consumption_kw, lecture.consumption_kw + precedente = lecture + if avant is None or apres is None: + continue + if avant == 0: + # Une variation relative n'a pas de sens depuis zéro, mais un redémarrage direct à + # une consommation positive reste le signal le plus alarmant du lot : `critical` + # plutôt qu'un ratio indéfini. + if apres > 0: + alertes.append(_spike_alert(lecture, avant, apres, severity="critical")) + continue + variation = abs(apres - avant) / abs(avant) + if variation < SPIKE_RELATIVE_THRESHOLD: + continue + alertes.append( + _spike_alert( + lecture, + avant, + apres, + severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD), + ) + ) + return alertes + + +def _spike_alert(lecture: Reading, avant: float, apres: float, *, severity: str) -> Alert: + return Alert( + source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="spike", + severity=severity, + message=( + f"Variation brutale entre deux lectures consécutives ({avant:.1f} kW -> {apres:.1f} kW)" + ), + value=apres, + threshold=avant, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + + +def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]: + # Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une + # cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`. + predictions_par_cle = { + (prediction.site_id, prediction.target_at): prediction + for prediction in predictions + if prediction.target_metric == ANOMALY_METRIC + } + alertes = [] + for lecture in lectures: + prediction = predictions_par_cle.get((lecture.site_id, lecture.timestamp)) + reel = lecture.consumption_kwh + if prediction is None or reel is None or prediction.predicted_value is None: + continue + predite = prediction.predicted_value + if abs(predite) < ANOMALY_MINIMUM_PREDICTED_VALUE: + continue + ecart = abs(reel - predite) / abs(predite) + if ecart < ANOMALY_RELATIVE_THRESHOLD: + continue + alertes.append( + Alert( + source_alert_id=f"anomaly:{ANOMALY_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="anomaly", + severity=_severity_from_ratio(ecart / ANOMALY_RELATIVE_THRESHOLD), + message=( + f"Écart de {ecart * 100:.0f}% entre la consommation mesurée ({reel:.1f} kWh) " + f"et la prévision ({predite:.1f} kWh)" + ), + value=reel, + threshold=predite, + metric=ANOMALY_METRIC, + prediction_id=prediction.prediction_id, + raw_data={}, + ) + ) + return alertes + + +def _detect_outage( + sites: Sequence[Site], dernieres_lectures: dict[str, Reading], now: datetime +) -> list[Alert]: + alertes = [] + for site in sites: + derniere = dernieres_lectures.get(site.site_id) + if derniere is None: + alertes.append( + _outage_alert( + site.site_id, + now, + reference=None, + message="Aucune lecture n'a jamais été reçue pour ce site", + severity="critical", + ) + ) + continue + absence = now - derniere.timestamp + if absence < OUTAGE_THRESHOLD: + continue + alertes.append( + _outage_alert( + site.site_id, + now, + reference=derniere.timestamp, + message=( + f"Aucune lecture depuis {absence} (dernière lecture : " + f"{derniere.timestamp.isoformat()})" + ), + severity=_severity_from_ratio(absence / OUTAGE_THRESHOLD), + ) + ) + return alertes + + +def _outage_alert( + site_id: str, now: datetime, *, reference: datetime | None, message: str, severity: str +) -> Alert: + return Alert( + source_alert_id=f"outage:{reference.isoformat() if reference is not None else 'jamais'}", + site_id=site_id, + source="enervision", + timestamp=now, + type="outage", + severity=severity, + message=message, + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, + ) + + +def _detect_sensor(lectures: Sequence[Reading]) -> list[Alert]: + alertes = [] + for lecture in lectures: + severite = QUALITE_VERS_SEVERITE.get(lecture.data_quality or "") + if severite is None: + continue + raisons = ", ".join(lecture.null_reasons or []) or "raison non précisée" + alertes.append( + Alert( + source_alert_id=f"sensor:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="sensor", + severity=severite, + message=f"Qualité de mesure {lecture.data_quality} ({raisons})", + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, + ) + ) + return alertes diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py new file mode 100644 index 0000000..9fac1e1 --- /dev/null +++ b/apps/backend/app/services/auth.py @@ -0,0 +1,464 @@ +# 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. +# 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, uuid4 + +from fastapi import BackgroundTasks + +from app.core.hashing import Argon2Hasher +from app.core.logging import get_logger +from app.core.mailer import Mailer +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +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.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 + +logger = get_logger(__name__) + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class AuthError(Exception): + pass + + +class InvalidCredentialsError(AuthError): + pass + + +class SessionRejectedError(AuthError): + pass + + +class RateLimitedError(AuthError): + def __init__(self, retry_after: int) -> None: + super().__init__("Trop de tentatives") + self.retry_after = retry_after + + +class InvalidOrExpiredResetTokenError(AuthError): + pass + + +@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 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 + access_token: str + expires_in: int + refresh_secret: str + + +class AuthService: + def __init__( + self, + *, + users: UserRepository, + attempts: LoginAttemptRepository, + refresh_tokens: RefreshTokenRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + 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 + 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 + 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 + ) -> 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 + ) + secret = await self._ouvre_une_famille( + user_id=compte.id, client_ip=client_ip, user_agent=user_agent + ) + await self._transaction.commit() + + return self._session(self._en_principal(compte), secret) + + 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 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 request_password_reset( + self, + *, + email: str, + client_ip: str | None, + user_agent: str | None, + background_tasks: BackgroundTasks, + ) -> 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. L'envoi SMTP lui-même est différé + # en tâche de fond : le laisser dans le chemin de réponse rouvrirait le même oracle par le + # temps (aller-retour réseau) et par la forme (500 si le relais SMTP échoue, contre 202). + 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}" + background_tasks.add_task(self._envoie_email_reset, compte.email, lien) + + async def _envoie_email_reset(self, email: str, reset_url: str) -> None: + try: + await self._mailer.send_password_reset_email(to=email, reset_url=reset_url) + except Exception: + logger.exception("auth.password_reset.mail_failed") + + # Piège : lecture seule, pas d'appel à `consume()`. Aucune limitation de débit n'est + # nécessaire ici : le jeton est un secret de 256 bits (`generate_refresh_secret`), donc + # non brute-forçable, et cette route n'apprend rien sur l'existence d'un compte ou d'un + # email, seulement si le lien déjà en main du visiteur est encore valide. + async def is_reset_token_valid(self, token: str) -> bool: + return await self._reset_tokens.exists_valid(fingerprint_refresh(token)) + + 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é") + + # Piège : le jeton peut avoir été émis avant une désactivation du compte. Sans cette + # relecture, un lien encore valide (15 min) changerait quand même le mot de passe d'un + # compte désactivé, réutilisable dès sa réactivation. + compte = await self._users.get_by_id(revendique.user_id) + if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + 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 + ) + 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, + 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()), + 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: + 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, + outcome=AuditOutcome.ECHEC, + 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 _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, + 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/app/services/data_quality.py b/apps/backend/app/services/data_quality.py new file mode 100644 index 0000000..ae8b43c --- /dev/null +++ b/apps/backend/app/services/data_quality.py @@ -0,0 +1,18 @@ +# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat +# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur +# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES` +# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli. + +from typing import Literal, get_args + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality)) + +_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)} + + +def qualite_ou_critique(valeur: str | None) -> DataQuality: + if valeur is None: + return "critical" + return _PAR_VALEUR.get(valeur, "critical") diff --git a/apps/backend/app/services/prediction.py b/apps/backend/app/services/prediction.py new file mode 100644 index 0000000..6235bf2 --- /dev/null +++ b/apps/backend/app/services/prediction.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.models.energy import Prediction, Site +from app.repositories.prediction import PredictionRepository +from app.repositories.site import SiteRepository + + +@dataclass(frozen=True, slots=True) +class SitePrediction: + target_at: datetime + target_metric: str + period_minutes: int | None + predicted_value: float | None + status: str + failure_reason: str | None + model_reference: str + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class SitePredictionSummary: + site_id: str + site_name: str + prediction: SitePrediction | None + + +@dataclass(frozen=True, slots=True) +class PredictionSummary: + timestamp: datetime + sites: list[SitePredictionSummary] + + +class PredictionService: + def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None: + self._sites = sites + self._predictions = predictions + + async def summary(self) -> PredictionSummary: + sites = await self._sites.list_all() + dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()} + + return PredictionSummary( + timestamp=datetime.now(UTC), + sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites], + ) + + +def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary: + # Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui + # n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de + # fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`. + prediction = None + if derniere is not None: + prediction = SitePrediction( + target_at=derniere.target_at, + target_metric=derniere.target_metric, + period_minutes=derniere.period_minutes, + predicted_value=derniere.predicted_value, + status=derniere.status, + failure_reason=derniere.failure_reason, + model_reference=derniere.model_reference, + created_at=derniere.created_at, + ) + + return SitePredictionSummary( + site_id=site.site_id, site_name=site.site_name, prediction=prediction + ) diff --git a/apps/backend/app/services/reading.py b/apps/backend/app/services/reading.py new file mode 100644 index 0000000..818c202 --- /dev/null +++ b/apps/backend/app/services/reading.py @@ -0,0 +1,59 @@ +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta + +from app.models.energy import Reading +from app.repositories.reading import ReadingRepository + +FENETRE_PAR_DEFAUT = timedelta(hours=24) +FENETRE_MAXIMALE = timedelta(days=90) + + +class FenetreInverseeError(Exception): + """`start` est postérieur ou égal à `end`.""" + + +class FenetreTropLargeError(Exception): + """L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`.""" + + +class ReadingService: + def __init__(self, *, readings: ReadingRepository) -> None: + self._readings = readings + + async def list_history( + self, + *, + site_id: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int, + offset: int, + ) -> Sequence[Reading]: + debut, fin = self._resoudre_fenetre(start, end) + return await self._readings.list_history( + site_id=site_id, start=debut, end=fin, limit=limit, offset=offset + ) + + @staticmethod + def _resoudre_fenetre( + start: datetime | None, end: datetime | None + ) -> tuple[datetime, datetime]: + # Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la + # comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt + # qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter. + debut = _vers_utc(start) + fin = _vers_utc(end) or datetime.now(UTC) + if debut is None: + debut = fin - FENETRE_PAR_DEFAUT + + if debut >= fin: + raise FenetreInverseeError + if fin - debut > FENETRE_MAXIMALE: + raise FenetreTropLargeError + return debut, fin + + +def _vers_utc(instant: datetime | None) -> datetime | None: + if instant is None: + return None + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py new file mode 100644 index 0000000..6b0ceb6 --- /dev/null +++ b/apps/backend/app/services/recommendation.py @@ -0,0 +1,62 @@ +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): + pass + + +class RecommendationNotFoundError(RecommendationError): + pass + + +@dataclass(frozen=True, slots=True) +class RapportGeneration: + alertes_examinees: int + recommandations_creees: int + deja_presentes: int + + +class RecommendationService: + def __init__( + self, + *, + recommendations: RecommendationRepository, + alerts: AlertRepository, + transaction: Transaction, + ) -> None: + self._recommendations = recommendations + self._alerts = alerts + self._transaction = transaction + + 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 + + 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/app/services/sensor.py b/apps/backend/app/services/sensor.py new file mode 100644 index 0000000..fa1a1ea --- /dev/null +++ b/apps/backend/app/services/sensor.py @@ -0,0 +1,136 @@ +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 +from app.services.data_quality import qualite_ou_critique + +CapteurStatus = Literal["ok", "failing"] +OverallStatus = Literal["ok", "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 = qualite_ou_critique(derniere.data_quality) + 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/app/services/site.py b/apps/backend/app/services/site.py new file mode 100644 index 0000000..a438a20 --- /dev/null +++ b/apps/backend/app/services/site.py @@ -0,0 +1,82 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime + +from app.models.energy import Site +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository +from app.services.data_quality import DataQuality, qualite_ou_critique + + +class SiteError(Exception): + pass + + +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, readings: ReadingRepository) -> None: + self._sites = sites + self._readings = readings + + 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 + + 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", + ) + + 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_ou_critique(derniere.data_quality), + ) diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py new file mode 100644 index 0000000..c2b15da --- /dev/null +++ b/apps/backend/app/services/stats.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository +from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique + + +@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 + qualite: DataQuality = "critical" + consommation = None + if derniere is not None and derniere.data_quality in QUALITES_CONNUES: + qualite = qualite_ou_critique(derniere.data_quality) + 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/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/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000..d3bdc53 Binary files /dev/null and b/apps/backend/app/static/logo-icon.png differ 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 diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json new file mode 100644 index 0000000..67b3877 --- /dev/null +++ b/apps/backend/openapi.json @@ -0,0 +1,3282 @@ +{ + "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", + "x-logo": { + "url": "/static/logo-icon.png", + "altText": "EnerVision" + } + }, + "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" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "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" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "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" + } + } + } + }, + "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": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/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/validate": { + "get": { + "tags": [ + "auth" + ], + "summary": "Vérifie sans le consommer si un lien de réinitialisation est encore valide", + "operationId": "validate_reset_token_api_v1_auth_reset_password_validate_get", + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetTokenValidationResponse" + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/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": [ + "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" + } + } + } + } + } + } + }, + "/api/v1/sites": { + "get": { + "tags": [ + "sites" + ], + "summary": "Liste les sites", + "operationId": "list_sites_api_v1_sites_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SiteResponse" + }, + "type": "array", + "title": "Response List Sites Api V1 Sites 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/sites/{site_id}": { + "get": { + "tags": [ + "sites" + ], + "summary": "Décrit un site", + "operationId": "get_site_api_v1_sites__site_id__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/SiteResponse" + } + } + } + }, + "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/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": [ + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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": [ + "stats" + ], + "summary": "Résume la consommation instantanée du parc", + "operationId": "get_summary_api_v1_stats_summary_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsSummaryResponse" + } + } + } + }, + "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/readings": { + "get": { + "tags": [ + "readings" + ], + "summary": "Liste l'historique des lectures", + "operationId": "list_readings_api_v1_readings_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 2000, + "minimum": 1, + "default": 500, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadingResponse" + }, + "title": "Response List Readings Api V1 Readings 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" + } + } + } + }, + "400": { + "description": "Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre les deux supérieur à 90 jours.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/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": [] + } + ] + } + }, + "/api/v1/predictions": { + "get": { + "tags": [ + "predictions" + ], + "summary": "Dernière prédiction de consommation par site", + "operationId": "get_predictions_api_v1_predictions_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PredictionSummaryResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + } + }, + "components": { + "schemas": { + "AccountKind": { + "type": "string", + "enum": [ + "human", + "service" + ], + "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": { + "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" + }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, + "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": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChangeRequest" + }, + "PredictionStatus": { + "type": "string", + "enum": [ + "available", + "insufficient_data", + "error" + ], + "title": "PredictionStatus" + }, + "PredictionSummaryResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SitePredictionSummaryResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "sites" + ], + "title": "PredictionSummaryResponse" + }, + "PredictionTargetMetric": { + "type": "string", + "enum": [ + "consumption_kwh", + "consumption_kw" + ], + "title": "PredictionTargetMetric" + }, + "PrincipalResponse": { + "properties": { + "id": { + "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" + }, + "ReadingDataQuality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "ReadingDataQuality" + }, + "ReadingResponse": { + "properties": { + "reading_id": { + "type": "integer", + "title": "Reading Id" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "source": { + "$ref": "#/components/schemas/ReadingSource" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "consumption_euros": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Consumption Euros" + }, + "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" + }, + "solar_irradiance_wm2": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Solar Irradiance Wm2" + }, + "is_working_hours": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Working Hours" + }, + "data_quality": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReadingDataQuality" + }, + { + "type": "null" + } + ] + }, + "null_reasons": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Null Reasons" + }, + "imputed_values": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Imputed Values" + }, + "imputation_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Imputation Method" + } + }, + "type": "object", + "required": [ + "reading_id", + "site_id", + "timestamp", + "source", + "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" + ], + "title": "ReadingResponse" + }, + "ReadingSource": { + "type": "string", + "enum": [ + "csv", + "api_current", + "api_history" + ], + "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": { + "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" + }, + "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" + }, + "ResetTokenValidationResponse": { + "properties": { + "valid": { + "type": "boolean", + "title": "Valid" + } + }, + "type": "object", + "required": [ + "valid" + ], + "title": "ResetTokenValidationResponse" + }, + "Role": { + "type": "string", + "enum": [ + "lecteur", + "operateur", + "admin" + ], + "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" + }, + "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" + }, + "SitePredictionResponse": { + "properties": { + "target_at": { + "type": "string", + "format": "date-time", + "title": "Target At" + }, + "target_metric": { + "$ref": "#/components/schemas/PredictionTargetMetric" + }, + "period_minutes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Period Minutes" + }, + "predicted_value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Predicted Value" + }, + "status": { + "$ref": "#/components/schemas/PredictionStatus" + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Reason" + }, + "model_reference": { + "type": "string", + "title": "Model Reference" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "target_at", + "target_metric", + "period_minutes", + "predicted_value", + "status", + "failure_reason", + "model_reference", + "created_at" + ], + "title": "SitePredictionResponse" + }, + "SitePredictionSummaryResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "prediction": { + "anyOf": [ + { + "$ref": "#/components/schemas/SitePredictionResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "prediction" + ], + "title": "SitePredictionSummaryResponse" + }, + "SiteResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, + "capacity_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Capacity Kw" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "site_type", + "location", + "capacity_kw", + "status" + ], + "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": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "current_consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current Consumption Kw" + }, + "capacity_kw": { + "type": "number", + "title": "Capacity Kw" + }, + "load_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Load Percent" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "current_consumption_kw", + "capacity_kw", + "load_percent", + "data_quality" + ], + "title": "SiteSummaryResponse" + }, + "StatsSummaryResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "total_sites": { + "type": "integer", + "title": "Total Sites" + }, + "total_consumption_kw": { + "type": "number", + "title": "Total Consumption Kw" + }, + "total_capacity_kw": { + "type": "number", + "title": "Total Capacity Kw" + }, + "average_load_percent": { + "type": "number", + "title": "Average Load Percent" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SiteSummaryResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "total_sites", + "total_consumption_kw", + "total_capacity_kw", + "average_load_percent", + "sites" + ], + "title": "StatsSummaryResponse" + }, + "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`." + }, + { + "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`." + }, + { + "name": "recommendations", + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." + }, + { + "name": "stats", + "description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`." + }, + { + "name": "readings", + "description": "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, 24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du rôle `lecteur`." + }, + { + "name": "sensors", + "description": "État de santé des capteurs par site. Réservé au rôle `admin`." + }, + { + "name": "predictions", + "description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." + } + ] +} diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml new file mode 100644 index 0000000..cfe6481 --- /dev/null +++ b/apps/backend/pyproject.toml @@ -0,0 +1,99 @@ +[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[email]>=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", + "pyjwt>=2.10", + "argon2-cffi>=23.1", + "anyio>=4.0", + "aiosmtplib>=5.1.3", + "httpx>=0.28.1", + "pandas>=3.0.5", +] + +[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", + "pandas-stubs>=3.0.5.260914", +] + +[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] +# 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"] + +[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" +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`"] + +[tool.coverage.run] +source = ["app"] +branch = true +omit = ["alembic/*"] + +[tool.coverage.report] +show_missing = true 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/acces.py b/apps/backend/tests/api/acces.py new file mode 100644 index 0000000..2b38374 --- /dev/null +++ b/apps/backend/tests/api/acces.py @@ -0,0 +1,88 @@ +# 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, + ("POST", "/api/v1/recommendations/generate"): Role.ADMIN, + ("GET", "/api/v1/stats/summary"): Role.LECTEUR, + ("GET", "/api/v1/readings"): Role.LECTEUR, + ("GET", "/api/v1/predictions"): Role.LECTEUR, + ("GET", "/api/v1/sensors/status"): Role.ADMIN, + ("GET", "/api/v1/users"): Role.ADMIN, + ("POST", "/api/v1/users"): Role.ADMIN, + ("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_alerts.py b/apps/backend/tests/api/test_alerts.py new file mode 100644 index 0000000..fc5f110 --- /dev/null +++ b/apps/backend/tests/api/test_alerts.py @@ -0,0 +1,137 @@ +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 +from app.schemas.alert import AlertSeverity + + +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, AlertSeverity.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_auth.py b/apps/backend/tests/api/test_auth.py new file mode 100644 index 0000000..dd8256b --- /dev/null +++ b/apps/backend/tests/api/test_auth.py @@ -0,0 +1,344 @@ +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, + InvalidOrExpiredResetTokenError, + RateLimitedError, + SessionRejectedError, +) + +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, *, jeton_valide: bool = True) -> None: + self._erreur = erreur + self._jeton_valide = jeton_valide + + async def refresh(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + + async def is_reset_token_valid(self, **_: object) -> bool: + return self._jeton_valide + + 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 + return AuthenticatedSession( + principal=PRINCIPAL, + access_token="un.jeton.factice", + expires_in=900, + refresh_secret="un-secret-opaque", + ) + + +@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 + + +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 + + +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 + + +@pytest.fixture +def fake_auth_service_reset_validity(app: FastAPI) -> Iterator[list[bool]]: + programme = [True] + app.dependency_overrides[get_auth_service] = lambda: FauxService(jeton_valide=programme[0]) + yield programme + app.dependency_overrides.pop(get_auth_service, None) + + +async def test_validate_reset_token_reports_a_living_token( + fake_auth_service_reset_validity: list[bool], client: AsyncClient +) -> None: + response = await client.get( + "/api/v1/auth/reset-password/validate", params={"token": "un-secret-opaque"} + ) + + assert response.status_code == 200 + assert response.json() == {"valid": True} + + +async def test_validate_reset_token_reports_an_invalid_or_expired_token( + fake_auth_service_reset_validity: list[bool], client: AsyncClient +) -> None: + fake_auth_service_reset_validity[0] = False + + response = await client.get( + "/api/v1/auth/reset-password/validate", params={"token": "un-secret-perime"} + ) + + assert response.status_code == 200 + assert response.json() == {"valid": False} + + +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_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_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 new file mode 100644 index 0000000..b9f2f33 --- /dev/null +++ b/apps/backend/tests/api/test_health.py @@ -0,0 +1,79 @@ +from collections.abc import Callable + +import pytest +from httpx import AsyncClient +from sqlalchemy.exc import OperationalError + + +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", + } + + +async def test_readiness_confirms_the_extension_without_leaking_its_version( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result="2.22.1") + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 200 + assert response.json() == { + "status": "ready", + "database": "reachable", + "timescaledb": "loaded", + } + assert "2.22.1" not in response.text + + +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" + + +@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( + fake_session: Callable[..., None], client: AsyncClient, failure: Exception +) -> None: + fake_session(failure=failure) + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 503 + assert response.json()["detail"] == "Base de données 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 + + +@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"] == "loaded" 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() diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py new file mode 100644 index 0000000..50e3c3a --- /dev/null +++ b/apps/backend/tests/api/test_openapi.py @@ -0,0 +1,117 @@ +# 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 +from tests.api.acces import ROLE_MINIMUM + +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")} + +ORIGINE_VERIFIEE = { + ("POST", "/api/v1/auth/refresh"), + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/logout-all"), + ("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`. +# 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") +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_role_guarded_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: + sans_403 = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"] + ] + + 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"] + 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/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" diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py new file mode 100644 index 0000000..184afc6 --- /dev/null +++ b/apps/backend/tests/api/test_predictions.py @@ -0,0 +1,85 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_prediction_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary + +TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) +CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +def lecteur() -> Principal: + # Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE` + # de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec + # autre chose que sa valeur par défaut. + return Principal( + id=uuid4(), + email="lecteur@enervision.fr", + role=Role.LECTEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxService: + def __init__(self) -> None: + self.resume = PredictionSummary( + timestamp=datetime.now(UTC), + sites=[ + SitePredictionSummary( + site_id="SITE001", + site_name="Bureau Paris La Défense", + prediction=SitePrediction( + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=812.5, + status="available", + failure_reason=None, + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ), + ), + SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None), + ], + ) + + async def summary(self) -> PredictionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_prediction_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: lecteur() + return service + + yield installe + app.dependency_overrides.pop(get_prediction_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_predictions_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/predictions") + + assert response.status_code == 200 + corps = response.json() + premier, second = corps["sites"] + assert premier["site_id"] == "SITE001" + assert premier["prediction"]["predicted_value"] == 812.5 + assert premier["prediction"]["status"] == "available" + assert second["site_id"] == "SITE002" + assert second["prediction"] is None diff --git a/apps/backend/tests/api/test_readings.py b/apps/backend/tests/api/test_readings.py new file mode 100644 index 0000000..0d01aa8 --- /dev/null +++ b/apps/backend/tests/api/test_readings.py @@ -0,0 +1,198 @@ +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_reading_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Reading +from app.services.reading import FenetreInverseeError, FenetreTropLargeError + + +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 reading(reading_id: int = 1, site_id: str = "site-1") -> Reading: + return Reading( + reading_id=reading_id, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="api_current", + consumption_kw=42.5, + consumption_kwh=None, + consumption_euros=None, + voltage_v=230.0, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + solar_irradiance_wm2=None, + is_working_hours=True, + data_quality="good", + null_reasons=None, + imputed_values=None, + imputation_method=None, + raw_data={}, + ) + + +class FauxService: + def __init__(self, leve: Exception | None = None) -> None: + self.reading = reading() + self.leve = leve + self.appels: list[tuple[str | None, str | None, str | None, int, int]] = [] + + async def list_history( + self, + *, + site_id: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int, + offset: int, + ) -> list[Reading]: + self.appels.append((site_id, start, end, limit, offset)) + if self.leve is not None: + raise self.leve + return [self.reading] + + +@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(*, leve: Exception | None = None) -> FauxService: + service = FauxService(leve=leve) + app.dependency_overrides[get_reading_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_reading_service, None) + + +async def test_list_readings_returns_the_readings( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/readings") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "reading_id": 1, + "site_id": "site-1", + "timestamp": "2026-09-16T00:00:00Z", + "source": "api_current", + "consumption_kw": 42.5, + "consumption_kwh": None, + "consumption_euros": None, + "voltage_v": 230.0, + "current_a": None, + "power_factor": None, + "temperature_celsius": None, + "humidity_percent": None, + "solar_irradiance_wm2": None, + "is_working_hours": True, + "data_quality": "good", + "null_reasons": None, + "imputed_values": None, + "imputation_method": None, + } + ] + + +async def test_list_readings_transmits_the_filters_and_pagination( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + service = servi() + + response = await client.get( + "/api/v1/readings", + params={ + "site_id": "site-1", + "start": "2026-09-01T00:00:00Z", + "end": "2026-09-02T00:00:00Z", + "limit": 50, + "offset": 10, + }, + ) + + assert response.status_code == 200 + assert service.appels == [ + ( + "site-1", + datetime(2026, 9, 1, tzinfo=UTC), + datetime(2026, 9, 2, tzinfo=UTC), + 50, + 10, + ) + ] + + +async def test_list_readings_returns_400_when_the_window_is_inverted( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(leve=FenetreInverseeError()) + + response = await client.get("/api/v1/readings") + + assert response.status_code == 400 + + +async def test_list_readings_returns_400_when_the_window_is_too_large( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(leve=FenetreTropLargeError()) + + response = await client.get("/api/v1/readings") + + assert response.status_code == 400 + + +async def test_list_readings_returns_422_for_a_limit_above_the_maximum( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/readings", params={"limit": 5000}) + + assert response.status_code == 422 + + +async def test_list_readings_returns_422_for_a_negative_offset( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/readings", params={"offset": -1}) + + assert response.status_code == 422 + + +async def test_list_readings_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/readings") + + assert response.status_code == 200 + assert response.json() == [] diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py new file mode 100644 index 0000000..6e854bd --- /dev/null +++ b/apps/backend/tests/api/test_recommendations.py @@ -0,0 +1,202 @@ +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 RapportGeneration, 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() + self.site_demande: str | None = None + + 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 + + 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]: + 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 + + +@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/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py new file mode 100644 index 0000000..ebaa8ff --- /dev/null +++ b/apps/backend/tests/api/test_route_protection.py @@ -0,0 +1,105 @@ +# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier +# `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. + +from typing import Any + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from tests.api.acces import ( + ROLE_MINIMUM, + ROUTE_COOKIE, + ROUTES_PUBLIQUES, + ROUTES_SANS_ROLE, + Route, + chemin_concret, + routes_du_schema, +) + +STATUTS_DE_REFUS = {401, 403} +HORS_SCHEMA = {("GET", "/metrics")} + + +def routes_declarees(app: FastAPI) -> list[Route]: + schema: dict[str, Any] = app.openapi() + return routes_du_schema(schema) + + +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)) | 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): + 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)) + + 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 + + +# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette +# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit +# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus. +@pytest.mark.parametrize( + "chemin", + ["/docs", "/redoc", "/static/logo-icon.png"], + ids=["swagger_ui", "redoc", "logo_statique"], +) +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 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/api/test_sites.py b/apps/backend/tests/api/test_sites.py new file mode 100644 index 0000000..dea8850 --- /dev/null +++ b/apps/backend/tests/api/test_sites.py @@ -0,0 +1,191 @@ +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_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 SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, 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 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", + ) + + +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] + + async def get_by_id(self, site_id: str) -> Site: + if self._erreur is not None: + 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]: + 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_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: + 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/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/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/conftest.py b/apps/backend/tests/conftest.py new file mode 100644 index 0000000..bc8ccfb --- /dev/null +++ b/apps/backend/tests/conftest.py @@ -0,0 +1,74 @@ +import os +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 +from app.main import create_app +from tests.factories import FakeSession + + +# 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( + { + "APP_ENV": "local", + "APP_DEBUG": "false", + "APP_LOG_LEVEL": "WARNING", + "APP_CORS_ORIGINS": "", + "APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur", + } + ) + os.environ.setdefault( + "DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test" + ) + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +# 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 + 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() + + +@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 + + +@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 + + +# 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: + yield async_session 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/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/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_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" 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/tests/db/__init__.py b/apps/backend/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 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..52295aa --- /dev/null +++ b/apps/backend/tests/db/test_data_schema.py @@ -0,0 +1,271 @@ +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_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 = 'reading'" + ) + + 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) + + savepoint = data_connection.begin_nested() + + with pytest.raises(IntegrityError): + async with savepoint: + 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) + + statement = insert(Reading).values(**values) + savepoint = data_connection.begin_nested() + + with pytest.raises(IntegrityError): + async with savepoint: + await data_connection.execute(statement) + + +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", + ) + + savepoint = data_connection.begin_nested() + + with pytest.raises(IntegrityError): + async with savepoint: + 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() + + 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 savepoint: + await data_connection.execute(statement) + + +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( + source_alert_id=str(uuid4()), + site_id=data_site, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + .returning(Alert.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) + + savepoint = data_connection.begin_nested() + + with pytest.raises(IntegrityError): + async with savepoint: + await data_connection.execute(statement) diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py new file mode 100644 index 0000000..2f3ea92 --- /dev/null +++ b/apps/backend/tests/etl/test_historical_import.py @@ -0,0 +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"]) + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Colonnes obligatoires absentes", + ): + validate_source( + frame, + metadata, + ) + + +def test_validate_source_rejects_duplicates(): + frame = make_dataframe() + + frame.loc[1, "timestamp"] = frame.loc[ + 0, + "timestamp", + ] + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="doublons", + ): + validate_source( + frame, + metadata, + ) + + +def test_validate_source_rejects_unknown_site(): + frame = make_dataframe() + + frame.loc[1, "site_id"] = "SITE999" + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Sites incohérents", + ): + validate_source( + frame, + metadata, + ) + + +def test_normalize_timestamps_adds_timezone(): + frame = make_dataframe() + + normalized = normalize_timestamps( + frame, + "UTC", + ) + + assert normalized["timestamp"].dt.tz is not None + + assert "_source_timestamp" in normalized.columns + + +def test_classify_quality_good(): + row = make_dataframe().iloc[0].to_dict() + + quality, reasons = classify_quality(row) + + assert quality == "good" + assert reasons == [] + + +def test_classify_quality_degraded_when_consumption_missing(): + row = make_dataframe().iloc[0].to_dict() + row["consumption_kwh"] = None + + quality, reasons = classify_quality(row) + + assert quality == "degraded" + + assert "missing:consumption_kwh" in reasons + + +def test_build_reading_batch_respects_database_contract(): + frame = normalize_timestamps( + make_dataframe(), + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + assert len(rows) == 1 + + row = rows[0] + + assert row["dataset_id"] == 3 + + # Important : + # contrainte ck_reading_dataset_source. + assert row["source"] == "csv" + assert SOURCE_NAME == "csv" + + # Important : + # contrainte ck_reading_imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + +def test_build_reading_batch_keeps_missing_values(): + frame = make_dataframe() + + frame.loc[0, "temperature_celsius"] = None + + frame = normalize_timestamps( + frame, + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + row = rows[0] + + assert row["temperature_celsius"] is None + + assert "missing:temperature_celsius" in row["null_reasons"] + + # RAW ingestion : aucune imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py new file mode 100644 index 0000000..cdcff55 --- /dev/null +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -0,0 +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 ( + 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/apps/backend/tests/factories.py b/apps/backend/tests/factories.py new file mode 100644 index 0000000..c606ee0 --- /dev/null +++ b/apps/backend/tests/factories.py @@ -0,0 +1,51 @@ +from collections.abc import Sequence +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-assez-long-pour-le-validateur", + "database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test", +} + + +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.""" + + 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() + + 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 + return self._result + + +# 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}) 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/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py new file mode 100644 index 0000000..16c5a9a --- /dev/null +++ b/apps/backend/tests/repositories/test_alert.py @@ -0,0 +1,148 @@ +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 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 + +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=AlertSeverity.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) == [] + + +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_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py new file mode 100644 index 0000000..9edbe5c --- /dev/null +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -0,0 +1,144 @@ +# 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) + + requete = text(instruction) + + with pytest.raises(DBAPIError, match="ajout seul"): + await session.execute(requete) + await session.rollback() + + +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, target_id=cible) + await session.flush() + ligne = ( + await session.execute( + text( + "select actor_id, actor_email, actor_role, outcome from audit_log " + "where target_id = :c" + ), + {"c": cible}, + ) + ).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) + + 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 where target_id = :c"), + {"c": cible}, + ) + ).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) + + 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 where target_id = :c"), {"c": cible} + ) + ).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_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..eebbd21 --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -0,0 +1,145 @@ +# 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_exists_valid_is_true_for_a_living_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + + assert await depot.exists_valid(fingerprint_refresh(secret)) is True + + +async def test_exists_valid_is_false_for_an_expired_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + assert await depot.exists_valid(fingerprint_refresh(secret)) is False + + +async def test_exists_valid_is_false_once_the_token_is_consumed(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + await depot.consume(fingerprint_refresh(secret)) + + assert await depot.exists_valid(fingerprint_refresh(secret)) is False + + +async def test_exists_valid_is_false_for_an_unknown_fingerprint(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + + assert await depot.exists_valid(fingerprint_refresh(generate_refresh_secret())) is False + + +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, + ) + + empreinte = fingerprint_refresh(secret) + expiration = datetime.now(UTC) + DUREE + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + token_hash=empreinte, + expires_at=expiration, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py new file mode 100644 index 0000000..da71aea --- /dev/null +++ b/apps/backend/tests/repositories/test_prediction.py @@ -0,0 +1,171 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Prediction +from app.repositories.prediction import PredictionRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +pytestmark = pytest.mark.integration + + +async def creer_prediction( + session: AsyncSession, *, site_id: str, **overrides: object +) -> Prediction: + prediction = Prediction( + site_id=site_id, + target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)), + target_metric=overrides.get("target_metric", "consumption_kwh"), + period_minutes=overrides.get("period_minutes", 60), + predicted_value=overrides.get("predicted_value", 42.0), + model_reference=overrides.get("model_reference", "lightgbm-test"), + status=overrides.get("status", "available"), + failure_reason=overrides.get("failure_reason"), + ) + session.add(prediction) + await session.flush() + return prediction + + +async def test_list_since_excludes_predictions_before_the_cutoff(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + dedans = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 16, tzinfo=UTC) + ) + await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC) + ) + + resultats = await depot.list_since( + since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id + ) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [dedans.prediction_id] + + +async def test_list_since_excludes_predictions_that_are_not_available( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + await creer_prediction( + session, + site_id=site.site_id, + target_at=datetime(2026, 9, 16, tzinfo=UTC), + status="insufficient_data", + predicted_value=None, + failure_reason="pas assez d'historique", + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + await session.rollback() + + assert list(resultats) == [] + + +async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id( + session: AsyncSession, +) -> None: + # `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans + # nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce + # départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus + # récent. + site = await creer_site(session) + depot = PredictionRepository(session) + cible = datetime(2026, 9, 16, tzinfo=UTC) + premier_run = await creer_prediction( + session, site_id=site.site_id, target_at=cible, predicted_value=10.0 + ) + second_run = await creer_prediction( + session, site_id=site.site_id, target_at=cible, predicted_value=20.0 + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [premier_run.prediction_id, second_run.prediction_id] + + +async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = PredictionRepository(session) + voulue = await creer_prediction(session, site_id=premier.site_id) + await creer_prediction(session, site_id=second.site_id) + + resultats = await depot.list_since( + since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id + ) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [voulue.prediction_id] + + +async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + ancienne = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.latest_by_site() + identifiants = [ + p.prediction_id + for p in resultats + if p.prediction_id in (ancienne.prediction_id, recente.prediction_id) + ] + await session.rollback() + + assert identifiants == [recente.prediction_id] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = PredictionRepository(session) + voulue_premier = await creer_prediction(session, site_id=premier.site_id) + voulue_second = await creer_prediction(session, site_id=second.site_id) + + resultats = await depot.latest_by_site() + identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)} + await session.rollback() + + assert identifiants == {voulue_premier.site_id, voulue_second.site_id} + + +async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + voulue = await creer_prediction( + session, + site_id=site.site_id, + status="insufficient_data", + predicted_value=None, + failure_reason="pas assez d'historique", + ) + + resultats = await depot.latest_by_site() + identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id] + await session.rollback() + + assert identifiants == [voulue.prediction_id] + + +async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = PredictionRepository(session) + + resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()] + + assert resultats == [] diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py new file mode 100644 index 0000000..ac3f854 --- /dev/null +++ b/apps/backend/tests/repositories/test_reading.py @@ -0,0 +1,335 @@ +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 +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +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 creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading: + reading = Reading( + site_id=site_id, + timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)), + source=overrides.get("source", "api_current"), + consumption_kw=overrides.get("consumption_kw", 10.0), + data_quality=overrides.get("data_quality", "good"), + raw_data=overrides.get("raw_data", {}), + ) + session.add(reading) + await session.flush() + return reading + + +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} + + +async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 15, tzinfo=UTC) + await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10 + ) + derniere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42 + ) + + resultats = await depot.latest_by_site() + retenues = [r.reading_id for r in resultats if r.site_id == site.site_id] + await session.rollback() + + assert retenues == [derniere.reading_id] + + +async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + trouvee = await depot.latest_for_site(site.site_id) + reading_id = trouvee.reading_id if trouvee else None + await session.rollback() + + assert reading_id == recente.reading_id + + +async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 15, tzinfo=UTC) + await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history") + derniere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current" + ) + + trouvee = await depot.latest_for_site(site.site_id) + reading_id = trouvee.reading_id if trouvee else None + await session.rollback() + + assert reading_id == derniere.reading_id + + +async def test_latest_for_site_ignores_the_readings_of_the_other_sites( + session: AsyncSession, +) -> None: + sans_lecture = await creer_site(session) + autre = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=autre.site_id) + + trouvee = await depot.latest_for_site(sans_lecture.site_id) + await session.rollback() + + assert trouvee is None + + +async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + plus_recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC) + ) + plus_ancienne = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id] + + +async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + dedans = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC) + ) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)) + + resultats = await depot.list_since( + since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [dedans.reading_id] + + +async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id( + session: AsyncSession, +) -> None: + # `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source` + # diffère (même piège que `latest_for_site`). Sans ce départage, `_detect_spike` traiterait + # cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution. + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 16, tzinfo=UTC) + premiere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10 + ) + seconde = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42 + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [premiere.reading_id, seconde.reading_id] + + +async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = ReadingRepository(session) + voulue = await creer_lecture(session, site_id=premier.site_id) + await creer_lecture(session, site_id=second.site_id) + + resultats = await depot.list_since( + since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [voulue.reading_id] + + +async def test_list_history_orders_the_readings_by_timestamp_descending( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + ancienne = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.list_history( + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=100, + offset=0, + ) + identifiants = [ + r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id) + ] + await session.rollback() + + assert identifiants == [recente.reading_id, ancienne.reading_id] + + +async def test_list_history_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_history( + site_id=premier.site_id, + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=100, + offset=0, + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [voulue.reading_id] + + +async def test_list_history_excludes_readings_outside_the_window(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, 10, tzinfo=UTC) + ) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC)) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC)) + + resultats = await depot.list_history( + site_id=site.site_id, + start=datetime(2026, 9, 1, tzinfo=UTC), + end=datetime(2026, 9, 30, tzinfo=UTC), + limit=100, + offset=0, + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [dedans.reading_id] + + +async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + lectures = [ + await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC) + ) + for jour in (1, 2, 3) + ] + + resultats = await depot.list_history( + site_id=site.site_id, + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=1, + offset=1, + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [lectures[1].reading_id] + + +async def test_list_history_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = ReadingRepository(session) + + resultats = await depot.list_history( + site_id=identifiant_site(), + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=100, + offset=0, + ) + + assert list(resultats) == [] diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py new file mode 100644 index 0000000..6585878 --- /dev/null +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -0,0 +1,142 @@ +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 import recommendation as module_recommendation +from app.repositories.recommendation import NouvelleRecommandation, 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) + + +def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation: + return NouvelleRecommandation( + alert_id=alert_id, + action="Délester les équipements non prioritaires", + explanation="Pic de consommation signalé.", + rule_reference=reference, + ) + + +async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + + creees = await depot.create_missing( + [nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")] + ) + await session.rollback() + + assert creees == 2 + + +async def test_create_missing_ignores_a_rule_already_held_for_the_alert( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + await depot.create_missing([nouvelle(alert_id)]) + + creees = await depot.create_missing([nouvelle(alert_id)]) + await session.rollback() + + assert creees == 0 + + +async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None: + creees = await RecommendationRepository(session).create_missing([]) + + assert creees == 0 + + +async def test_create_missing_inserts_every_proposal_across_several_batches( + session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2) + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)] + + creees = await depot.create_missing(propositions) + enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id] + await session.rollback() + + assert creees == 5 + assert len(enregistrees) == 5 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..f1adad8 --- /dev/null +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -0,0 +1,194 @@ +# 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, + ) + + famille = uuid.uuid4() + empreinte = fingerprint_refresh(secret) + expiration = datetime.now(UTC) + DUREE + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + family_id=famille, + token_hash=empreinte, + expires_at=expiration, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..a9864a6 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -0,0 +1,59 @@ +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) + 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 if site.site_id in (premier, second)] + await session.rollback() + + assert identifiants == [premier, second] diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py new file mode 100644 index 0000000..e52284f --- /dev/null +++ b/apps/backend/tests/repositories/test_user.py @@ -0,0 +1,194 @@ +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() + + requete = text( + "insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')" + ) + + with pytest.raises(IntegrityError): + await session.execute(requete, {"e": saisie}) + await session.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/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..956e1e4 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -0,0 +1,61 @@ +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") + + +def test_valide_complexite_accepts_an_accented_password() -> None: + assert valide_complexite("Sécurité1!") == "Sécurité1!" + + +@pytest.mark.parametrize("mot_de_passe", ["abcdefg1×", "abcdefg1÷"]) # noqa: RUF001 +def test_valide_complexite_rejects_a_password_without_uppercase_despite_times_or_divide( + mot_de_passe: str, +) -> None: + with pytest.raises(ValueError, match="majuscule"): + valide_complexite(mot_de_passe) + + +@pytest.mark.parametrize("mot_de_passe", ["ABCDEFG1×", "ABCDEFG1÷"]) # noqa: RUF001 +def test_valide_complexite_rejects_a_password_without_lowercase_despite_times_or_divide( + mot_de_passe: str, +) -> None: + with pytest.raises(ValueError, match="minuscule"): + valide_complexite(mot_de_passe) diff --git a/apps/backend/tests/services/__init__.py b/apps/backend/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py new file mode 100644 index 0000000..97b2b0a --- /dev/null +++ b/apps/backend/tests/services/test_alert.py @@ -0,0 +1,457 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta + +from app.models.energy import Alert +from app.services.alert import OUTAGE_THRESHOLD, AlertService, _severity_from_ratio + +NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +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={}, + ) + + +@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 + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return self._alerts + + async def create_many(self, alerts: list[Alert]) -> list[Alert]: + self.crees = list(alerts) + return self.crees + + +@dataclass +class FauxDepotLectures: + depuis: list[FauxLecture] = field(default_factory=list) + dernieres: list[FauxLecture] = field(default_factory=list) + + async def list_since(self, *, since: datetime, site_id: str | None = None) -> list[FauxLecture]: + return [lecture for lecture in self.depuis if site_id is None or lecture.site_id == site_id] + + async def latest_by_site(self) -> list[FauxLecture]: + return self.dernieres + + +@dataclass +class FauxDepotPredictions: + predictions: list[FauxPrediction] = field(default_factory=list) + + async def list_since( + self, *, since: datetime, site_id: str | None = None + ) -> list[FauxPrediction]: + return [p for p in self.predictions if site_id is None or p.site_id == site_id] + + +@dataclass +class FauxDepotSites: + sites: list[FauxSite] + + async def list_all(self) -> list[FauxSite]: + return self.sites + + +def service( + *, + sites: list[FauxSite], + lectures: list[FauxLecture] | None = None, + dernieres: list[FauxLecture] | None = None, + predictions: list[FauxPrediction] | None = None, + alerts: FakeRepository | None = None, +) -> tuple[AlertService, FakeRepository]: + depot_alertes = alerts or FakeRepository([]) + dernieres_lectures = dernieres if dernieres is not None else (lectures or []) + return ( + AlertService( + alerts=depot_alertes, # type: ignore[arg-type] + readings=FauxDepotLectures(depuis=lectures or [], dernieres=dernieres_lectures), # type: ignore[arg-type] + predictions=FauxDepotPredictions(predictions or []), # type: ignore[arg-type] + sites=FauxDepotSites(sites), # type: ignore[arg-type] + ), + depot_alertes, + ) + + +async def test_list_all_returns_the_repository_alerts() -> None: + svc, _ = service(sites=[], alerts=FakeRepository([alert(1), alert(2)])) + + 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([]) + svc, _ = service(sites=[], alerts=depot) + + 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_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> ( + None +): + # `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id` + # croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la + # liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir. + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[ + FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1), + FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2), + ], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "anomaly"] + assert candidate.threshold == 70.0 + assert candidate.prediction_id == 2 + + +async def test_detect_raises_an_outage_alert_past_the_threshold() -> None: + derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1) + svc, depot = service( + sites=[FauxSite("A")], + lectures=[], + dernieres=[FauxLecture("A", derniere)], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "outage"] + assert candidate.severity in {"low", "medium", "high", "critical"} + + +async def test_detect_ignores_a_site_still_within_the_outage_threshold() -> None: + derniere = NOW - OUTAGE_THRESHOLD + timedelta(minutes=1) + svc, depot = service( + sites=[FauxSite("A")], + lectures=[], + dernieres=[FauxLecture("A", derniere)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "outage"] == [] + + +async def test_detect_raises_a_critical_outage_alert_for_a_site_never_read() -> None: + svc, depot = service(sites=[FauxSite("A")], lectures=[], dernieres=[]) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "outage"] + assert candidate.severity == "critical" + assert candidate.source_alert_id == "outage:jamais" + + +async def test_detect_raises_a_sensor_alert_on_a_degraded_reading() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, data_quality="critical", null_reasons=["missing:x"])], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "sensor"] + assert candidate.severity == "critical" + + +async def test_detect_ignores_a_good_quality_reading_for_the_sensor_rule() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, data_quality="good")], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "sensor"] == [] + + +async def test_detect_scopes_to_a_single_site_when_asked() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0), FauxSite("B", capacity_kw=100.0)], + lectures=[ + FauxLecture("A", NOW, consumption_kw=150.0), + FauxLecture("B", NOW, consumption_kw=150.0), + ], + ) + + await svc.detect(now=NOW, site_id="A") + + assert {a.site_id for a in depot.crees} == {"A"} + + +async def test_detect_returns_early_when_there_is_no_site() -> None: + svc, depot = service(sites=[]) + + resultat = await svc.detect(now=NOW) + + assert resultat == [] + assert depot.crees == [] + + +async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0), + FauxLecture("A", NOW, consumption_kw=0.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0), + FauxLecture("A", NOW, consumption_kw=50.0), + ], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "spike"] + assert candidate.severity == "critical" + assert candidate.value == 50.0 + assert candidate.threshold == 0.0 + + +async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW, consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_ignores_an_anomaly_when_the_prediction_is_near_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=5.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=0.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +def test_severity_from_ratio_covers_every_band() -> None: + assert _severity_from_ratio(1.0) == "low" + assert _severity_from_ratio(1.2) == "medium" + assert _severity_from_ratio(1.5) == "high" + assert _severity_from_ratio(2.0) == "critical" + + +async def test_detect_does_not_call_create_many_when_nothing_triggers() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=10.0, data_quality="good")], + ) + + resultat = await svc.detect(now=NOW) + + assert resultat == [] + assert depot.crees == [] diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py new file mode 100644 index 0000000..50a906a --- /dev/null +++ b/apps/backend/tests/services/test_auth.py @@ -0,0 +1,717 @@ +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 fastapi import BackgroundTasks + +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.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, +) + +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, +) +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 +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 + self.mots_de_passe_changes = 0 + + 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 + + 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 + + +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)) + + +@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 + 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 + + +class FauxDepotJetonsReset: + def __init__( + self, revendique: ConsumedResetToken | None = None, *, valide: bool = False + ) -> None: + self.revendique = revendique + self.valide = valide + 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 exists_valid(self, token_hash: bytes) -> bool: + return self.valide + + 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 + comptes: FauxDepotComptes + tentatives: FauxDepotTentatives + jetons: FauxDepotJetons + audit: FauxDepotAudit + hacheur: FauxHacheur + jetons_reset: FauxDepotJetonsReset + tentatives_reset: FauxDepotTentativesReset + mailer: FauxMailer + + +def fabrique_service( + *, + compte: FauxCompte | None = None, + 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] + 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), + 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, + ) + + +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 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() + attirail = fabrique_service(compte=compte) + + 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 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: + attirail = fabrique_service(compte=None) + + with pytest.raises(InvalidCredentialsError): + await connecte(attirail.service) + + 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) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + + with pytest.raises(RateLimitedError): + await connecte(attirail.service) + + 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) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + + with pytest.raises(RateLimitedError): + await connecte(attirail.service) + + 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: + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)) + + with pytest.raises(InvalidCredentialsError): + await connecte(attirail.service) + + assert attirail.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: + attirail = fabrique_service(compte=compte) + + with pytest.raises(InvalidCredentialsError): + await connecte(attirail.service) + + 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: + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)) + + await connecte(attirail.service) + + assert attirail.comptes.rehachages == 1 + + +async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: + attirail = fabrique_service(compte=FauxCompte()) + + await connecte(attirail.service) + + 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 + + +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 == [] + + +async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + taches = BackgroundTasks() + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches + ) + + assert attirail.jetons_reset.invalidations == [compte.id] + assert attirail.jetons_reset.crees == [compte.id] + assert attirail.mailer.envois == [], "l'envoi doit être différé, pas fait dans la réponse" + await taches() + 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) + taches = BackgroundTasks() + + await attirail.service.request_password_reset( + email="inconnu@enervision.fr", + client_ip="203.0.113.10", + user_agent="pytest", + background_tasks=taches, + ) + await taches() + + 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) + taches = BackgroundTasks() + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches + ) + await taches() + + 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)) + taches = BackgroundTasks() + + with pytest.raises(RateLimitedError): + await attirail.service.request_password_reset( + email="operateur@enervision.fr", + client_ip="203.0.113.10", + user_agent="pytest", + background_tasks=taches, + ) + + await taches() + assert attirail.mailer.envois == [] + + +async def test_request_password_reset_logs_instead_of_raising_when_the_mailer_fails() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + taches = BackgroundTasks() + + async def echoue(*, to: str, reset_url: str) -> None: + raise RuntimeError("relais SMTP indisponible") + + attirail.mailer.send_password_reset_email = echoue # type: ignore[method-assign] + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches + ) + + await taches() + + +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_is_reset_token_valid_reflects_the_repository() -> None: + attirail_valide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=True)) + attirail_invalide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=False)) + + assert await attirail_valide.service.is_reset_token_valid("un-secret-opaque") is True + assert await attirail_invalide.service.is_reset_token_valid("un-secret-opaque") is False + + +async def test_confirm_password_reset_rejects_a_token_for_an_account_disabled_since() -> None: + compte = FauxCompte(is_active=False) + jetons_reset = FauxDepotJetonsReset( + revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id) + ) + attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset) + + with pytest.raises(InvalidOrExpiredResetTokenError): + 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.comptes.mots_de_passe_changes == 0 + assert attirail.jetons.revocations_par_compte == [] + + +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/services/test_prediction.py b/apps/backend/tests/services/test_prediction.py new file mode 100644 index 0000000..a402b24 --- /dev/null +++ b/apps/backend/tests/services/test_prediction.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.services.prediction import PredictionService + +TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) +CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +@dataclass +class FauxSite: + site_id: str + site_name: str + + +@dataclass +class FauxPrediction: + site_id: str + target_at: datetime + target_metric: str + period_minutes: int | None + predicted_value: float | None + status: str + failure_reason: str | None + model_reference: str + created_at: datetime + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotPredictions: + def __init__(self, predictions: list[FauxPrediction]) -> None: + self._predictions = predictions + + async def latest_by_site(self) -> list[FauxPrediction]: + return self._predictions + + +def prediction_disponible(site_id: str = "A") -> FauxPrediction: + return FauxPrediction( + site_id=site_id, + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=812.5, + status="available", + failure_reason=None, + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ) + + +async def test_summary_attaches_the_latest_prediction_to_its_site() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.site_id == "A" + assert site.prediction is not None + assert site.prediction.predicted_value == 812.5 + assert site.prediction.status == "available" + + +async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.sites[0].prediction is None + + +async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None: + insuffisante = FauxPrediction( + site_id="A", + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=None, + status="insufficient_data", + failure_reason="pas assez d'historique", + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ) + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.prediction is not None + assert site.prediction.status == "insufficient_data" + assert site.prediction.predicted_value is None + assert site.prediction.failure_reason == "pas assez d'historique" + + +async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].prediction is not None + assert par_site["B"].prediction is None diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py new file mode 100644 index 0000000..5718281 --- /dev/null +++ b/apps/backend/tests/services/test_reading.py @@ -0,0 +1,151 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from app.models.energy import Reading +from app.services.reading import ( + FENETRE_MAXIMALE, + FENETRE_PAR_DEFAUT, + FenetreInverseeError, + FenetreTropLargeError, + ReadingService, +) + + +def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading: + return Reading( + reading_id=reading_id, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="api_current", + consumption_kw=10.0, + data_quality="good", + raw_data={}, + ) + + +class FakeRepository: + def __init__(self, readings: list[Reading]) -> None: + self._readings = readings + self.appels: list[tuple[str | None, datetime, datetime, int, int]] = [] + + async def list_history( + self, + *, + start: datetime, + end: datetime, + site_id: str | None = None, + limit: int, + offset: int, + ) -> list[Reading]: + self.appels.append((site_id, start, end, limit, offset)) + return self._readings + + +async def test_list_history_returns_the_repository_readings() -> None: + service = ReadingService(readings=FakeRepository([reading(1), reading(2)])) + + lectures = await service.list_history(limit=500, offset=0) + + assert [r.reading_id for r in lectures] == [1, 2] + + +async def test_list_history_relays_the_site_id_limit_and_offset() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + debut = datetime(2026, 9, 1, tzinfo=UTC) + fin = datetime(2026, 9, 2, tzinfo=UTC) + + await service.list_history(site_id="site-1", start=debut, end=fin, limit=50, offset=10) + + assert depot.appels == [("site-1", debut, fin, 50, 10)] + + +async def test_list_history_defaults_to_the_last_24_hours_when_no_window_is_given() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + avant = datetime.now(UTC) + + await service.list_history(limit=500, offset=0) + + apres = datetime.now(UTC) + _, debut, fin, _, _ = depot.appels[0] + assert avant <= fin <= apres + assert fin - debut == FENETRE_PAR_DEFAUT + + +async def test_list_history_defaults_end_to_now_when_only_start_is_given() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + debut = datetime.now(UTC) - timedelta(hours=1) + avant = datetime.now(UTC) + + await service.list_history(start=debut, limit=500, offset=0) + + apres = datetime.now(UTC) + _, debut_transmis, fin, _, _ = depot.appels[0] + assert debut_transmis == debut + assert avant <= fin <= apres + + +async def test_list_history_defaults_start_to_24_hours_before_end_when_only_end_is_given() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + fin = datetime(2026, 9, 16, tzinfo=UTC) + + await service.list_history(end=fin, limit=500, offset=0) + + _, debut, fin_transmise, _, _ = depot.appels[0] + assert fin_transmise == fin + assert debut == fin - FENETRE_PAR_DEFAUT + + +async def test_list_history_normalizes_naive_datetimes_to_utc() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + + await service.list_history( + start=datetime(2026, 9, 1), end=datetime(2026, 9, 2), limit=500, offset=0 + ) + + _, debut, fin, _, _ = depot.appels[0] + assert debut == datetime(2026, 9, 1, tzinfo=UTC) + assert fin == datetime(2026, 9, 2, tzinfo=UTC) + + +async def test_list_history_raises_when_start_is_after_end() -> None: + service = ReadingService(readings=FakeRepository([])) + + debut = datetime(2026, 9, 2, tzinfo=UTC) + fin = datetime(2026, 9, 1, tzinfo=UTC) + + with pytest.raises(FenetreInverseeError): + await service.list_history(start=debut, end=fin, limit=500, offset=0) + + +async def test_list_history_raises_when_start_equals_end() -> None: + service = ReadingService(readings=FakeRepository([])) + instant = datetime(2026, 9, 1, tzinfo=UTC) + + with pytest.raises(FenetreInverseeError): + await service.list_history(start=instant, end=instant, limit=500, offset=0) + + +async def test_list_history_raises_when_the_window_exceeds_the_maximum_span() -> None: + service = ReadingService(readings=FakeRepository([])) + debut = datetime(2026, 1, 1, tzinfo=UTC) + fin = debut + FENETRE_MAXIMALE + timedelta(seconds=1) + + with pytest.raises(FenetreTropLargeError): + await service.list_history(start=debut, end=fin, limit=500, offset=0) + + +async def test_list_history_accepts_a_window_exactly_at_the_maximum_span() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + debut = datetime(2026, 1, 1, tzinfo=UTC) + fin = debut + FENETRE_MAXIMALE + + await service.list_history(start=debut, end=fin, limit=500, offset=0) + + assert depot.appels == [(None, debut, fin, 500, 0)] diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py new file mode 100644 index 0000000..725df25 --- /dev/null +++ b/apps/backend/tests/services/test_recommendation.py @@ -0,0 +1,163 @@ +from collections.abc import Sequence +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Alert, Recommendation +from app.repositories.recommendation import NouvelleRecommandation +from app.services.recommendation import RecommendationNotFoundError, RecommendationService + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +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, + ) + + +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], 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 + + 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 create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + self.recues = list(nouvelles) + return len(self.recues) if self._creees is None else self._creees + + +class FakeAlertRepository: + def __init__(self, alertes: list[Alert]) -> None: + self._alertes = alertes + self.site_demande: str | None = None + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.site_demande = site_id + if site_id is None: + return self._alertes + return [a for a in self._alertes if a.site_id == site_id] + + +class FakeTransaction: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +def service( + recommendations: FakeRepository | None = None, + alerts: FakeAlertRepository | None = None, + transaction: FakeTransaction | None = None, +) -> RecommendationService: + return RecommendationService( + recommendations=recommendations or FakeRepository([]), + alerts=alerts or FakeAlertRepository([]), + transaction=transaction or FakeTransaction(), + ) + + +async def test_list_all_returns_the_repository_recommendations() -> None: + depot = FakeRepository([recommendation(1), recommendation(2)]) + + 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: + 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: + with pytest.raises(RecommendationNotFoundError): + await service().get_by_id(404) + + +async def test_generate_persists_one_proposal_per_triggered_rule() -> None: + depot = FakeRepository([]) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")]) + ).generate() + + assert {n.rule_reference for n in depot.recues} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + } + assert rapport.recommandations_creees == 2 + + +async def test_generate_commits_once() -> None: + transaction = FakeTransaction() + + await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate() + + assert transaction.commits == 1 + + +async def test_generate_restricts_the_alerts_to_the_requested_site() -> None: + alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")]) + depot = FakeRepository([]) + + rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002") + + assert alertes.site_demande == "SITE002" + assert rapport.alertes_examinees == 1 + assert {n.alert_id for n in depot.recues} == {2} + + +async def test_generate_reports_nothing_when_no_alert_matches() -> None: + rapport = await service().generate() + + assert rapport.alertes_examinees == 0 + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 0 + + +async def test_generate_counts_the_proposals_the_database_already_held() -> None: + depot = FakeRepository([], creees=0) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte()]) + ).generate() + + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 1 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/services/test_sensor.py b/apps/backend/tests/services/test_sensor.py new file mode 100644 index 0000000..85073b3 --- /dev/null +++ b/apps/backend/tests/services/test_sensor.py @@ -0,0 +1,224 @@ +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" + + +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" diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py new file mode 100644 index 0000000..76584fb --- /dev/null +++ b/apps/backend/tests/services/test_site.py @@ -0,0 +1,122 @@ +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( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +@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 + + 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) + + +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures + + 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: + svc = service([site("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: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + 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/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/apps/backend/tests/services/test_user.py b/apps/backend/tests/services/test_user.py new file mode 100644 index 0000000..0cd2d0c --- /dev/null +++ b/apps/backend/tests/services/test_user.py @@ -0,0 +1,241 @@ +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 + + methode = getattr(attirail.service, action) + + with pytest.raises(UserNotFoundError): + await methode(**arguments) diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py new file mode 100644 index 0000000..530efee --- /dev/null +++ b/apps/backend/tests/test_cli.py @@ -0,0 +1,151 @@ +import json +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: + 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: + parser = cli.build_parser() + + with pytest.raises(SystemExit): + parser.parse_args([]) + + +def test_build_parser_requires_an_email() -> None: + parser = cli.build_parser() + + with pytest.raises(SystemExit): + 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 + valide_complexite(mot_de_passe) + + +def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: + 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-valide1" + + +def test_read_password_refuses_a_password_below_the_minimum_length( + monkeypatch: pytest.MonkeyPatch, +) -> None: + 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-valide1", "Un-autre-mot-de-passe2"]) + monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) + + 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 + + +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/apps/backend/tests/test_internal_alerts.py b/apps/backend/tests/test_internal_alerts.py new file mode 100644 index 0000000..690ac00 --- /dev/null +++ b/apps/backend/tests/test_internal_alerts.py @@ -0,0 +1,87 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_session_factory +from app.detection import internal_alerts +from app.repositories.alert import AlertRepository +from tests.repositories.test_reading import creer_lecture +from tests.repositories.test_site import creer as creer_site + + +def test_parse_args_defaults_to_no_site_and_no_instant() -> None: + arguments = internal_alerts.parse_args([]) + + assert arguments.site_id is None + assert arguments.now is None + + +def test_parse_args_reads_the_site_id() -> None: + arguments = internal_alerts.parse_args(["--site-id", "site-1"]) + + assert arguments.site_id == "site-1" + + +def test_parse_args_parses_the_instant_option() -> None: + arguments = internal_alerts.parse_args(["--now", "2026-09-16T12:00:00+00:00"]) + + assert arguments.now == datetime(2026, 9, 16, 12, tzinfo=UTC) + + +def test_parse_instant_treats_a_naive_datetime_as_utc() -> None: + assert internal_alerts._parse_instant("2026-09-16T12:00:00") == datetime( + 2026, 9, 16, 12, tzinfo=UTC + ) + + +def test_main_prints_how_many_alerts_were_recorded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + async def fausse_execution(*, now: datetime | None, site_id: str | None) -> int: + return 3 + + monkeypatch.setattr(internal_alerts, "run_detection", fausse_execution) + + code = internal_alerts.main([]) + + assert code == 0 + assert "3 nouvelle" in capsys.readouterr().out + + +@pytest.mark.integration +async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None: + # `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait + # rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur + # le modèle de `tests/api/test_matrice_acces.py`. + site = await creer_site(session, capacity_kw=100.0) + site_id = site.site_id + instant = datetime(2026, 9, 16, 12, tzinfo=UTC) + await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0) + await session.commit() + + try: + nombre = await internal_alerts.run_detection(now=instant, site_id=site_id) + + alertes = await AlertRepository(session).list_all(site_id=site_id) + types = [a.type for a in alertes] + await session.rollback() + + assert nombre == 1 + assert types == ["threshold"] + finally: + # `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les + # objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une + # requête hors contexte async. D'où `site_id`, capturé avant. + async with get_session_factory()() as nettoyage: + await nettoyage.execute( + text("delete from alert where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.execute( + text("delete from reading where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.execute( + text("delete from site where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.commit() diff --git a/apps/backend/tests/test_static_assets.py b/apps/backend/tests/test_static_assets.py new file mode 100644 index 0000000..125ecda --- /dev/null +++ b/apps/backend/tests/test_static_assets.py @@ -0,0 +1,13 @@ +# Piège : le logo est committé indépendamment à deux endroits (`app/static/`, servi par +# `/docs`/`/redoc`, et `apps/frontend/public/`, servi au front) faute d'étape de build partagée. +# Sans ce test, une mise à jour d'un seul des deux fichiers dérive silencieusement : rien en CI +# ne le détecte. + +from pathlib import Path + +BACKEND_LOGO = Path(__file__).parent.parent / "app" / "static" / "logo-icon.png" +FRONTEND_LOGO = Path(__file__).parent.parent.parent / "frontend" / "public" / "logo-icon.png" + + +def test_the_backend_logo_stays_in_sync_with_the_frontend_one() -> None: + assert BACKEND_LOGO.read_bytes() == FRONTEND_LOGO.read_bytes() diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock new file mode 100644 index 0000000..a8b434f --- /dev/null +++ b/apps/backend/uv.lock @@ -0,0 +1,1165 @@ +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 = "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" +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 = "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" +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 = "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" +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 = "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" +source = { editable = "." } +dependencies = [ + { name = "aiosmtplib" }, + { name = "alembic" }, + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pandas" }, + { name = "prometheus-fastapi-instrumentator" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-json-logger" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[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" }, + { 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" }, + { 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" }, +] + +[package.metadata.requires-dev] +dev = [ + { 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" }, + { 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 = "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" +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 = "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 = "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" +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 = "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" +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.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[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 = "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 = "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-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" +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 = "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" +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 = "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" +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" }, +] 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..e1b6f74 --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,45 @@ +# 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 +/test-results +/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/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000..1890bc1 --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,47 @@ +# ================== +# Étape 1 : Build +# ================== + +# Image pour frontend +FROM node:24-alpine3.22 AS builder + +WORKDIR /app + +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 . . + +# 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=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=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 && \ + 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 diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 0000000..c5b7484 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,88 @@ +# Frontend EnerVision + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.8. + +## Development server + +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 \ + --directory frontend \ + --style=scss \ + --routing \ + --ssr=false \ + --package-manager=npm \ + --skip-git +``` + +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, 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). + +## 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/TESTING.md b/apps/frontend/TESTING.md new file mode 100644 index 0000000..d4e92bf --- /dev/null +++ b/apps/frontend/TESTING.md @@ -0,0 +1,85 @@ +# 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 + +`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'; +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 new file mode 100644 index 0000000..8e508c2 --- /dev/null +++ b/apps/frontend/angular.json @@ -0,0 +1,100 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm", + "analytics": false + }, + "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": { + "optimization": { + "styles": { + "inlineCritical": false + } + }, + "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", + "options": { + "coverage": true, + "isolate": true, + "coverageReporters": [ + "text-summary", + "lcov", + "html" + ] + } + } + } + } + } +} 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; + } + } +} diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json new file mode 100644 index 0000000..a60cacb --- /dev/null +++ b/apps/frontend/package-lock.json @@ -0,0 +1,8289 @@ +{ + "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", + "chart.js": "^4.5.1", + "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", + "@vitest/coverage-v8": "^4.1.11", + "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/@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", + "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/@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", + "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/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", + "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/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", + "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/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", + "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-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", + "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/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", + "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/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", + "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/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", + "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/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", + "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..1c934bc --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "test:ci": "ng test --watch=false" + }, + "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", + "chart.js": "^4.5.1", + "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", + "@vitest/coverage-v8": "^4.1.11", + "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 0000000..2b78d24 Binary files /dev/null and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/logo-icon.png b/apps/frontend/public/logo-icon.png new file mode 100644 index 0000000..d3bdc53 Binary files /dev/null and b/apps/frontend/public/logo-icon.png differ diff --git a/apps/frontend/sonar-project.properties b/apps/frontend/sonar-project.properties new file mode 100644 index 0000000..46a6cce --- /dev/null +++ b/apps/frontend/sonar-project.properties @@ -0,0 +1,18 @@ +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/app +sonar.tests=apps/backend/tests + +# Liste des fichiers et dossiers à exclure de l'analyse +# 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 +# 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 diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts new file mode 100644 index 0000000..66ed3d3 --- /dev/null +++ b/apps/frontend/src/app/app.config.ts @@ -0,0 +1,21 @@ +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([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.html b/apps/frontend/src/app/app.html new file mode 100644 index 0000000..0680b43 --- /dev/null +++ b/apps/frontend/src/app/app.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..40e814f --- /dev/null +++ b/apps/frontend/src/app/app.routes.ts @@ -0,0 +1,33 @@ +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: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, + { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, + { + path: 'dashboard', + canActivate: [authGuard], + loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), + }, + { + path: 'sites', + canActivate: [authGuard], + loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList), + }, + { + path: 'sites/:siteId', + canActivate: [authGuard], + loadComponent: () => + import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), + }, + { + path: 'monitoring/sensors', + canActivate: [authGuard], + data: { role: 'admin' }, + loadComponent: () => + import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), + }, +]; 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..75753d6 --- /dev/null +++ b/apps/frontend/src/app/app.spec.ts @@ -0,0 +1,16 @@ +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(); + }); +}); 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/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..9064aaf --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts @@ -0,0 +1,177 @@ +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(); + vi.restoreAllMocks(); + }); + + 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("ne redirige pas vers /login sur un 401 de /auth/refresh si on est déjà sur /reset-password", () => { + vi.spyOn(window, 'location', 'get').mockReturnValue({ + pathname: '/reset-password', + } as Location); + + 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).not.toHaveBeenCalled(); + }); + + 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..16f3047 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts @@ -0,0 +1,93 @@ +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; +} + +const ROUTES_INVITEES = ['/login', '/forgot-password', '/reset-password']; + +// Piège : le rafraîchissement de session lancé au démarrage de l'app (provideAppInitializer) +// échoue silencieusement sans cookie valide. `window.location.pathname` (pas `router.url`, +// pas encore fiable à ce stade) évite qu'un 401 de fond écrase la navigation vers le lien de +// reset reçu par email. +function surRouteInvitee(): boolean { + return ROUTES_INVITEES.some((chemin) => window.location.pathname.startsWith(chemin)); +} + +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(); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } + return throwError(() => error); + } + + const kind = parseAuthError(error); + + if (kind === 'invalid_token') { + auth.clearSession(); + if (!surRouteInvitee()) { + 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(); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } + return throwError(() => refreshError); + }) + ); + } + + return throwError(() => error); + }) + ); +}; 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..4483313 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -0,0 +1,76 @@ +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); + }); + + it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => { + environment.useMockFixtures = true; + + http.get(`${environment.apiUrl}/predictions`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] }); + }); +}); 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..7c48936 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -0,0 +1,32 @@ +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 })); + } + // Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies + // par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel). + return next(req); +}; 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..f71a41b --- /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.1, + 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/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts new file mode 100644 index 0000000..c51c8eb --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -0,0 +1,99 @@ +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); +}); + + it('vérifie la validité du jeton de reset via GET /auth/reset-password/validate', () => { + let result: { valid: boolean } | undefined; + service.validateResetToken('un-secret-opaque').subscribe((r) => (result = r)); + + const req = httpMock.expectOne( + `${environment.apiUrl}/auth/reset-password/validate?token=un-secret-opaque` + ); + expect(req.request.method).toBe('GET'); + req.flush({ valid: true }); + + expect(result).toEqual({ valid: true }); + }); +}); 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..c2d3e9c --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -0,0 +1,92 @@ +import { Service, signal, computed, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, tap, finalize, shareReplay } from 'rxjs'; +import { + ForgotPasswordRequest, + LoginRequest, + PasswordChangeRequest, + Principal, + ResetPasswordRequest, + 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`); + } + + 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))); + } + + validateResetToken(token: string): Observable<{ valid: boolean }> { + return this.http.get<{ valid: boolean }>(`${environment.apiUrl}/auth/reset-password/validate`, { + params: { token }, + }); + } +} diff --git a/apps/frontend/src/app/core/services/predictions.service.spec.ts b/apps/frontend/src/app/core/services/predictions.service.spec.ts new file mode 100644 index 0000000..b3aaf7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { PredictionsService } from './predictions.service'; +import { environment } from '../../../environments/environment'; + +describe('PredictionsService', () => { + let service: PredictionsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PredictionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne un résumé de prévisions', () => { + let result: unknown; + service.getPredictions().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-18T09:00:00Z', + sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }], + }); + + expect((result as { sites: unknown[] }).sites.length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/predictions.service.ts b/apps/frontend/src/app/core/services/predictions.service.ts new file mode 100644 index 0000000..4245f7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { PredictionSummary } from '../../shared/models/prediction.model'; + +@Service() +export class PredictionsService { + private http = inject(HttpClient); + + getPredictions() { + return this.http.get(`${environment.apiUrl}/predictions`); + } +} diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts new file mode 100644 index 0000000..2513f08 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { ReadingsService } from './readings.service'; +import { environment } from '../../../environments/environment'; + +describe('ReadingsService', () => { + let service: ReadingsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ReadingsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("demande l'historique du site avec la fenêtre temporelle donnée", () => { + let result: unknown; + service + .getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z') + .subscribe((r) => (result = r)); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z'); + expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z'); + + req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]); + + expect((result as unknown[]).length).toBe(1); + }); + + it('ne pose pas de paramètres start/end quand ils sont omis', () => { + service.getHistory('SITE001').subscribe(); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.has('start')).toBe(false); + expect(req.request.params.has('end')).toBe(false); + + req.flush([]); + }); +}); diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts new file mode 100644 index 0000000..00a2711 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -0,0 +1,20 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Reading } from '../../shared/models/reading.model'; + +@Service() +export class ReadingsService { + private http = inject(HttpClient); + + getHistory(siteId: string, start?: string, end?: string) { + let params = new HttpParams().set('site_id', siteId); + if (start) { + params = params.set('start', start); + } + if (end) { + params = params.set('end', end); + } + return this.http.get(`${environment.apiUrl}/readings`, { params }); + } +} 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/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts new file mode 100644 index 0000000..7f475e7 --- /dev/null +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -0,0 +1,87 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { SitesService } from './sites.service'; +import { environment } from '../../../environments/environment'; + +describe('SitesService', () => { + let service: SitesService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SitesService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne la liste des sites', () => { + let result: unknown; + service.getSites().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites`); + expect(req.request.method).toBe('GET'); + + req.flush([ + { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, + ]); + + expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); + }); + + it('appelle le bon endpoint et retourne un site', () => { + let result: unknown; + service.getSite('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`); + expect(req.request.method).toBe('GET'); + + req.flush({ + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }); + + expect((result as { site_id: string }).site_id).toBe('SITE001'); + }); + + it('appelle le bon endpoint et retourne la mesure courante du site', () => { + let result: unknown; + service.getCurrent('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial', + }); + + expect((result as { null_reasons: string[] }).null_reasons).toEqual([ + 'electrical_sensor_failure', + ]); + }); +}); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts new file mode 100644 index 0000000..c613c7c --- /dev/null +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -0,0 +1,22 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Site } from '../../shared/models/site.model'; +import { SiteCurrent } from '../../shared/models/site-current.model'; + +@Service() +export class SitesService { + private http = inject(HttpClient); + + getSites() { + return this.http.get(`${environment.apiUrl}/sites`); + } + + getSite(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}`); + } + + getCurrent(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } +} 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/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html new file mode 100644 index 0000000..6c3de32 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -0,0 +1,38 @@ +
+
+ + +

Nouveau mot de passe

+

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

+ + + + + + + {{ passwordHint }} + + @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 new file mode 100644 index 0000000..e69de29 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..126e892 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -0,0 +1,97 @@ +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('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-passe1!' }); + + 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-passe1!' }); + + 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('.ev-alert'); + 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('.ev-alert')).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-passe1!' }); + 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-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 new file mode 100644 index 0000000..06be74d --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -0,0 +1,49 @@ +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'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; + +@Component({ + selector: 'app-change-password', + standalone: true, + imports: [ReactiveFormsModule, Button, Card, Alert, Brand], + 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); + passwordHint = PASSWORD_HINT; + + form = this.fb.nonNullable.group({ + current_password: ['', Validators.required], + new_password: ['', passwordValidators], + }); + + 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 (${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 new file mode 100644 index 0000000..cb802ca --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -0,0 +1,43 @@ +
+
+ + +

Connexion

+

Accédez à votre espace EnerVision

+ + + + + + + + @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 new file mode 100644 index 0000000..f0ffb17 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -0,0 +1,9 @@ +.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 new file mode 100644 index 0000000..5c0ac6c --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -0,0 +1,134 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, convertToParamMap, 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'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; + +function configure(queryParams: Record = {}) { + const authMock = { login: vi.fn() }; + const routerMock = { navigate: vi.fn() }; + + return { + authMock, + routerMock, + testBed: TestBed.configureTestingModule({ + imports: [Login, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } }, + }, + ], + }), + }; +} + +describe('Login', () => { + let authMock: { login: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + const attirail = configure(); + authMock = attirail.authMock; + routerMock = attirail.routerMock; + await attirail.testBed.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('.ev-alert'); + 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('.ev-alert'); + expect(errorEl?.textContent).toContain('30s'); + }); + + it('affiche le message standard quand on arrive avec ?motif=lien-expire', async () => { + const attirail = configure({ motif: MOTIF_LIEN_RESET_INVALIDE }); + await attirail.testBed.compileComponents(); + const fixture = TestBed.createComponent(Login); + + expect(fixture.componentInstance.errorMessage()).toContain('expiré'); + }); + + 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('.ev-alert')).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..22fbe8d --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -0,0 +1,68 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } 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'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { + MESSAGE_LIEN_RESET_INVALIDE, + MOTIF_LIEN_RESET_INVALIDE, +} from '../../../shared/models/auth-redirect-reason'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class Login { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + errorMessage = signal( + this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE + ? MESSAGE_LIEN_RESET_INVALIDE + : 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/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..fad2f7e --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,32 @@ +
+
+

Nouveau mot de passe

+ + @if (hasToken && !isCheckingToken()) { +

Choisissez votre nouveau mot de passe

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

{{ errorMessage() }}

+ } + + + } + + @if (hasToken && isCheckingToken()) { +

Vérification du lien...

+ } + + + +
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..38e30b5 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,129 @@ +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'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; + +function configure(token: string | null) { + return TestBed.configureTestingModule({ + imports: [ResetPassword, ReactiveFormsModule], + providers: [ + { + provide: AuthService, + useValue: { + resetPassword: vi.fn(), + validateResetToken: vi.fn().mockReturnValue(of({ valid: true })), + }, + }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } }, + }, + ], + }).compileComponents(); +} + +describe('ResetPassword', () => { + it("redirige vers /login avec le motif standard quand le jeton est absent de l'URL", async () => { + await configure(null); + const fixture = TestBed.createComponent(ResetPassword); + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + + fixture.detectChanges(); + + expect(fixture.componentInstance.hasToken).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); + }); + + it('vérifie le jeton sans le consommer dès le chargement de la page', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const auth = TestBed.inject(AuthService) as unknown as { validateResetToken: ReturnType }; + + fixture.detectChanges(); + + expect(auth.validateResetToken).toHaveBeenCalledWith('un-secret-opaque'); + expect(fixture.componentInstance.isCheckingToken()).toBe(false); + }); + + it('redirige immédiatement vers /login si la vérification signale un jeton invalide', async () => { + await configure('un-secret-perime'); + TestBed.overrideProvider(AuthService, { + useValue: { resetPassword: vi.fn(), validateResetToken: vi.fn().mockReturnValue(of({ valid: false })) }, + }); + const fixture = TestBed.createComponent(ResetPassword); + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); + }); + + 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('redirige vers /login avec le motif standard 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 }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + + component.onSubmit(); + + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); + }); + + it('affiche un message générique sur une erreur inattendue (pas 400)', 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: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + 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..64ad31e --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,79 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +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'; +import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink, PasswordRequirementsChecklist], + templateUrl: './reset-password.html', + styleUrl: './reset-password.scss', +}) +export class ResetPassword implements OnInit { + 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], + }); + + password = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' }); + isCheckingToken = signal(this.hasToken); + + ngOnInit(): void { + if (!this.hasToken) { + this.redirigeVersLoginLienInvalide(); + return; + } + + this.auth.validateResetToken(this.token).subscribe({ + next: ({ valid }) => { + this.isCheckingToken.set(false); + if (!valid) { + this.redirigeVersLoginLienInvalide(); + } + }, + error: () => this.isCheckingToken.set(false), + }); + } + + 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.redirigeVersLoginLienInvalide(); + return; + } + this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); + }, + }); + } + + private redirigeVersLoginLienInvalide(): void { + this.router.navigate(['/login'], { queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE } }); + } +} 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..f9a3fb2 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,113 @@ +
+
+
+ + +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+
+
+ @if (auth.principal()?.role === 'admin') { + Supervision des capteurs + } + Voir les sites + Déconnexion +
+
+ + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); 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 }} + +
+ +
+

Charge et alerte visuelle par site

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

Alertes actives

+
    + @for (alert of alerts(); track alert.alert_id) { +
  • + {{ alert.severity }} + {{ alert.message }} +
  • + } +
+
+ } + + @if (predictions().length > 0) { +
+

Prévisions de consommation

+
    + @for (site of predictions(); track site.site_id) { +
  • + {{ site.site_name }} + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + {{ prediction.predicted_value | number: '1.0-1' }} kWh + {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} + + } @else { + {{ + prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' + }} + } + } @else { + Pas encore de prévision + } +
  • + } +
+
+ } +
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss new file mode 100644 index 0000000..9c89f56 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -0,0 +1,169 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 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; + font-size: 1.75rem; + font-weight: 700; + } +} + +.dashboard__logo { + font-size: 1.3rem; +} + +.dashboard__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.dashboard__actions { + display: flex; + align-items: center; + gap: 1rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + padding: 1.25rem; + gap: 0.35rem; +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card--link { + cursor: pointer; + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--color-primary); + } +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.progress-bar { + height: 6px; + background: var(--color-border-light); + border-radius: var(--radius-pill); + overflow: hidden; + margin-top: 0.25rem; +} + +.progress-bar__fill { + height: 100%; + background: var(--color-primary); + border-radius: var(--radius-pill); + 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-md); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); +} + +.alert-item__message { + font-size: 0.9rem; +} + +.predictions-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prediction-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); +} + +.prediction-item__site { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__value { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__target { + margin-left: 0.35rem; + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} 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..910b0f6 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -0,0 +1,280 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +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'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +function predictionsMock(sites: unknown[] = []) { + return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; +} + +describe('Dashboard', () => { + afterEach(() => vi.useRealTimers()); + + it('charge les stats, les alertes et les prévisions au démarrage', async () => { + 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([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + // laisse le timer(0, ...) se déclencher avant de vérifier + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect(statsMock.getSummary).toHaveBeenCalled(); + expect(alertsMock.getAlerts).toHaveBeenCalled(); + expect(predictions.getPredictions).toHaveBeenCalled(); + expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.predictions().length).toBe(1); + expect(fixture.componentInstance.statsError()).toBeNull(); + expect(fixture.componentInstance.alertsError()).toBeNull(); + expect(fixture.componentInstance.predictionsError()).toBeNull(); + }); + + it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { + vi.useFakeTimers(); + const statsMock = { + getSummary: vi + .fn() + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of({ total_sites: 7, sites: [] })), + }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + vi.advanceTimersByTime(1); + expect(statsMock.getSummary).toHaveBeenCalledTimes(1); + 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.statsError()).toBeNull(); + }); + + it("n'interrompt pas la page quand le chargement des alertes échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.alerts().length).toBe(0); + expect(fixture.componentInstance.alertsError()).not.toBeNull(); + }); + + it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictions().length).toBe(0); + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + }); + + it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { + vi.useFakeTimers(); + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + + // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit + // toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée. + vi.advanceTimersByTime(30000); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); + }); + + it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const authMock = { + logout: vi.fn().mockReturnValue(of(undefined)), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + { provide: AuthService, useValue: authMock }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + const button = fixture.nativeElement.querySelector('.logout-button'); + button.click(); + + expect(authMock.logout).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + }); + it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const authMock = { + logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + { provide: AuthService, useValue: authMock }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + const button = fixture.nativeElement.querySelector('.logout-button'); + button.click(); + + expect(authMock.clearSession).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); +}); + + it('distingue le ton des sévérités high et critical', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + const dashboard = fixture.componentInstance; + + expect(dashboard.badgeToneForSeverity('low')).toBe('success'); + expect(dashboard.badgeToneForSeverity('medium')).toBe('warning'); + expect(dashboard.badgeToneForSeverity('high')).toBe('danger'); + expect(dashboard.badgeToneForSeverity('critical')).toBe('critical'); + expect(dashboard.badgeToneForSeverity('high')).not.toBe( + dashboard.badgeToneForSeverity('critical'), + ); + }); + + 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 new file mode 100644 index 0000000..2ba20c0 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -0,0 +1,135 @@ +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'; +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'; +import { Brand } from '../../shared/components/ui/brand/brand'; +import { Button } from '../../shared/components/ui/button/button'; + +const REFRESH_INTERVAL_MS = 10000; +const UNAVAILABLE_MESSAGE = + 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; + +const TON_PAR_SEVERITE: Record = { + low: 'success', + medium: 'warning', + high: 'danger', + critical: 'critical', +}; + +// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le +// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que +// tomber sur `undefined` si ce statut apparaît un jour. +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, + Card, + EvAlert, + Badge, + Brand, + Button, + ], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class Dashboard implements OnInit { + private statsService = inject(StatsService); + private alertsService = inject(AlertsService); + public auth = inject(AuthService); + private predictionsService = inject(PredictionsService); + private router = inject(Router); + private destroyRef = inject(DestroyRef); + + stats = signal(null); + alerts = signal([]); + predictions = signal([]); + + // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) + // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, + // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + statsError = signal(null); + alertsError = signal(null); + predictionsError = signal(null); + + ngOnInit(): void { + this.alertsService + .getAlerts() + .pipe(catchError(() => this.reportUnavailable(this.alertsError))) + .subscribe((alerts) => { + this.alertsError.set(null); + this.alerts.set(alerts); + }); + + // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul + // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. + this.predictionsService + .getPredictions() + .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.statsError))), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((stats) => { + this.statsError.set(null); + this.stats.set(stats); + }); + } + + badgeToneForSeverity(severity: AlertSeverity): BadgeTone { + 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']), + 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(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} 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..3497dd5 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html @@ -0,0 +1,51 @@ +
+ + +
+ + +
+

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]) { + @let diagnostic = sensorOf(site.sensors, entry[0]); +
  • + + {{ entry[1] }} + @if (diagnostic.status === 'failing') { + + @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.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..2e6e84e --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts @@ -0,0 +1,122 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { SensorStatusView } from './sensor-status'; +import { SensorsService } from '../../../core/services/sensors.service'; +import { SiteSensors } from '../../../shared/models/sensor-status.model'; +import {provideRouter} from '@angular/router'; + +const OK_SENSORS: SiteSensors = { + consumption: { status: 'ok', since: null }, + electrical: { status: 'ok', since: null }, + temperature: { status: 'ok', since: null }, + humidity: { status: 'ok', since: null }, + network: { status: 'ok', since: null }, +}; + +describe('SensorStatusView', () => { + let sensorsMock: { getStatus: ReturnType }; + + beforeEach(() => { + sensorsMock = { getStatus: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [SensorStatusView], + providers: [ + { provide: SensorsService, useValue: sensorsMock }, + provideRouter([]), + ], + }); + }); + + it('charge et affiche les données au démarrage', () => { + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [ + { site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS }, + ], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.componentInstance.data()?.sites.length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Bureau Test'); + }); + + it("affiche un message d'erreur si l'appel échoue", () => { + sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom'))); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).toBe( + 'État des capteurs indisponible, réessayez plus tard.' + ); + expect(fixture.componentInstance.data()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible'); + }); + + it('associe le bon ton de badge à chaque statut global', () => { + sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] })); + const fixture = TestBed.createComponent(SensorStatusView); + const component = fixture.componentInstance; + + expect(component.badgeToneForOverall('ok')).toBe('success'); + expect(component.badgeToneForOverall('degraded')).toBe('warning'); + expect(component.badgeToneForOverall('critical')).toBe('critical'); + expect(component.badgeToneForOverall('inconnu')).toBe('neutral'); + }); + + it('retourne le bon diagnostic via sensorOf', () => { + sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] })); + const fixture = TestBed.createComponent(SensorStatusView); + const component = fixture.componentInstance; + + expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null }); + }); + + it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => { + const sensors: SiteSensors = { + ...OK_SENSORS, + temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('dernière lecture le'); + expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00'); + }); + + it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => { + const sensors: SiteSensors = { + consumption: { status: 'failing', since: null }, + electrical: { status: 'failing', since: null }, + temperature: { status: 'failing', since: null }, + humidity: { status: 'failing', since: null }, + network: { status: 'failing', since: null }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue'); + expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le'); + }); +}); 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/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html new file mode 100644 index 0000000..9c4a4bc --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -0,0 +1,85 @@ +
+ + +
+ + +
+

{{ site()?.site_name ?? siteId() }}

+ @if (site(); as s) { +

+ {{ s.site_type }} · {{ s.location || 'Localisation inconnue' }} +

+ } +
+
+ @if (site(); as s) { + {{ s.status ?? '-' }} + } + @if (hasMeasurement() && qualityLabel(); as label) { + {{ label }} + } +
+
+ + @if (error(); as message) { + + } + + @if (site(); as s) { + @if (hasMeasurement()) { +
+ + Consommation vs capacité + @let consumption = consumptionKw(); + @if (consumption !== null) { + + + {{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW + + } @else { +

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

+ } +
+ + + Mesure instantanée +
+ @for (metric of metrics(); track metric.key) { +
+
{{ metric.label }}
+ @if (metric.value !== null) { +
{{ metric.value }}
+ } @else { +
+ Indisponible + ({{ metric.reason }}) +
+ } +
+ } +
+
+
+ + @if (history().length > 0) { +
+

Historique de consommation

+ +
+ } + } @else { + + } + } + + Retour aux sites +
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss new file mode 100644 index 0000000..a0032be --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -0,0 +1,119 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.site-detail__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.site-detail__logo { + font-size: 1.3rem; +} + +.site-detail__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.site-detail__badges { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.banner-empty { + display: block; + margin: 0 0 1.5rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + padding: 1.25rem; + gap: 0.35rem; +} + +.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; +} + +.card__unavailable { + color: var(--color-text-muted); + margin: 0; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem 1.5rem; + margin: 0.5rem 0 0; +} + +.metric { + dt { + font-size: 0.75rem; + color: var(--color-text-muted); + } + + dd { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.metric__unavailable { + color: var(--color-text-muted); + font-weight: 400; +} + +.metric__reason { + font-size: 0.8rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.chart-section { + margin-bottom: 2rem; +} 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 new file mode 100644 index 0000000..5e3cf46 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -0,0 +1,281 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { SiteDetail } from './site-detail'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; + +const SITE = { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', +}; + +const CURRENT_COMPLET = { + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: [] as string[], + data_quality: 'good' as const, +}; + +const SANS_MESURE = { + ...CURRENT_COMPLET, + timestamp: null, + consumption_kw: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + data_quality: 'critical' as const, +}; + +const LECTURE = { + reading_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-17T09:00:00Z', + source: 'api_history' as const, + consumption_kw: 118, + consumption_kwh: null, + consumption_euros: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + solar_irradiance_wm2: null, + is_working_hours: true, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +function setup( + siteId: string, + sitesMock: Partial, + readingsMock: Partial, +) { + const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); + TestBed.configureTestingModule({ + imports: [SiteDetail], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { paramMap } }, + { provide: SitesService, useValue: sitesMock }, + { provide: ReadingsService, useValue: readingsMock }, + ], + }); + return { fixture: TestBed.createComponent(SiteDetail), paramMap }; +} + +describe('SiteDetail', () => { + it('charge le site, la mesure courante et son historique au démarrage', () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + expect(fixture.componentInstance.current()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.history().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("signale l'indisponibilité quand un des appels échoue", () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + 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, + voltage_v: null, + current_a: null, + power_factor: null, + null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial' as const, + }; + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(partielle)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v'); + expect(tension?.value).toBeNull(); + expect(tension?.reason).toBe('capteur électrique en panne'); + + const texte = fixture.nativeElement.textContent; + expect(texte).toContain('Indisponible'); + expect(texte).toContain('capteur électrique en panne'); + expect(texte).toContain('Données partielles'); + }); + + it('recharge les données quand le paramètre de route siteId change', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE002'); + }); + + it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith( + 'SITE001', + '2026-09-16T10:00:00.000Z', + '2026-09-17T10:00:00Z', + ); + }); + + it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)), + }, + { getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).not.toHaveBeenCalled(); + expect(fixture.componentInstance.hasMeasurement()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.'); + }); +}); 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 new file mode 100644 index 0000000..21e716d --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -0,0 +1,213 @@ +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, 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'; +import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model'; +import { SiteCurrent } from '../../../shared/models/site-current.model'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; +import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; + +const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; +const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.'; +const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000; + +const TON_PAR_STATUT: Record = { + actif: 'success', + maintenance: 'warning', + hors_service: 'danger', +}; + +const TON_PAR_QUALITE: Record = { + good: 'success', + partial: 'warning', + degraded: 'danger', + critical: 'critical', +}; + +const LIBELLE_PAR_QUALITE: Record = { + good: 'Données complètes', + partial: 'Données partielles', + degraded: 'Données dégradées', + critical: 'Données critiques', +}; + +type MetricKey = + | 'consumption_kw' + | 'voltage_v' + | 'current_a' + | 'power_factor' + | 'temperature_celsius' + | 'humidity_percent'; + +interface MetricDef { + key: MetricKey; + label: string; + format: (value: number) => string; +} + +const CONSUMPTION_DEF: MetricDef = { + key: 'consumption_kw', + label: 'Consommation', + format: (v) => `${v.toFixed(1)} kW`, +}; + +const METRIC_DEFS: MetricDef[] = [ + 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) }, + { key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` }, + { key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` }, +]; + +// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ; +// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide. +const RAISONS_PAR_CHAMP: Record = { + consumption_kw: ['consumption_sensor_failure', 'network_loss'], + voltage_v: ['electrical_sensor_failure', 'network_loss'], + current_a: ['electrical_sensor_failure', 'network_loss'], + power_factor: ['electrical_sensor_failure', 'network_loss'], + temperature_celsius: ['temperature_sensor_failure', 'network_loss'], + humidity_percent: ['humidity_sensor_failure', 'network_loss'], +}; + +const LIBELLE_PAR_RAISON: Record = { + consumption_sensor_failure: 'capteur de consommation en panne', + electrical_sensor_failure: 'capteur électrique en panne', + temperature_sensor_failure: 'capteur de température en panne', + humidity_sensor_failure: "capteur d'humidité en panne", + network_loss: 'perte réseau', +}; + +export interface MetricView { + key: MetricKey; + label: string; + value: string | null; + reason: string; +} + +@Component({ + selector: 'app-site-detail', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], + templateUrl: './site-detail.html', + styleUrl: './site-detail.scss', +}) +export class SiteDetail { + private route = inject(ActivatedRoute); + private sitesService = inject(SitesService); + private readingsService = inject(ReadingsService); + private destroyRef = inject(DestroyRef); + + readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE; + + siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); + + site = signal(null); + current = signal(null); + history = signal([]); + error = signal(null); + + 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; + }); + + qualityTone = computed(() => { + const quality = this.current()?.data_quality; + return quality ? TON_PAR_QUALITE[quality] : 'neutral'; + }); + + metrics = computed(() => { + const current = this.current(); + return METRIC_DEFS.map((def) => { + const valeur = current ? current[def.key] : null; + return { + key: def.key, + label: def.label, + value: valeur != null ? def.format(valeur) : null, + reason: valeur == null ? this.reasonFor(def.key, current) : '', + }; + }); + }); + + constructor() { + toObservable(this.siteId) + .pipe( + filter((siteId): siteId is string => !!siteId), + // Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un + // nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché. + switchMap((siteId) => this.load(siteId)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((result) => { + this.error.set(null); + this.site.set(result.site); + this.current.set(result.current); + this.history.set(result.history); + }); + } + + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string) { + 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 }))), + ), + catchError(() => this.reportUnavailable()), + ); + } + + private loadHistory(siteId: string, current: SiteCurrent): Observable { + // Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la + // dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide. + const end = current.timestamp; + if (end === null) { + return of([]); + } + const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString(); + return this.readingsService.getHistory(siteId, start, end); + } + + private reasonFor(field: MetricKey, current: SiteCurrent | null): string { + const raisons = RAISONS_PAR_CHAMP[field]; + const trouvees = (current?.null_reasons ?? []) + .filter((raison) => raisons.includes(raison)) + .map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison); + 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/features/sites/site-list/site-list.html b/apps/frontend/src/app/features/sites/site-list/site-list.html new file mode 100644 index 0000000..9998066 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.html @@ -0,0 +1,46 @@ +
+ + +
+ + +
+

Sites

+

Vue d'ensemble du parc suivi

+
+
+ + @if (error(); as message) { + + } + + + + + + + + + + + + + + + @for (site of sites(); track site.site_id) { + + + + + + + + + } + +
NomTypeLocalisationCapacité (kW)Statut
{{ site.site_name }}{{ site.site_type }}{{ site.location || '-' }}{{ site.capacity_kw ?? '-' }}{{ site.status ?? '-' }}Détail
+
+
diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.scss b/apps/frontend/src/app/features/sites/site-list/site-list.scss new file mode 100644 index 0000000..9fa25a2 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.scss @@ -0,0 +1,63 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.site-list__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.site-list__logo { + font-size: 1.3rem; +} + +.site-list__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.table-card { + padding: 0; + overflow: hidden; +} + +.sites-table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 0.85rem 1.25rem; + text-align: left; + border-bottom: 1px solid var(--color-border-light); + } + + th { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; + font-weight: 600; + } + + tr:last-child td { + border-bottom: none; + } +} diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.spec.ts b/apps/frontend/src/app/features/sites/site-list/site-list.spec.ts new file mode 100644 index 0000000..56d8d6c --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { of, throwError } from 'rxjs'; +import { SiteList } from './site-list'; +import { SitesService } from '../../../core/services/sites.service'; + +describe('SiteList', () => { + it('charge et affiche les sites au démarrage', () => { + const sitesMock = { + getSites: vi.fn().mockReturnValue( + of([ + { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, + ]), + ), + }; + + TestBed.configureTestingModule({ + imports: [SiteList], + providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])], + }); + + const fixture = TestBed.createComponent(SiteList); + fixture.detectChanges(); + + expect(sitesMock.getSites).toHaveBeenCalled(); + expect(fixture.componentInstance.sites().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("signale l'indisponibilité quand le chargement échoue", () => { + const sitesMock = { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }; + + TestBed.configureTestingModule({ + imports: [SiteList], + providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])], + }); + + const fixture = TestBed.createComponent(SiteList); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.sites().length).toBe(0); + }); + + it('affiche un tiret pour les champs nullables', () => { + const sitesMock = { + getSites: vi.fn().mockReturnValue( + of([ + { + site_id: 'SITE002', + site_name: 'Site 2', + site_type: 'bureau', + location: null, + capacity_kw: null, + status: null, + }, + ]), + ), + }; + + TestBed.configureTestingModule({ + imports: [SiteList], + providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])], + }); + + const fixture = TestBed.createComponent(SiteList); + fixture.detectChanges(); + + const cells = fixture.nativeElement.querySelectorAll('td'); + expect(cells[2].textContent.trim()).toBe('-'); + expect(cells[3].textContent.trim()).toBe('-'); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.ts b/apps/frontend/src/app/features/sites/site-list/site-list.ts new file mode 100644 index 0000000..30e9e4e --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-list/site-list.ts @@ -0,0 +1,47 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { catchError, EMPTY, Observable } from 'rxjs'; +import { SitesService } from '../../../core/services/sites.service'; +import { Site } from '../../../shared/models/site.model'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; +import { Brand } from '../../../shared/components/ui/brand/brand'; + +const UNAVAILABLE_MESSAGE = 'Liste des sites indisponible, réessayez plus tard.'; + +const TON_PAR_STATUT: Record = { + actif: 'success', + maintenance: 'warning', + hors_service: 'danger', +}; + +@Component({ + selector: 'app-site-list', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand], + templateUrl: './site-list.html', + styleUrl: './site-list.scss', +}) +export class SiteList implements OnInit { + private sitesService = inject(SitesService); + + sites = signal([]); + error = signal(null); + + ngOnInit(): void { + this.sitesService + .getSites() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((sites) => this.sites.set(sites)); + } + + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} 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..672d50f --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -0,0 +1,59 @@ +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 { + 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] }); + 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(); + }); + + 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 new file mode 100644 index 0000000..bda661a --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -0,0 +1,67 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} 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, OnDestroy { + 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 } }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.html b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.html new file mode 100644 index 0000000..bc7ed54 --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.html @@ -0,0 +1,8 @@ +
    + @for (requirement of requirements(); track requirement.label) { +
  • + {{ requirement.met ? '✓' : '○' }} + {{ requirement.label }} +
  • + } +
diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss new file mode 100644 index 0000000..b5cb32b --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss @@ -0,0 +1,29 @@ +:host { + display: block; +} + +.password-requirements { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; + font-size: 0.8rem; + line-height: 1.5; + + li { + display: flex; + align-items: center; + gap: 0.4rem; + } + + .password-requirements-icon { + font-weight: 700; + } + + .unmet { + color: #9ca3af; + } + + .met { + color: #16a34a; + } +} diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts new file mode 100644 index 0000000..23c4bbc --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { PasswordRequirementsChecklist } from './password-requirements'; + +describe('PasswordRequirementsChecklist', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PasswordRequirementsChecklist], + }).compileComponents(); + }); + + it('ne coche aucune règle pour un mot de passe vide', () => { + const fixture = TestBed.createComponent(PasswordRequirementsChecklist); + fixture.componentRef.setInput('password', ''); + fixture.detectChanges(); + + expect(fixture.componentInstance.requirements().every((r) => !r.met)).toBe(true); + }); + + it('ne coche que les règles satisfaites pour un mot de passe partiel', () => { + const fixture = TestBed.createComponent(PasswordRequirementsChecklist); + fixture.componentRef.setInput('password', 'abcdefgh'); + fixture.detectChanges(); + + const parLabel = new Map(fixture.componentInstance.requirements().map((r) => [r.label, r.met])); + expect(parLabel.get('8 caractères minimum')).toBe(true); + expect(parLabel.get('1 minuscule')).toBe(true); + expect(parLabel.get('1 majuscule')).toBe(false); + expect(parLabel.get('1 chiffre')).toBe(false); + expect(parLabel.get('1 caractère spécial')).toBe(false); + }); + + it('coche toutes les règles pour un mot de passe conforme', () => { + const fixture = TestBed.createComponent(PasswordRequirementsChecklist); + fixture.componentRef.setInput('password', 'Un-nouveau-mot-de-passe1!'); + fixture.detectChanges(); + + expect(fixture.componentInstance.requirements().every((r) => r.met)).toBe(true); + }); +}); diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts new file mode 100644 index 0000000..36a4413 --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts @@ -0,0 +1,19 @@ +import { Component, computed, input } from '@angular/core'; +import { PASSWORD_REQUIREMENTS } from '../../validators/password.validator'; + +@Component({ + selector: 'app-password-requirements', + standalone: true, + templateUrl: './password-requirements.html', + styleUrl: './password-requirements.scss', +}) +export class PasswordRequirementsChecklist { + password = input(''); + + requirements = computed(() => + PASSWORD_REQUIREMENTS.map((requirement) => ({ + label: requirement.label, + met: requirement.test(this.password()), + })), + ); +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} 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 new file mode 100644 index 0000000..63be883 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -0,0 +1,101 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { Chart } from 'chart.js'; +import { ReadingHistoryChart } from './reading-history-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + static instances: ChartMock[] = []; + static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + 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; + data: { labels?: unknown[]; datasets: Record[] }; +}; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + +const READING = { + reading_id: 1, + site_id: 'S1', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_history' as const, + consumption_kw: 42, + consumption_kwh: null, + consumption_euros: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + solar_irradiance_wm2: null, + is_working_hours: null, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +describe('ReadingHistoryChart', () => { + it('se crée sans erreur avec une liste de lectures valide', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('met à jour le graphique quand les lectures changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const }, + ]); + fixture.detectChanges(); + + 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); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); +}); 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 new file mode 100644 index 0000000..17d1e06 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -0,0 +1,95 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { Reading, ReadingDataQuality } from '../../models/reading.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#3b82f6', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; +const UNKNOWN_QUALITY_COLOR = '#9ca3af'; + +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({ + selector: 'app-reading-history-chart', + standalone: true, + templateUrl: './reading-history-chart.html', + styleUrl: './reading-history-chart.scss', +}) +export class ReadingHistoryChart implements AfterViewInit, OnDestroy { + readings = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart<'line'>; + + constructor() { + effect(() => { + const series = toSeries(this.readings()); + if (this.chart) { + 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 series = toSeries(this.readings()); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'line', + data: { + labels: series.labels, + datasets: [ + { + data: series.values, + borderColor: '#3b82f6', + pointBackgroundColor: series.colors, + tension: 0.25, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } }, + }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} 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..59e6b6b --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -0,0 +1,92 @@ +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 { + 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] }); + 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(); + }); + + 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 new file mode 100644 index 0000000..6c17803 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -0,0 +1,76 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} 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, OnDestroy { + 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 (%)' } }, + }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} 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..21e7d6a --- /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: var(--color-warning-text); +} + +: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..00c7b7d --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.scss @@ -0,0 +1,35 @@ +:host { + display: inline-flex; + flex-shrink: 0; +} + +.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: var(--color-text-inverse); +} + +.ev-badge--success { + background: var(--color-success); +} + +.ev-badge--warning { + background: var(--color-warning); +} + +.ev-badge--danger { + background: var(--color-danger); +} + +.ev-badge--critical { + background: var(--color-critical); +} + +.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..e08ac82 --- /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' | 'critical' | '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/brand/brand.html b/apps/frontend/src/app/shared/components/ui/brand/brand.html new file mode 100644 index 0000000..7c4e599 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/brand/brand.html @@ -0,0 +1,2 @@ + +EnerVision diff --git a/apps/frontend/src/app/shared/components/ui/brand/brand.scss b/apps/frontend/src/app/shared/components/ui/brand/brand.scss new file mode 100644 index 0000000..3303909 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/brand/brand.scss @@ -0,0 +1,19 @@ +:host { + display: inline-flex; + align-items: center; + gap: 0.45em; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + line-height: 1; +} + +.ev-brand__icon { + height: 1.3em; + width: auto; + flex-shrink: 0; +} + +.ev-brand__name { + white-space: nowrap; +} diff --git a/apps/frontend/src/app/shared/components/ui/brand/brand.spec.ts b/apps/frontend/src/app/shared/components/ui/brand/brand.spec.ts new file mode 100644 index 0000000..7a6b16f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/brand/brand.spec.ts @@ -0,0 +1,14 @@ +import { TestBed } from '@angular/core/testing'; +import { Brand } from './brand'; + +describe('Brand', () => { + it("affiche l'icône et le nom EnerVision", async () => { + await TestBed.configureTestingModule({ imports: [Brand] }).compileComponents(); + const fixture = TestBed.createComponent(Brand); + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('img.ev-brand__icon'); + expect(icon).toBeTruthy(); + expect(fixture.nativeElement.textContent).toContain('EnerVision'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/brand/brand.ts b/apps/frontend/src/app/shared/components/ui/brand/brand.ts new file mode 100644 index 0000000..55f5706 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/brand/brand.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ev-brand', + standalone: true, + templateUrl: './brand.html', + styleUrl: './brand.scss', +}) +export class Brand {} 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..4cf5a67 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.html @@ -0,0 +1,9 @@ + 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..8fe3753 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.scss @@ -0,0 +1,55 @@ +.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--inline { + width: auto; + } +} + +.ev-button--primary { + background: var(--color-primary); + color: var(--color-text-inverse); + + &: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: var(--color-text-inverse); + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: var(--color-danger-hover); + } +} 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..c7b3148 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.ts @@ -0,0 +1,16 @@ +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); + fullWidth = input(true); +} 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/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/auth-redirect-reason.ts b/apps/frontend/src/app/shared/models/auth-redirect-reason.ts new file mode 100644 index 0000000..7feb0de --- /dev/null +++ b/apps/frontend/src/app/shared/models/auth-redirect-reason.ts @@ -0,0 +1,3 @@ +export const MOTIF_LIEN_RESET_INVALIDE = 'lien-expire'; +export const MESSAGE_LIEN_RESET_INVALIDE = + 'Ce lien de réinitialisation est invalide ou a expiré. Connectez-vous ou redemandez-en un.'; 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..ebed0d5 --- /dev/null +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -0,0 +1,35 @@ +export type Role = 'lecteur' | 'operateur' | 'admin'; + +export interface LoginRequest { + email: string; + password: string; +} + +export interface PasswordChangeRequest { + current_password: string; + new_password: string; +} + +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: 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/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/apps/frontend/src/app/shared/models/reading.model.ts b/apps/frontend/src/app/shared/models/reading.model.ts new file mode 100644 index 0000000..daba555 --- /dev/null +++ b/apps/frontend/src/app/shared/models/reading.model.ts @@ -0,0 +1,23 @@ +export type ReadingSource = 'csv' | 'api_current' | 'api_history'; +export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical'; + +export interface Reading { + reading_id: number; + site_id: string; + timestamp: string; + source: ReadingSource; + consumption_kw: number | null; + consumption_kwh: number | null; + consumption_euros: string | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + solar_irradiance_wm2: number | null; + is_working_hours: boolean | null; + data_quality: ReadingDataQuality | null; + null_reasons: string[] | null; + imputed_values: Record | null; + imputation_method: string | null; +} 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[]; +} diff --git a/apps/frontend/src/app/shared/models/site-current.model.ts b/apps/frontend/src/app/shared/models/site-current.model.ts new file mode 100644 index 0000000..9f1e6f5 --- /dev/null +++ b/apps/frontend/src/app/shared/models/site-current.model.ts @@ -0,0 +1,16 @@ +import { ReadingDataQuality } from './reading.model'; + +export interface SiteCurrent { + timestamp: string | null; + site_id: string; + site_type: string; + consumption_kw: number | null; + consumption_kwh: number | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + null_reasons: string[]; + data_quality: ReadingDataQuality; +} diff --git a/apps/frontend/src/app/shared/models/site.model.ts b/apps/frontend/src/app/shared/models/site.model.ts new file mode 100644 index 0000000..fcf18f0 --- /dev/null +++ b/apps/frontend/src/app/shared/models/site.model.ts @@ -0,0 +1,8 @@ +export interface Site { + site_id: string; + site_name: string; + site_type: string; + location: string | null; + capacity_kw: number | null; + status: string | null; +} 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/app/shared/validators/password.validator.spec.ts b/apps/frontend/src/app/shared/validators/password.validator.spec.ts new file mode 100644 index 0000000..455ee36 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.spec.ts @@ -0,0 +1,26 @@ +import { FormControl } from '@angular/forms'; +import { passwordValidators } from './password.validator'; + +function estValide(motDePasse: string): boolean { + return new FormControl(motDePasse, passwordValidators).valid; +} + +describe('passwordValidators', () => { + it('accepte un mot de passe couvrant les quatre classes', () => { + expect(estValide('Un-mot-de-passe1!')).toBe(true); + }); + + it('accepte un mot de passe accentué (alignement avec le backend, ex: "Sécurité1")', () => { + expect(estValide('Sécurité1!')).toBe(true); + }); + + it('refuse un mot de passe sans majuscule même avec un "×" ou un "÷"', () => { + expect(estValide('abcdefg1×')).toBe(false); + expect(estValide('abcdefg1÷')).toBe(false); + }); + + it('refuse un mot de passe sans minuscule même avec un "×" ou un "÷"', () => { + expect(estValide('ABCDEFG1×')).toBe(false); + expect(estValide('ABCDEFG1÷')).toBe(false); + }); +}); 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..78d4066 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -0,0 +1,40 @@ +// Contrainte : `PASSWORD_PATTERN` doit rester identique au validateur Pydantic de +// `app/schemas/auth.py` côté backend (mêmes plages de majuscules/minuscules, excluant +// × et ÷, mêmes chiffres 0-9, même jeu de caractères spéciaux). `\w`/`\d` divergent entre +// JavaScript (ASCII) et Python (Unicode) : une négation aurait accepté ou rejeté un même +// mot de passe différemment d'un côté à l'autre (ex. "Sécurité1"). + +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 SPECIAL_CHARACTERS = '!@#$%^&*()\\-_=+[\\]{};:,.?'; +const PASSWORD_PATTERN = new RegExp( + `^(?=.*[A-ZÀ-ÖØ-Þ])(?=.*[a-zà-öø-þ])` + + `(?=.*[0-9])(?=.*[${SPECIAL_CHARACTERS}]).*$`, +); + +export const passwordValidators = [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.maxLength(PASSWORD_MAX_LENGTH), + Validators.pattern(PASSWORD_PATTERN), +]; + +export interface PasswordRequirement { + label: string; + test: (value: string) => boolean; +} + +const SPECIAL_REGEX = new RegExp(`[${SPECIAL_CHARACTERS}]`); + +export const PASSWORD_REQUIREMENTS: PasswordRequirement[] = [ + { label: `${PASSWORD_MIN_LENGTH} caractères minimum`, test: (v) => v.length >= PASSWORD_MIN_LENGTH }, + { label: '1 majuscule', test: (v) => /[A-ZÀ-ÖØ-Þ]/.test(v) }, + { label: '1 minuscule', test: (v) => /[a-zà-öø-þ]/.test(v) }, + { label: '1 chiffre', test: (v) => /[0-9]/.test(v) }, + { label: '1 caractère spécial', test: (v) => SPECIAL_REGEX.test(v) }, +]; diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts new file mode 100644 index 0000000..3c946c9 --- /dev/null +++ b/apps/frontend/src/environments/environment.development.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + apiUrl: '/api/v1', + useMockFixtures: false, +}; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts new file mode 100644 index 0000000..1f39f6f --- /dev/null +++ b/apps/frontend/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + apiUrl: '/api/v1', + useMockFixtures: false, +}; diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html new file mode 100644 index 0000000..2c75ae4 --- /dev/null +++ b/apps/frontend/src/index.html @@ -0,0 +1,13 @@ + + + + + EnerVision + + + + + + + + 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..5599780 --- /dev/null +++ b/apps/frontend/src/styles.scss @@ -0,0 +1,11 @@ +@use 'styles/tokens'; +@use 'styles/forms'; +@use 'styles/auth-page'; +@use 'styles/links'; + +body { + margin: 0; + font-family: var(--font-family); + color: var(--color-text); + background: var(--color-bg); +} diff --git a/apps/frontend/src/styles/_auth-page.scss b/apps/frontend/src/styles/_auth-page.scss new file mode 100644 index 0000000..be4c566 --- /dev/null +++ b/apps/frontend/src/styles/_auth-page.scss @@ -0,0 +1,57 @@ +.auth-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--space-4); + box-sizing: border-box; + background: + radial-gradient(circle at 15% 10%, var(--color-primary-light) 0%, transparent 45%), + radial-gradient(circle at 85% 90%, var(--color-primary-light) 0%, transparent 40%), + var(--color-bg); +} + +.auth-card-wrapper { + width: 100%; + max-width: 420px; + + ev-card { + padding: 3rem 2.5rem; + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.06), + 0 8px 10px -6px rgba(0, 0, 0, 0.04); + } + + .auth-brand { + justify-content: center; + width: 100%; + font-size: 2.1rem; + margin-bottom: 1.75rem; + } + + h1 { + margin: 0; + font-size: 1.85rem; + font-weight: 700; + color: var(--color-text); + text-align: center; + } + + .auth-subtitle { + margin: 0.4rem 0 2rem; + color: var(--color-text-muted); + font-size: 0.95rem; + line-height: 1.4; + text-align: center; + } + + ev-alert { + display: block; + margin-top: 0.75rem; + } + + ev-button { + display: block; + margin-top: 1.75rem; + } +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..9bbfb0c --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -0,0 +1,31 @@ +.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; +} diff --git a/apps/frontend/src/styles/_links.scss b/apps/frontend/src/styles/_links.scss new file mode 100644 index 0000000..7569ff0 --- /dev/null +++ b/apps/frontend/src/styles/_links.scss @@ -0,0 +1,34 @@ +.ev-link { + color: var(--color-primary); + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.ev-breadcrumb { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--color-text-muted); + margin-bottom: 1.25rem; + + a { + color: var(--color-text-muted); + text-decoration: none; + + &:hover { + color: var(--color-primary); + text-decoration: underline; + } + } +} + +.ev-brand-link { + display: inline-flex; + color: inherit; + text-decoration: none; +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..2e7663b --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -0,0 +1,43 @@ +: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-hover: #b91c1c; + --color-danger-bg: #fef2f2; + --color-danger-border: #fecaca; + --color-critical: #b91c1c; + --color-warning-text: #92400e; + --color-text-inverse: #ffffff; + + // 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/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"] +} diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 0000000..e6ea512 --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + reportsDirectory: './coverage', + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: [ + '**/*.spec.*', + '**/*.test.*', + '**/node_modules/**', + '**/dist/**', + ], + }, + }, +}) \ No newline at end of file diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..fd62d6f --- /dev/null +++ b/db/README.md @@ -0,0 +1,39 @@ +# Base de donnees + +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. +- `seeds` : jeux de donnees de reference. + +Les migrations du schema applicatif expose par l'API vivent dans +`apps/backend/alembic`, pas ici. + +## `init` ne rejoue jamais + +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 : il faut detruire +le volume, ce que fait `make db-reset`. + +L'image apporte ses propres scripts dans ce dossier, et ils comptent : + +| 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. | + +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, 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/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/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/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/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..868ae17 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,74 @@ +# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste +# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et +# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide. +# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul +# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy. +# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour +# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi. + +name: enervision + +services: + db: + ports: !override + - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" + + mailpit: + ports: !override + - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" + + airflow-webserver: + ports: !override + - "127.0.0.1:${AIRFLOW_PORT:-8080}:8080" + + backend: + ports: !reset null + command: + - uvicorn + - app.main:create_app + - --factory + - --host + - 0.0.0.0 + - --port + - "8000" + - --proxy-headers + - --forwarded-allow-ips=* + environment: + APP_ENV: prod + APP_DEBUG: "false" + APP_TRUST_PROXY_HEADERS: "true" + APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local} + APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password + + frontend: + ports: !reset null + + proxy: + image: nginx:1.28-alpine + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_started + ports: + - "80:80" + - "443:443" + volumes: + - ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro + - ./infra/proxy/conf.d:/etc/nginx/conf.d:ro + - ./infra/proxy/tls:/etc/nginx/tls:ro + - acme_webroot:/var/www/certbot + restart: unless-stopped + + certbot: + image: certbot/certbot:v5.8.0 + profiles: ["acme"] + volumes: + - letsencrypt:/etc/letsencrypt + - acme_webroot:/var/www/certbot + - ./infra/proxy/tls:/tls + - ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro + +volumes: + acme_webroot: + letsencrypt: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da19f43 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,170 @@ +# 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 + +# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du +# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte +# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce +# dernier puisse au besoin l'inspecter sans en devenir dependant. +x-airflow-common: &airflow-common + build: + context: . + dockerfile: etl/airflow/Dockerfile + environment: &airflow-common-env + AIRFLOW__CORE__EXECUTOR: LocalExecutor + AIRFLOW__CORE__LOAD_EXAMPLES: "false" + # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de + # filtrer les services : une variable requise manquante casserait aussi `make db-up`, + # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par + # `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. + AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} + AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} + AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow + # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) : + # memes identifiants que le backend en attendant. + ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db + # Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en + # dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`. + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + # Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et + # Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008). + APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-} + volumes: + - ./etl/airflow/dags:/opt/airflow/dags + - ./etl/airflow/plugins:/opt/airflow/plugins + - airflow_logs:/opt/airflow/logs + - airflow_ml_state:/opt/ml/state + restart: unless-stopped + +services: + db: + image: timescale/timescaledb-ha:pg17 + environment: + 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/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 + timeout: 5s + retries: 12 + 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} + 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} + + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} + APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} + + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} + APP_SMTP_HOST: mailpit + APP_SMTP_PORT: "1025" + 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 + + frontend: + build: ./apps/frontend + ports: + - "${FRONTEND_PORT:-3000}:3000" + restart: unless-stopped + + # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont + # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte + # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume + # `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son + # succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, + # jamais par `argv` (ni `ps`, ni `docker compose config`). + # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous + # refuse en plus les deux cles de chiffrement vides. + airflow-init: + <<: *airflow-common + restart: "no" + environment: + <<: *airflow-common-env + _AIRFLOW_DB_MIGRATE: "true" + _AIRFLOW_WWW_USER_CREATE: "true" + _AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin} + _AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-} + _AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr} + depends_on: + db: + condition: service_healthy + command: + - bash + - -c + - | + set -euo pipefail + : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" + : "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" + : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" + exec airflow version + + airflow-webserver: + <<: *airflow-common + command: webserver + ports: + - "${AIRFLOW_PORT:-8080}:8080" + depends_on: + db: + condition: service_healthy + airflow-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + airflow-scheduler: + <<: *airflow-common + command: scheduler + depends_on: + db: + condition: service_healthy + airflow-init: + condition: service_completed_successfully + +volumes: + pgdata: + airflow_logs: + airflow_ml_state: diff --git a/docs/ML-START.md b/docs/ML-START.md new file mode 100644 index 0000000..68518ac --- /dev/null +++ b/docs/ML-START.md @@ -0,0 +1,177 @@ +# ML-START : accès aux données, scoring, frontière API et ML + +Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`, +`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md) +et par les vues d'architecture. Il répond à trois questions, et à elles seules : + +1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ; +2. **ce que fait un run de scoring**, étape par étape ; +3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là. + +Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md). +Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas. + +--- + +## 1. Mécanisme d'accès aux données + +### Deux sources, un seul schéma de sortie + +`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes** +(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`, +`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) : + +| Fonction | Source | Usage | +|---|---|---| +| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée | +| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement | +| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring | + +L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur +CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une +divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production +sous forme de prédictions silencieusement fausses. + +### Connexion directe à PostgreSQL, pas l'API + +Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois +raisons, à défendre telles quelles : + +- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire + passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer + trois fois pour un `SELECT`. +- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du + schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la + disponibilité du service, pour lire des données dont il connaît déjà la forme. +- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte + applicatif porteur d'un rôle métier. + +### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL` + +La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est +pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du +schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner +l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()` +lève donc plutôt que de retomber sur une valeur par défaut. + +**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`, +restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement, +`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente +avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md). + +### Le seul endroit qui construit les features + +`enervision_ml.features.build_features` est **l'unique** constructeur de features, à +l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une +fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à +ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**. +Ne jamais réécrire cette logique ailleurs : importer le module. + +Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais +un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des +lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait +un surapprentissage. + +--- + +## 2. Les étapes d'un run de scoring + +`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la +consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans +`prediction`. + +| # | Étape | Point de vigilance | +|---|---|---| +| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` | +| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 | +| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire | +| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | | + +### Ce que le run écrit, et ce qu'il n'écrase pas + +La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run +insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra +possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui +n'existe pas encore. + +Trois contraintes de cohérence sont portées par la base et non par le code applicatif : +`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ; +`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à +`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. + +### `model_reference` est un hachage, pas un nom de fichier + +`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque +entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction` +porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une +prédiction douteuse, de savoir quel modèle l'a produite. + +### Mode CSV : rien n'est écrit en base + +En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe +dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode +sert à valider le pipeline sans base joignable. + +### Limite assumée + +La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture +réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans +le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule, +sur une feature parmi une dizaine, pour une prévision à un seul pas. + +--- + +## 3. La frontière entre l'API et le ML + +```mermaid +flowchart LR + subgraph ml["ml/ · projet Python indépendant"] + train["enervision_ml.train
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. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly` +(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme +l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la +surveillance de dérive, portée par les issues #44 et #45. + +--- + +## Voir aussi + +- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests +- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés +- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions +- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions` +- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ca50a10 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# Documentation + +- `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 | +| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global | +| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | +| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | +| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement | 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. 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/adr/0005-modele-prediction-lightgbm.md b/docs/adr/0005-modele-prediction-lightgbm.md new file mode 100644 index 0000000..cb39524 --- /dev/null +++ b/docs/adr/0005-modele-prediction-lightgbm.md @@ -0,0 +1,101 @@ +# 0005 - Modèle de prédiction de consommation : LightGBM + +- Statut : accepté +- Date : 2026-09-17 + +## Contexte + +Le schéma `prediction` contraint déjà la forme de la solution (deux cibles de régression, +`consumption_kw` instantané et `consumption_kwh` sur `period_minutes`, un statut +`insufficient_data` à détecter explicitement), mais aucun modèle n'était choisi. Trois +contraintes non négociables cadrent le choix, discutées dans l'issue #89 : + +1. **EC06** (grille de notation individuelle) exige un modèle **entraîné, versionné avec + MLflow**, exposé via un endpoint fonctionnel, avec **surveillance du drift** en production. +2. **Aucun GPU dédié** : l'infra tourne on-premise sur une VM à 4 CPU / 8 Gio RAM (ou + `Standard_B2s`/`B2ms` côté Azure, 2 vCPU max) — Azure Machine Learning est de toute façon + bloqué par la politique Azure du projet. +3. **Délai serré** : le jalon J3 arrive à échéance le lendemain de la décision, J4 concentre déjà + 26 issues sur 4 jours. Un modèle long à mettre en œuvre retarde la chaîne complète (service de + scoring #37, moteur de recommandations #38, tests ML #44/#45, tous bloqués par ce choix). + +Le jeu de données est déjà disponible (`all_sites_combined.csv`, fourni par le formateur) : 7 +sites, 2 ans au pas horaire (~17 500 lignes/site), avec `temperature_celsius`, +`humidity_percent`, `solar_irradiance_wm2` en régresseurs exogènes et des features calendaires +déjà dérivées. + +## Options comparées + +| Critère | Prophet | LightGBM/XGBoost | NeuralProphet | SARIMA | Holt-Winters | Mistral (LLM) | +|---|---|---|---|---|---|---| +| Saisonnalités multiples (jour/semaine/an) | Oui, nativement | Oui, via features engineered | Oui, nativement, + autorégression | Une seule, lourd à régler (SARIMAX) | Une seule, aucune | Non conçu pour ça | +| Régresseurs exogènes | Oui, mais doivent être connus dans le futur au moment de la prédiction | Oui, via lags/moyennes glissantes sur le passé | Oui, natif | Difficile en multivarié | Aucun support | Contexte de prompt seulement, non appris | +| Coût de calcul (VM sans GPU) | Faible | Faible | Élevé (deep learning) | Faible | Faible | Élevé à prohibitif | +| Versionnable MLflow | Oui, nativement | Oui, nativement | Pas de support direct | Oui, générique | Pas de support direct | Rien à versionner (pas un modèle entraîné) | +| Granularité | Un modèle par site (ou par site × métrique) | Un seul modèle global sur tous les sites | Un par site | Un par site | Un par site | — | +| Effort avant l'échéance | Faible | Moyen (feature engineering) | Élevé | Moyen à élevé | Faible en soi | Élevé, ou factice | + +## Décision + +**LightGBM, un seul modèle global** couvrant tous les sites, plutôt qu'un modèle par site +(Prophet) ou par famille de site. Cible : `consumption_kwh`, avec `period_minutes` comme feature +d'entrée plutôt que comme étape d'agrégation post-prédiction. Suivi et versioning via **MLflow** +(tracking + registre de modèles), sur le magasin local par défaut dans un premier temps — +l'hébergement sur l'infra k3s reste une question ouverte, non bloquante pour démarrer. + +Raisons retenues, au-delà du tableau ci-dessus : + +- **Un modèle global plutôt qu'un modèle par site** évite la fragilité des sites les moins + fournis en historique : ils bénéficient de ce qu'apprennent les autres sites, ce qu'un Prophet + par site ne permet pas. +- **Aucune dépendance à une prévision météo future.** Prophet exige que ses régresseurs + (`add_regressor`) soient connus au moment prédit ; `temperature_celsius`, + `humidity_percent` et `solar_irradiance_wm2` sont des mesures passées, pas des prévisions, et + aucune source de prévision météo n'existe dans le projet. LightGBM s'en sort avec des features + de lag/moyenne glissante calculées sur l'historique déjà présent dans `reading`, cf. + `ml/enervision_ml/features.py` — un choix qui vaut aussi bien à l'entraînement qu'au futur + scoring. +- **Apprentissage direct sur `consumption_kwh`** avec `period_minutes` en feature, sans étape + d'agrégation intermédiaire que la sortie continue de Prophet aurait demandée. +- **Coût de calcul compatible avec l'infra on-premise sans GPU.** + +Débat complet, comparatif détaillé et décision finale : issue #89 (Johan, phyri0s, +ValentinDeFaria), actée en réunion d'équipe du 2026-09-17 et validée par l'ensemble de l'équipe. + +## Conséquences + +- Le pipeline d'entraînement (`ml/`, ce commit) lit `reading` + `site` par connexion PostgreSQL + directe et construit ses features par lags/moyennes glissantes plutôt que par régresseurs + contemporains, cf. `docs/ML-START.md`. +- Le rôle PostgreSQL dédié `enervision_ml` (lecture seule sur `reading`/`site`) n'est pas encore + provisionné : dette déjà assumée par l'ADR 0003 pour les comptes ETL/ML, `ML_DATABASE_URL` + pointe pour l'instant vers la même base que le backend applicatif en développement. +- Le service de scoring (#37), le moteur de recommandations (#38) et les tests de dérive + (#44/#45) restent à construire ; ils consommeront le même module `enervision_ml.features`, qui + doit rester strictement identique entre entraînement et scoring pour éviter un train/serve skew + silencieux. +- La surveillance de drift exigée par EC06 n'est pas encore implémentée : ce ticket ne livre que + l'entraînement et son suivi MLflow (paramètres, métriques, artefact modèle), pas le monitoring + en production. +- L'hébergement de MLflow sur l'infra k3s reste une question ouverte ; le magasin SQLite local + (`ml/mlflow.db`, ignoré par git) suffit pour l'instant à comparer des runs sur un poste. + +## Alternatives écartées + +- **Prophet** : proposition initiale, écartée après débat pour les raisons ci-dessus (modèle par + site, dépendance à une météo future indisponible, agrégation kWh en post-traitement). Reste un + candidat solide si un jour le projet doit produire une décomposition tendance/saisonnalité + explicable pour un usage différent. +- **Mistral (LLM)** : aucun produit dédié aux séries temporelles ; interroger un LLM généraliste + ne constitue pas un modèle entraîné et versionnable au sens MLflow, et le fine-tuning est hors + budget de calcul et hors délai. +- **SARIMA** : ne gère pas nativement plusieurs régresseurs exogènes ; réglage (p,d,q,P,D,Q) plus + long que le délai disponible. +- **NeuralProphet** : fait tout ce que fait Prophet et apprend en plus des motifs autorégressifs, + mais coûte plus cher en calcul (pas de GPU disponible) et n'a pas d'outil MLflow direct — piste + d'évolution possible, non engageante à ce stade. +- **Holt-Winters** : écarté d'entrée, pas seulement différé — aucun support de régresseurs + exogènes, alors que la météo et l'irradiance sont nécessaires ici. +- **CatBoost** : même famille que LightGBM, gère nativement les colonnes catégorielles (comme + `site_type`) sans encodage manuel. Non rejeté, différé : candidat à comparer si LightGBM + plafonne en précision. 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..4f23dce --- /dev/null +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -0,0 +1,81 @@ +# 0006 - Le moteur de règles de recommandation vit dans le backend + +- Statut : accepté +- Date : 2026-09-18 + +## Contexte + +L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le +schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation, +rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité +`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc +**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision. + +Deux emplacements se disputaient le code : + +1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome + qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire. + L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant + que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même + module `enervision_ml.features` ». +2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ». + +## Décision + +**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur +`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()` +qui l'applique, persiste et valide la transaction. + +Trois raisons : + +- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et + `alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de + l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des + features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel + « prédiction et recommandation », pas l'emplacement du code. +- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà + filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à + maintenir deux représentations du même domaine. +- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer + par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans + l'application FastAPI. + +Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli +generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien +n'oblige à exposer un port pour régénérer des recommandations. + +## Conséquences + +- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique : + entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même + commit. +- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà + utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de + ne pas commiter. +- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING` + sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la + fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. + Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée + ferait réapparaître ses recommandations à côté des anciennes. +- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée + par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ; + l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant + que la détection n'a pas tourné, sans que le moteur soit à retoucher. +- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT` + lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes, + et la détection interne peut alimenter `alert` au fil de l'eau. +- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait + à rouvrir : le moteur redeviendrait consommateur du pipeline ML. + +## Alternatives écartées + +- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de + l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération + hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des + features ou un modèle. +- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime + le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles, + et interdit toute trace de ce qui a été proposé et quand. +- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple, + mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que + rien n'exprime à ce stade. 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..014f95e --- /dev/null +++ b/docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md @@ -0,0 +1,120 @@ +# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose + +- Statut : accepté +- Date : 2026-09-21 + +## Contexte + +Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress +remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans +« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte. +`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » : +servir le SPA et l'API sous la même origine, et servir en HTTPS. + +Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers +`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En +développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le +fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend +le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et +l'authentification ne tient pas au rechargement de page. + +La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible +documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le +`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas +aboutir, faute de DNS public et de port 80 entrant. + +## Décision + +**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à +publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la +base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par +l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement. + +**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers +l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et +redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister. + +**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`. +Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de +certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura +pas à changer le jour de la bascule. + +**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par +`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut +pas savoir si elle est jointe en HTTPS, le terminateur, si. + +## Pourquoi Compose et pas l'ingress k3s + +Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le +provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais +été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux +topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un +stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les +deux topologies », et il ne tient pas dans le jalon. + +Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un +cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix +d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes. + +## Ce que le proxy n'expose pas, et pourquoi c'est structurel + +`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**, +pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans +`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est +nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer » +Swagger, il publiera les métriques avec. + +## Conséquences + +- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent + ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine + CORS, et le cookie devient `__Secure-ev_refresh`. +- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec + `$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que + lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP + du proxy, c'est-à-dire globalement. +- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire + `request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de + `request.client` du backend, en dernier recours quand l'en-tête est absent. +- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20 + requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret, + `login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en + sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule + adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration. +- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires + d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela : + ``. La feuille serait restée en + `media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans + `angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de + composants injectés à l'exécution. +- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient + donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être + amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique + couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`. +- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle + sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe + donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent + couvre l'hôte demandé, ce qui est la condition réelle à tenir. +- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend + sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502. +- La ligne API8 transport de `owasp-traceabilite.md` se referme. +- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas + été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur + avertit, et c'est la situation réelle du projet, pas un raccourci. +- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`, + sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement. + +## Alternatives écartées + +- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un + registre et des manifestes qui n'existent pas, à quatre jours du rendu. +- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS : + moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front + redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire. +- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le + défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx. +- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine + possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la + configuration nginx le jour où ces deux éléments existent. +- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi + la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`. 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 new file mode 100644 index 0000000..7bdd1da --- /dev/null +++ b/docs/architecture/00-vue-ensemble.md @@ -0,0 +1,188 @@ +# 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 | 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 + +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"] + proxy["Reverse proxy Nginx
:80 et :443"] + 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 --> proxy + proxy --> front + proxy --> api + front -.-> api + api --> db + airflow --> db + prom -.-> api + grafana -.-> db + grafana -.-> prom +``` + +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 `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. + +## É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`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | +| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite | +| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | +| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | +| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG | +| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) | + +## Flux bout en bout + +Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin +d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`, +issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas +l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16). + +```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 + +- **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, 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`. +- **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`. + +### Absent, et assumé + +- **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. +- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi + HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME + est livré et documenté mais pas exercé. +- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place + (`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir + [50-cicd.md](50-cicd.md). + +## 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/` | +| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | +| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête | +| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | +| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM | +| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | +| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md new file mode 100644 index 0000000..c6121e7 --- /dev/null +++ b/docs/architecture/10-infra.md @@ -0,0 +1,264 @@ +# Infrastructure + +Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans +quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles. + +| Topologie | Sert à | Statut | +|---|---|---| +| Docker Compose | Développer et recetter sur le poste | `Fait` | +| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` | +| k3s single-node | Cible à terme | `En cours` | + +## 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 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. + +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 (issues #115 et #116) + +Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make +airflow-up`, pas dans `make dev`) : + +| Service | Rôle | Points notables | +|---|---|---| +| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | +| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | +| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` | + +Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : +l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser +deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à +la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent +vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow. +Le choix et ses contreparties sont dans +l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). + +| DAG | Planification | Ce qu'il lance, et où | +|---|---|---| +| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` | +| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` | +| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` | + +**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la +`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring +finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni +tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais +entraîné ne doit pas priver le parc de ses alertes. Le décalage est donc une convention et non une +garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive +`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre +la couvre encore. Les quatre autres règles ne s'en aperçoivent pas. + +Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT +NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base, +`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes +d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises, +l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1` +rend contraignant. + +`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, +`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une +migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et +`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin +passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). + +Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier +entier avant de filtrer les services, une variable requise manquante casserait `make db-up`, +`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est +`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou +`AIRFLOW_APP_SECRET_KEY` vides). + +Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en +dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est +**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire +sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet +déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature +des JWT. + +**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision +prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un +cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le +déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1` +(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes. + +CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et +tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une +modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs) +avant de vérifier que les deux environnements s'y importent sans réseau. + +Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier +`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute +`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois : +`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`. + +`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base +est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur +(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la +construction de l'image. + +## Machine cible, exécution Docker + +Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le +`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de +l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). + +```mermaid +flowchart LR + navigateur["Navigateur"] + + subgraph machine["Machine on-premise"] + proxy["service proxy
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é. + +```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/` | +| 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 + +| 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` | 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` | +| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` | +| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | + +## Le trou vers k3s + +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 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é. +- **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..c499dca --- /dev/null +++ b/docs/architecture/20-backend.md @@ -0,0 +1,443 @@ +# 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. + +Les quatre couches existent désormais, portées par l'authentification. + +```mermaid +flowchart TB + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors, predictions"] + sc["schemas
Pydantic"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] + md["models
10 tables"] + db[("PostgreSQL")] + + ep --> sc + 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` +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` 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 + +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` | | +| `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` | + +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. + +## Routes exposées + +| Méthode | Chemin | Rôle | Erreurs déclarées | +|---|---|---|---| +| 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, 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 | +| 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 | `/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 | +| POST | `/api/v1/recommendations/generate` | Applique le moteur de règles aux alertes, filtrable par `site_id`. `admin` | 401, 403, 422, 500 | +| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | +| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | +| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | +| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | +| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | +| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | + +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. + +**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 `ROUTES_PUBLIQUES` dans `tests/api/acces.py`. + +`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour +`GET /alerts` puis pour les suivantes (`dataset`) : les quatre couches +`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. `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` 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. `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). + +`GET /predictions` reprend ce même sous-gabarit « dernière valeur par site » (`SiteRepository` + +`PredictionRepository`, un `SitePredictionSummaryResponse` par site plutôt qu'une table brute). +Différence avec `stats`/`sensors` : `prediction` est une vraie table accumulée par un processus +externe (`enervision_ml.score`, cf. `ml/README.md`), pas une valeur recalculée à la volée depuis +`reading` à chaque appel. `PredictionRepository.latest_by_site()` isole donc un `DISTINCT ON +(site_id)` ordonné par `target_at DESC` (couvert par l'index `ix_prediction_site_target`), le même +mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend `prediction: null` +plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte +`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais +LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. +[ML-START.md](../ML-START.md) section 3. + +`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique +le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni +feature ML : le catalogue `REGLES` associe à chaque type et à chaque gravité d'alerte une action et +son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit +[40-data.md](40-data.md). L'idempotence est portée par la base, pas par le service : +`RecommendationRepository.create_missing()` insère en `ON CONFLICT DO NOTHING` sur +`uq_recommendation_alert_rule`, donc rejouer la génération sur les mêmes alertes ne crée rien et +le rapport rendu distingue `recommendations_created` de `already_present`. Le même traitement est +disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make +recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend +plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). +Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la +génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est +découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres. + +`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, +donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ +owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni +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. + +### Détection d'alertes internes + +`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour +le type `anomaly`, les `prediction`) des dernières 48h (`LOOKBACK`) à cinq règles et enregistre une +ligne `alert` par déclenchement, avec `source="enervision"`. `metric`/`value`/`threshold` gardent +leur sens dans chaque règle plutôt que d'être laissés à `null` par commodité : + +| `type` | Règle | `value` / `threshold` | +|---|---|---| +| `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site | +| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site, ou redémarrage direct à une valeur positive depuis zéro (`critical`) | mesure actuelle / mesure précédente | +| `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite | +| `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` | +| `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` | + +La sévérité de chaque alerte (hors `sensor`, dérivée directement de `data_quality`) suit le même +barème par ratio observé/seuil : `low` sous 1.2, `medium` sous 1.5, `high` sous 2.0, `critical` +au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO NOTHING` sur +`uq_alert_source_reference`, et `source_alert_id` est construit de façon déterministe (règle + +horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une +alerte. + +**Pièges de tri corrigés en revue** : `reading`/`prediction` n'ont pas d'unicité sur leur couple +métier (`uq_reading_source` autorise deux `source` différentes au même `site_id`+`timestamp`, +`prediction` n'a aucune contrainte sur `(site_id, target_at)`, chaque run de scoring gardant sa +propre ligne). `ReadingRepository.list_since()`/`PredictionRepository.list_since()` départagent +donc les égalités par `reading_id`/`prediction_id` croissant, comme le font déjà +`latest_by_site()`/`latest_for_site()` sur les mêmes tables ; sans ce départage, l'ordre entre +lignes à égalité n'est pas garanti d'un appel à l'autre, et `_detect_spike`/`_detect_anomaly` +auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spike` ignore en plus +explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul +instant réel, pas une variation). + +La détection s'exécute dans `apps/backend`, puisque les règles s'appuient sur les repositories ORM +de l'API plutôt que sur une connexion SQL directe (contrairement à +`app/etl/historical_import.py`) : `uv run python -m app.detection.internal_alerts [--site-id ...] +[--now ...]`, ou `make detect-alerts`. Cette issue (#104) débloquait #38 (moteur de règles pour +recommandations), dont la FK `alert_id` `NOT NULL` n'avait jusqu'ici rien à référencer côté +`source="enervision"`. + +Depuis l'issue #116, le lancement n'est plus manuel : le DAG Airflow `alertes` enchaîne cette +détection et la génération des recommandations, toutes les heures à la quinzième minute. Airflow +exécute le code du backend en sous-processus, dans son propre environnement, ce que décide +l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md) ; le détail de l'ordonnancement est +dans [10-infra.md](10-infra.md). La ligne de commande reste le moyen de rejouer une fenêtre +passée, ce que `--now` permet et que le DAG ne fait pas. + +### `/health/ready` + +Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un +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 + 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 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 timescaledb loaded + 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. + +### Ajouter une route métier + +Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ +`readings`/`sensors`/`predictions` (`dataset`) : + +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. **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é + +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 : + +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 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`. +- 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é + +- 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). + +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. +- `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 + +- **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** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours, + `limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple — pas de curseur ni + de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique. + `statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête + individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants. +- **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..14a7012 --- /dev/null +++ b/docs/architecture/30-frontend.md @@ -0,0 +1,154 @@ +# Frontend + +Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps/frontend`. + +## État actuel + +Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée +par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API. + +Ce qui est en place : + +- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. +- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et + `provideHttpClient(withInterceptors([mockApiInterceptor]))`. +- Une route `/dashboard` en composant différé, et une redirection depuis la racine. +- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et + `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification + (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, + `features/dashboard` la page principale, `shared/components` la jauge de consommation et le + graphique de charge par site, tous deux construits sur Chart.js. +- 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. +- Prettier configuré, parser `angular` pour les gabarits HTML. + +Ce qui n'existe pas encore : + +- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien + ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. + `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette + PR (voir plus bas). +- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. +- Aucun lint : ESLint n'est pas installé. + +## Arborescence + +Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit 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 : `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->>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. +`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il +suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide +et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. + +En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce +qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un +`apiUrl` relatif, `/api/v1`. + +En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL +absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build +de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy** +qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir +[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md). + +## 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 | + +**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 a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`, +englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis +`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut +d'`APP_CORS_ORIGINS`, côté backend. + +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é + +- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. +- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et + `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le + rafraîchissement sur 401. Détail complet dans + [31-contrat-authentification.md](31-contrat-authentification.md). +- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les + gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un + (``), 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 + +Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md). + +## Questions ouvertes + +- **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. diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md new file mode 100644 index 0000000..4ad3dcf --- /dev/null +++ b/docs/architecture/31-contrat-authentification.md @@ -0,0 +1,162 @@ +# 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` | +| 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` | +| 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 [`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 + +```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": "..." } // 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. + +## 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 | +| `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 + +**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 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. + +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 la même origine 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/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md new file mode 100644 index 0000000..a7894d8 --- /dev/null +++ b/docs/architecture/32-design-systeme-frontend.md @@ -0,0 +1,103 @@ +# 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` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) | +| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) | +| `--font-family` | Police unique de l'application | +| `--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`) sont dans +`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles +s'appliquent directement à des `