Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3047f53e8 | ||
|
|
ec63798807 | ||
|
|
1e168ef03e | ||
|
|
ddf7e17788 | ||
|
|
56e8f95729 | ||
|
|
91d435748c | ||
|
|
0b41580310 | ||
|
|
e3d436ea53 | ||
|
|
c453700b13 | ||
|
|
f427f8a8f3 | ||
|
|
7be8a44e89 | ||
|
|
2095ad6bd3 | ||
|
|
fb06bf0062 | ||
|
|
e27142c7db | ||
|
|
77feabcbad | ||
|
|
18a4be6e38 | ||
|
|
12fb8860d1 | ||
|
|
7c2936f2ef | ||
|
|
297d85a0ca | ||
|
|
af58172742 | ||
|
|
b433e01fa8 | ||
|
|
5eb74aa64a | ||
|
|
cc0a58ac4c | ||
|
|
2400b6f05e | ||
|
|
9e33c276d6 | ||
|
|
6cb9ac00cb | ||
|
|
c1f63889c1 | ||
|
|
5875e8c239 | ||
|
|
c8383014a8 | ||
|
|
3cf9194d4c | ||
|
|
8def1e23af | ||
|
|
56c134beb0 | ||
|
|
8fb5ab9f65 | ||
|
|
e22feac2c4 | ||
|
|
5581cb1ef3 | ||
|
|
1d8c986386 | ||
|
|
85cb7c9eeb | ||
|
|
39b1d28ead | ||
|
|
5394257855 | ||
|
|
c741ffc827 | ||
|
|
d7f775f9f7 | ||
|
|
e381e0de09 | ||
|
|
7674955637 | ||
|
|
19cfac1cff | ||
|
|
1fce577a78 | ||
|
|
063092f2c7 | ||
|
|
1afaee069f | ||
|
|
7bc9a09489 | ||
|
|
921da48eb1 | ||
|
|
4ee2109628 | ||
|
|
ec1c05ad54 | ||
|
|
334ca5982b | ||
|
|
2465021d61 | ||
|
|
278299c2b1 | ||
|
|
4f69199734 | ||
|
|
1cca4f130c | ||
|
|
016f226fdb | ||
|
|
88f4f9a601 | ||
|
|
ed7311d4ef | ||
|
|
ff68a51424 | ||
|
|
11f9b1bcd5 | ||
|
|
41c18a3bb1 | ||
|
|
00ef725249 | ||
|
|
34f35f3ca0 | ||
|
|
f5cac1c2a8 | ||
|
|
8e07168a5e | ||
|
|
916b5d246a | ||
|
|
d167b64188 | ||
|
|
7913518c4b | ||
|
|
2ad7692f1c | ||
|
|
a158d6f84c | ||
|
|
7dfd7a7e74 | ||
|
|
8d28113f03 | ||
|
|
62932e57c3 | ||
|
|
cd4fd962be | ||
|
|
517144f7e5 | ||
|
|
cc7ca2d359 | ||
|
|
bbafe7d119 | ||
|
|
9c78c6dc38 | ||
|
|
9161b74874 | ||
|
|
6798d35572 | ||
|
|
f03dce5fe3 | ||
|
|
74ac1b4577 | ||
|
|
ebb72fb399 | ||
|
|
b2d52823ba | ||
|
|
fcbfcc8eb2 | ||
|
|
3692d486c6 | ||
|
|
24bf8bf4b9 | ||
|
|
9f465538bf | ||
|
|
515a92b395 | ||
|
|
0174272bdd | ||
|
|
c740b61b24 | ||
|
|
5669cd63ec | ||
|
|
2f97e4d434 | ||
|
|
07ea8d21dc | ||
|
|
06cb60463c | ||
|
|
1c6b6105bd | ||
|
|
77440281f8 | ||
|
|
63ee79cf32 | ||
|
|
76fa90dfcb | ||
|
|
6ecec1afef | ||
|
|
e13096c62a | ||
|
|
970a4a50b8 | ||
|
|
3fb907d6f6 | ||
|
|
c7490d01b3 | ||
|
|
523b623dc1 | ||
|
|
61e031fc16 | ||
|
|
781644b28e | ||
|
|
1654e4dd81 | ||
|
|
e50921c907 | ||
|
|
56f7211f0b | ||
|
|
730adb69b1 | ||
|
|
f43c9f76a0 | ||
|
|
c83fd889b8 | ||
|
|
04e4913952 | ||
|
|
cf22b2ae55 | ||
|
|
12c5cf87ad | ||
|
|
7b076171d2 | ||
|
|
ad149db0cb | ||
|
|
50dcb4de32 | ||
|
|
d25e544db6 | ||
|
|
d1e4d8cfa0 | ||
|
|
22ff1d93f4 | ||
|
|
1f0eb410eb | ||
|
|
078983a41d | ||
|
|
d632af57b8 | ||
|
|
596cf43eda | ||
|
|
fabd073aaf | ||
|
|
31a9cb109f | ||
|
|
50dddf952b | ||
|
|
fc6600aeaf | ||
|
|
1325a75e9a | ||
|
|
16a0cc4d3b | ||
|
|
11baea7117 | ||
|
|
5e7cb005ac | ||
|
|
3347fa5bdb | ||
|
|
da481d7485 | ||
|
|
344f82fcdd | ||
|
|
61b3494d12 | ||
|
|
44468e85d7 | ||
|
|
580da72eff | ||
|
|
e85c83972a | ||
|
|
da97e6aa8b | ||
|
|
0259f66b62 | ||
|
|
7b9406965e | ||
|
|
881f503f1a | ||
|
|
918bd971da | ||
|
|
6c1f86b4ce | ||
|
|
b5cffbf56f | ||
|
|
cdef30736a | ||
|
|
3ef7de5baa | ||
|
|
ff6e3c288c | ||
|
|
2390e58f78 | ||
|
|
b300be5186 | ||
|
|
b8f806518f | ||
|
|
83392c7ff4 |
@@ -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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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"
|
||||||
@@ -56,3 +56,27 @@ jobs:
|
|||||||
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
|
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
|
||||||
- name: Tests et couverture
|
- name: Tests et couverture
|
||||||
run: uv run pytest --cov-fail-under=85
|
run: uv run pytest --cov-fail-under=85
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
name: SonarQube
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "apps/frontend/**"
|
||||||
|
- "apps/backend/**"
|
||||||
|
- ".github/workflows/sonarqube.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "apps/frontend/**"
|
||||||
|
- "apps/backend/**"
|
||||||
|
- ".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
|
||||||
|
|
||||||
|
sonarqube:
|
||||||
|
needs: [build-front, build-back, test-front, test-back]
|
||||||
|
name: SonarQube
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Téléchargement du rapport de couverture (Front)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: apps/frontend/coverage/frontend
|
||||||
|
- name: Téléchargement du rapport de couverture (Back)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: apps/backend
|
||||||
|
- name: SonarQube Scan
|
||||||
|
uses: SonarSource/sonarqube-scan-action@v8
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
+10
-1
@@ -52,11 +52,20 @@ standalone_admin_password.txt
|
|||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
# Donnees locales
|
# Donnees locales
|
||||||
data/
|
data/raw/*
|
||||||
|
!data/raw/.gitkeep
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
monitoring/grafana/data/
|
monitoring/grafana/data/
|
||||||
monitoring/prometheus/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
|
||||||
|
|
||||||
# IDE et OS
|
# IDE et OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
BACKEND := apps/backend
|
BACKEND := apps/backend
|
||||||
|
FRONTEND := apps/frontend
|
||||||
|
ML := ml
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install dev lint format typecheck test test-cov test-integration check \
|
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
||||||
docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
|
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
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
install: ## Installe les dépendances du backend
|
install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
|
||||||
|
|
||||||
|
install-backend: ## Installe les dépendances du backend
|
||||||
cd $(BACKEND) && uv sync --all-groups
|
cd $(BACKEND) && uv sync --all-groups
|
||||||
|
|
||||||
dev: ## Lance l'API en rechargement à chaud
|
install-frontend: ## Installe les dépendances du frontend
|
||||||
|
cd $(FRONTEND) && npm ci
|
||||||
|
|
||||||
|
install-ml: ## Installe les dépendances du pipeline ML
|
||||||
|
cd $(ML) && 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
|
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
|
lint: ## Analyse statique du backend
|
||||||
cd $(BACKEND) && uv run ruff check .
|
cd $(BACKEND) && uv run ruff check .
|
||||||
|
|
||||||
@@ -34,6 +57,23 @@ test-integration: ## Exécute les tests exigeant une base joignable
|
|||||||
|
|
||||||
check: lint typecheck test ## Chaîne de vérification complète
|
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),)
|
||||||
|
|
||||||
docker-build: ## Construit l'image du backend
|
docker-build: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,18 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Domaine | Technologie | Emplacement | Etat |
|
| Domaine | Technologie | Emplacement | Etat |
|
||||||
|------------|-------------------------------------|---------------------|---------------|
|
|------------|-------------------------------------|---------------------|---------------|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette |
|
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||||
|
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
||||||
|
|
||||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||||
porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers
|
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
||||||
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
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
|
L'etat detaille de chaque brique et les vues d'architecture sont dans
|
||||||
[docs/architecture](docs/architecture/README.md).
|
[docs/architecture](docs/architecture/README.md).
|
||||||
@@ -52,6 +54,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
├── infra/terraform/
|
├── infra/terraform/
|
||||||
│ ├── modules/ Modules reutilisables
|
│ ├── modules/ Modules reutilisables
|
||||||
│ └── environments/ Racines Terraform, une par environnement
|
│ └── environments/ Racines Terraform, une par environnement
|
||||||
|
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
||||||
├── monitoring/
|
├── monitoring/
|
||||||
│ ├── prometheus/ Collecte et regles d'alerte
|
│ ├── prometheus/ Collecte et regles d'alerte
|
||||||
│ ├── grafana/ Provisioning et dashboards
|
│ ├── grafana/ Provisioning et dashboards
|
||||||
@@ -62,16 +65,17 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
|
|
||||||
## Demarrage
|
## Demarrage
|
||||||
|
|
||||||
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
|
Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que
|
||||||
|
`uv` installe seul.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # variables de docker-compose
|
cp .env.example .env # variables de docker-compose
|
||||||
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
||||||
|
|
||||||
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
||||||
make install # dependances du backend
|
make install # dependances du backend et du frontend
|
||||||
make migrate # applique les migrations Alembic
|
make migrate # applique les migrations Alembic
|
||||||
make dev # API sur http://localhost:8000, docs sur /docs
|
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
|
||||||
make check # lint + typage + tests
|
make check # lint + typage + tests
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,9 +86,11 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker
|
|||||||
5432, souvent deja pris par une autre base.
|
5432, souvent deja pris par une autre base.
|
||||||
|
|
||||||
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
||||||
conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
|
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
|
||||||
et n'embarque pas le source, donc toute modification y demande un
|
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
|
||||||
`docker compose up -d --build 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 :
|
Verifier que la base repond et que l'extension est chargee :
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me
|
|||||||
|
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
|
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
|
||||||
|
|||||||
+11
-1
@@ -28,7 +28,7 @@ de demarrer sans elles.
|
|||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
||||||
`make format`, `make typecheck`, `make test`, `make check`, `make docker-build`.
|
`make format`, `make typecheck`, `make test`, `make check`, `make openapi`, `make docker-build`.
|
||||||
|
|
||||||
Directement depuis ce dossier :
|
Directement depuis ce dossier :
|
||||||
|
|
||||||
@@ -39,8 +39,12 @@ uv run ruff format . # format
|
|||||||
uv run mypy app # typage strict
|
uv run mypy app # typage strict
|
||||||
uv run pytest # tests + couverture
|
uv run pytest # tests + couverture
|
||||||
uv run pytest -m integration # tests exigeant une base joignable
|
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
|
Les conventions de tests, les gabarits et le detail des marqueurs sont dans
|
||||||
[`TESTING.md`](TESTING.md).
|
[`TESTING.md`](TESTING.md).
|
||||||
|
|
||||||
@@ -99,10 +103,16 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
|||||||
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
|
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
|
||||||
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton |
|
| `/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/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/auth/me` | Décrit le compte connecté | jeton |
|
||||||
| `/api/v1/users` | Liste et crée des comptes | `admin` |
|
| `/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}` | Change le rôle ou l'activation | `admin` |
|
||||||
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `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` |
|
||||||
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -16,16 +16,29 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.core.hashing import Argon2Hasher, build_hasher
|
from app.core.hashing import Argon2Hasher, build_hasher
|
||||||
|
from app.core.mailer import Mailer, SmtpConfig
|
||||||
from app.core.principal import Principal
|
from app.core.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role, has_at_least
|
from app.core.roles import AccountKind, Role, has_at_least
|
||||||
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
|
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
|
||||||
from app.core.security import decode_access_token as decode_token
|
from app.core.security import decode_access_token as decode_token
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
|
from app.repositories.alert import AlertRepository
|
||||||
from app.repositories.audit_log import AuditLogRepository
|
from app.repositories.audit_log import AuditLogRepository
|
||||||
from app.repositories.login_attempt import LoginAttemptRepository
|
from app.repositories.login_attempt import LoginAttemptRepository
|
||||||
|
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||||
|
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||||
|
from app.repositories.reading import ReadingRepository
|
||||||
|
from app.repositories.recommendation import RecommendationRepository
|
||||||
from app.repositories.refresh_token import RefreshTokenRepository
|
from app.repositories.refresh_token import RefreshTokenRepository
|
||||||
|
from app.repositories.site import SiteRepository
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
from app.services.auth import AuthService, LoginPolicy
|
from app.services.alert import AlertService
|
||||||
|
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
|
||||||
|
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
|
from app.services.user import UserService
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
@@ -88,11 +101,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
|
|||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mailer(settings: SettingsDep) -> Mailer:
|
||||||
|
return Mailer(
|
||||||
|
SmtpConfig(
|
||||||
|
host=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_username,
|
||||||
|
password=(
|
||||||
|
settings.smtp_password.get_secret_value() if settings.smtp_password else None
|
||||||
|
),
|
||||||
|
use_tls=settings.smtp_use_tls,
|
||||||
|
from_address=settings.smtp_from_address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service(
|
def get_auth_service(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
|
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
|
||||||
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
|
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
|
||||||
|
mailer: Annotated[Mailer, Depends(get_mailer)],
|
||||||
) -> AuthService:
|
) -> AuthService:
|
||||||
return AuthService(
|
return AuthService(
|
||||||
users=UserRepository(session),
|
users=UserRepository(session),
|
||||||
@@ -109,6 +138,16 @@ def get_auth_service(
|
|||||||
max_failures_per_identifier=settings.login_max_failures_per_identifier,
|
max_failures_per_identifier=settings.login_max_failures_per_identifier,
|
||||||
),
|
),
|
||||||
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +170,48 @@ def get_user_service(
|
|||||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
||||||
|
return RecommendationService(recommendations=RecommendationRepository(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)]
|
||||||
|
|
||||||
|
|
||||||
async def get_current_principal(
|
async def get_current_principal(
|
||||||
credentials: CredentialsDep,
|
credentials: CredentialsDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 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`."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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`.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de
|
# 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.
|
# ce module : partout ailleurs, le navigateur n'attache rien de lui-même.
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
AuthServiceDep,
|
AuthServiceDep,
|
||||||
@@ -11,17 +11,30 @@ from app.api.deps import (
|
|||||||
get_client_ip,
|
get_client_ip,
|
||||||
require_trusted_origin,
|
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.cookies import RefreshCookie, cookie_name
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
|
ForgotPasswordRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
PasswordChangeRequest,
|
PasswordChangeRequest,
|
||||||
PrincipalResponse,
|
PrincipalResponse,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
ResetTokenValidationResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
|
InvalidOrExpiredResetTokenError,
|
||||||
RateLimitedError,
|
RateLimitedError,
|
||||||
SessionRejectedError,
|
SessionRejectedError,
|
||||||
)
|
)
|
||||||
@@ -31,6 +44,66 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
||||||
DETAIL_SESSION = "Session invalide"
|
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(
|
def repond(
|
||||||
@@ -61,7 +134,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
|
|||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse, summary="Ouvre une session")
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=TokenResponse,
|
||||||
|
summary="Ouvre une session",
|
||||||
|
responses=REPONSES_LOGIN,
|
||||||
|
)
|
||||||
async def login(
|
async def login(
|
||||||
payload: LoginRequest,
|
payload: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -98,7 +176,8 @@ async def login(
|
|||||||
"/refresh",
|
"/refresh",
|
||||||
response_model=TokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="Fait tourner la session",
|
summary="Fait tourner la session",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||||
|
responses=REPONSES_REFRESH,
|
||||||
)
|
)
|
||||||
async def refresh(
|
async def refresh(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -133,7 +212,8 @@ async def refresh(
|
|||||||
"/logout",
|
"/logout",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme la session courante",
|
summary="Ferme la session courante",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||||
|
responses=REPONSES_LOGOUT,
|
||||||
)
|
)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
||||||
@@ -150,6 +230,7 @@ async def logout(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme toutes les sessions du compte",
|
summary="Ferme toutes les sessions du compte",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin)],
|
||||||
|
responses=REPONSES_LOGOUT_ALL,
|
||||||
)
|
)
|
||||||
async def logout_all(
|
async def logout_all(
|
||||||
principal: CurrentPrincipalDep,
|
principal: CurrentPrincipalDep,
|
||||||
@@ -163,7 +244,12 @@ async def logout_all(
|
|||||||
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté")
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
response_model=PrincipalResponse,
|
||||||
|
summary="Décrit le compte connecté",
|
||||||
|
responses=REPONSES_AUTHENTIFIEES,
|
||||||
|
)
|
||||||
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||||
return PrincipalResponse.from_principal(principal)
|
return PrincipalResponse.from_principal(principal)
|
||||||
|
|
||||||
@@ -173,6 +259,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
|||||||
response_model=TokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="Change son propre mot de passe",
|
summary="Change son propre mot de passe",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin)],
|
||||||
|
responses=REPONSES_MOT_DE_PASSE,
|
||||||
)
|
)
|
||||||
async def change_password(
|
async def change_password(
|
||||||
payload: PasswordChangeRequest,
|
payload: PasswordChangeRequest,
|
||||||
@@ -200,3 +287,79 @@ async def change_password(
|
|||||||
|
|
||||||
logger.info("auth.password_changed user_id=%s", principal.id)
|
logger.info("auth.password_changed user_id=%s", principal.id)
|
||||||
return repond(response, settings, session)
|
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)
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.api.deps import SessionDep, SettingsDep
|
from app.api.deps import SessionDep, SettingsDep
|
||||||
|
from app.api.openapi import REPONSE_INDISPONIBLE
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.schemas.health import LivenessStatus, ReadinessStatus
|
from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter()
|
||||||
|
|
||||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/live", summary="Sonde de vivacite")
|
@router.get("/live", summary="Sonde de vivacité")
|
||||||
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||||
return LivenessStatus(
|
return LivenessStatus(
|
||||||
status="ok",
|
status="ok",
|
||||||
@@ -22,11 +23,13 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ready", summary="Sonde de disponibilite")
|
@router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE)
|
||||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||||
try:
|
try:
|
||||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||||
except SQLAlchemyError, OSError:
|
# `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce
|
||||||
|
# `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`).
|
||||||
|
except (SQLAlchemyError, OSError): # fmt: skip
|
||||||
logger.exception("Base de données injoignable")
|
logger.exception("Base de données injoignable")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.api.deps import LecteurDep, RecommendationServiceDep
|
||||||
|
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
|
from app.schemas.recommendation import RecommendationResponse
|
||||||
|
from app.services.recommendation import RecommendationNotFoundError
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
REPONSES_INTROUVABLE: Reponses = {
|
||||||
|
**REPONSE_VALIDATION,
|
||||||
|
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations")
|
||||||
|
async def list_recommendations(
|
||||||
|
_: LecteurDep, service: RecommendationServiceDep
|
||||||
|
) -> list[RecommendationResponse]:
|
||||||
|
recommendations = await service.list_all()
|
||||||
|
return [RecommendationResponse.model_validate(r) for r in recommendations]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{recommendation_id}",
|
||||||
|
response_model=RecommendationResponse,
|
||||||
|
summary="Décrit une recommandation",
|
||||||
|
responses=REPONSES_INTROUVABLE,
|
||||||
|
)
|
||||||
|
async def get_recommendation(
|
||||||
|
recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep
|
||||||
|
) -> RecommendationResponse:
|
||||||
|
try:
|
||||||
|
recommendation = await service.get_by_id(recommendation_id)
|
||||||
|
except RecommendationNotFoundError as erreur:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
||||||
|
) from erreur
|
||||||
|
return RecommendationResponse.model_validate(recommendation)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -3,7 +3,9 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
from app.api.deps import AdminDep, UserServiceDep
|
from app.api.deps import AdminDep, UserServiceDep
|
||||||
|
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.schemas.user import (
|
from app.schemas.user import (
|
||||||
TemporaryPasswordResponse,
|
TemporaryPasswordResponse,
|
||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
@@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = get_logger(__name__)
|
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")
|
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
|
||||||
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
||||||
@@ -27,6 +51,7 @@ async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]
|
|||||||
response_model=TemporaryPasswordResponse,
|
response_model=TemporaryPasswordResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Crée un compte avec un mot de passe provisoire",
|
summary="Crée un compte avec un mot de passe provisoire",
|
||||||
|
responses=REPONSES_CREATION,
|
||||||
)
|
)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
payload: UserCreateRequest,
|
payload: UserCreateRequest,
|
||||||
@@ -55,7 +80,12 @@ async def create_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation")
|
@router.patch(
|
||||||
|
"/{user_id}",
|
||||||
|
response_model=UserResponse,
|
||||||
|
summary="Change le rôle ou l'activation",
|
||||||
|
responses=REPONSES_MODIFICATION,
|
||||||
|
)
|
||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
payload: UserUpdateRequest,
|
payload: UserUpdateRequest,
|
||||||
@@ -92,6 +122,7 @@ async def update_user(
|
|||||||
"/{user_id}/password-reset",
|
"/{user_id}/password-reset",
|
||||||
response_model=TemporaryPasswordResponse,
|
response_model=TemporaryPasswordResponse,
|
||||||
summary="Réinitialise le mot de passe et ferme les sessions",
|
summary="Réinitialise le mot de passe et ferme les sessions",
|
||||||
|
responses=REPONSES_INTROUVABLE,
|
||||||
)
|
)
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1.endpoints import auth, health, users
|
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||||
|
from app.api.v1.endpoints import (
|
||||||
|
alerts,
|
||||||
|
auth,
|
||||||
|
health,
|
||||||
|
readings,
|
||||||
|
recommendations,
|
||||||
|
sensors,
|
||||||
|
sites,
|
||||||
|
stats,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|||||||
+70
-4
@@ -7,18 +7,26 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import string
|
||||||
import sys
|
import sys
|
||||||
from getpass import getpass
|
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.config import Settings, get_settings
|
||||||
from app.core.hashing import build_hasher
|
from app.core.hashing import build_hasher
|
||||||
from app.core.roles import Role
|
from app.core.roles import Role
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
|
from app.main import create_app
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
|
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
||||||
|
|
||||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||||
LONGUEUR_MINIMALE = 12
|
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||||
|
|
||||||
|
|
||||||
async def create_admin(
|
async def create_admin(
|
||||||
@@ -55,6 +63,35 @@ async def create_admin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
|
||||||
|
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
|
||||||
|
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
||||||
|
def settings_du_contrat() -> Settings:
|
||||||
|
return Settings(
|
||||||
|
_env_file=None,
|
||||||
|
name="EnerVision API",
|
||||||
|
version="0.1.0",
|
||||||
|
env="local",
|
||||||
|
api_prefix="/api/v1",
|
||||||
|
secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"),
|
||||||
|
database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_du_contrat() -> dict[str, Any]:
|
||||||
|
schema: dict[str, Any] = create_app(settings_du_contrat()).openapi()
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def rend_le_contrat() -> str:
|
||||||
|
return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def export_openapi(destination: Path) -> str:
|
||||||
|
destination.write_text(rend_le_contrat(), encoding="utf-8")
|
||||||
|
return f"Contrat OpenAPI écrit dans {destination}"
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
||||||
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
||||||
@@ -67,18 +104,42 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
admin.add_argument(
|
admin.add_argument(
|
||||||
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
|
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
contrat = sous_commandes.add_parser(
|
||||||
|
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
||||||
|
)
|
||||||
|
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
||||||
return parser
|
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:
|
def read_password(*, generate: bool) -> str:
|
||||||
if generate:
|
if generate:
|
||||||
mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE)
|
mot_de_passe = genere_mot_de_passe()
|
||||||
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
|
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
|
||||||
return mot_de_passe
|
return mot_de_passe
|
||||||
|
|
||||||
mot_de_passe = getpass("Mot de passe : ")
|
mot_de_passe = getpass("Mot de passe : ")
|
||||||
if len(mot_de_passe) < LONGUEUR_MINIMALE:
|
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
|
||||||
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères")
|
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 : "):
|
if mot_de_passe != getpass("Confirmation : "):
|
||||||
raise SystemExit("Les deux saisies diffèrent")
|
raise SystemExit("Les deux saisies diffèrent")
|
||||||
return mot_de_passe
|
return mot_de_passe
|
||||||
@@ -86,6 +147,11 @@ def read_password(*, generate: bool) -> str:
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
arguments = build_parser().parse_args(argv)
|
arguments = build_parser().parse_args(argv)
|
||||||
|
|
||||||
|
if arguments.commande == "export-openapi":
|
||||||
|
print(export_openapi(Path(arguments.output)))
|
||||||
|
return 0
|
||||||
|
|
||||||
mot_de_passe = read_password(generate=arguments.generate)
|
mot_de_passe = read_password(generate=arguments.generate)
|
||||||
|
|
||||||
succes, message = asyncio.run(
|
succes, message = asyncio.run(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"]
|
|||||||
SameSite = Literal["lax", "strict", "none"]
|
SameSite = Literal["lax", "strict", "none"]
|
||||||
|
|
||||||
SECRET_KEY_MIN_LENGTH = 32
|
SECRET_KEY_MIN_LENGTH = 32
|
||||||
|
REFRESH_COOKIE_DEFAUT = "ev_refresh"
|
||||||
SENTINELLES_INTERDITES = frozenset(
|
SENTINELLES_INTERDITES = frozenset(
|
||||||
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
||||||
)
|
)
|
||||||
@@ -38,7 +39,7 @@ class Settings(BaseSettings):
|
|||||||
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
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_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
|
||||||
|
|
||||||
refresh_cookie_name: str = "ev_refresh"
|
refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT
|
||||||
cookie_path: str = "/api/v1/auth"
|
cookie_path: str = "/api/v1/auth"
|
||||||
cookie_samesite: SameSite = "strict"
|
cookie_samesite: SameSite = "strict"
|
||||||
cookie_secure: bool | None = None
|
cookie_secure: bool | None = None
|
||||||
@@ -53,6 +54,19 @@ class Settings(BaseSettings):
|
|||||||
login_max_failures_per_ip: int = Field(default=20, ge=1)
|
login_max_failures_per_ip: int = Field(default=20, ge=1)
|
||||||
login_max_failures_per_identifier: int = Field(default=50, 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
|
trust_proxy_headers: bool = False
|
||||||
expose_api_docs: bool | None = None
|
expose_api_docs: bool | None = None
|
||||||
metrics_token: SecretStr | None = None
|
metrics_token: SecretStr | None = None
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 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.errors import register_error_handlers
|
||||||
from app.api.middleware import SecurityHeadersMiddleware
|
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.security import require_metrics_token
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
@@ -17,6 +23,8 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
|
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
|
||||||
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
|
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
LOGO_URL = "/static/logo-icon.png"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -37,13 +45,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
application = FastAPI(
|
application = FastAPI(
|
||||||
title=resolved.name,
|
title=resolved.name,
|
||||||
version=resolved.version,
|
version=resolved.version,
|
||||||
|
summary=SUMMARY,
|
||||||
|
description=DESCRIPTION,
|
||||||
|
openapi_tags=TAGS,
|
||||||
debug=resolved.debug,
|
debug=resolved.debug,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url="/docs" if documentee else None,
|
docs_url=None,
|
||||||
redoc_url="/redoc" if documentee else None,
|
redoc_url=None,
|
||||||
openapi_url="/openapi.json" if documentee else 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)
|
application.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
if resolved.allowed_origins:
|
if resolved.allowed_origins:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
||||||
from app.models.login_attempt import LoginAttempt
|
from app.models.login_attempt import LoginAttempt
|
||||||
|
from app.models.password_reset_attempt import PasswordResetAttempt
|
||||||
|
from app.models.password_reset_token import PasswordResetToken
|
||||||
from app.models.refresh_token import RefreshToken
|
from app.models.refresh_token import RefreshToken
|
||||||
from app.models.user import AppUser
|
from app.models.user import AppUser
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ __all__ = [
|
|||||||
"AuditLog",
|
"AuditLog",
|
||||||
"Dataset",
|
"Dataset",
|
||||||
"LoginAttempt",
|
"LoginAttempt",
|
||||||
|
"PasswordResetAttempt",
|
||||||
|
"PasswordResetToken",
|
||||||
"Prediction",
|
"Prediction",
|
||||||
"Reading",
|
"Reading",
|
||||||
"Recommendation",
|
"Recommendation",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class AuditAction(StrEnum):
|
|||||||
COMPTE_ACTIVE = "user.enabled"
|
COMPTE_ACTIVE = "user.enabled"
|
||||||
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
|
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
|
||||||
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
|
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"
|
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
|
||||||
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
|
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
|
||||||
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
|
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.energy import Alert
|
||||||
|
|
||||||
|
|
||||||
|
class AlertRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
self, *, site_id: str | None = None, severity: str | None = None
|
||||||
|
) -> Sequence[Alert]:
|
||||||
|
requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc())
|
||||||
|
if site_id is not None:
|
||||||
|
requete = requete.where(Alert.site_id == site_id)
|
||||||
|
if severity is not None:
|
||||||
|
requete = requete.where(Alert.severity == severity)
|
||||||
|
return (await self._session.scalars(requete)).all()
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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_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()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.energy import Recommendation
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def list_all(self) -> Sequence[Recommendation]:
|
||||||
|
requete = select(Recommendation).order_by(Recommendation.recommendation_id)
|
||||||
|
return (await self._session.scalars(requete)).all()
|
||||||
|
|
||||||
|
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
|
||||||
|
requete = select(Recommendation).where(
|
||||||
|
Recommendation.recommendation_id == recommendation_id
|
||||||
|
)
|
||||||
|
recommendation: Recommendation | None = await self._session.scalar(requete)
|
||||||
|
return recommendation
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,17 +1,45 @@
|
|||||||
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix
|
# 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.
|
# 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 typing import Literal, Self
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||||
|
|
||||||
from app.core.principal import Principal
|
from app.core.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role
|
from app.core.roles import AccountKind, Role
|
||||||
|
|
||||||
PASSWORD_MIN_LENGTH = 12
|
PASSWORD_MIN_LENGTH = 8
|
||||||
PASSWORD_MAX_LENGTH = 128
|
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):
|
class LoginRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
@@ -22,6 +50,25 @@ class PasswordChangeRequest(BaseModel):
|
|||||||
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
|
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)
|
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):
|
class PrincipalResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -37,6 +84,10 @@ class PrincipalResponse(BaseModel):
|
|||||||
return cls.model_validate(principal)
|
return cls.model_validate(principal)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetTokenValidationResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: Literal["bearer"] = "bearer" # noqa: S105
|
token_type: Literal["bearer"] = "bearer" # noqa: S105
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
recommendation_id: int
|
||||||
|
alert_id: int
|
||||||
|
action: str
|
||||||
|
explanation: str
|
||||||
|
rule_reference: str
|
||||||
|
created_at: datetime
|
||||||
@@ -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]
|
||||||
@@ -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"]
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from app.models.energy import Alert
|
||||||
|
from app.repositories.alert import AlertRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AlertService:
|
||||||
|
def __init__(self, *, alerts: AlertRepository) -> None:
|
||||||
|
self._alerts = alerts
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
self, *, site_id: str | None = None, severity: str | None = None
|
||||||
|
) -> Sequence[Alert]:
|
||||||
|
return await self._alerts.list_all(site_id=site_id, severity=severity)
|
||||||
@@ -14,7 +14,11 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from typing import NoReturn, Protocol
|
from typing import NoReturn, Protocol
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
from app.core.hashing import Argon2Hasher
|
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.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role
|
from app.core.roles import AccountKind, Role
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
@@ -28,9 +32,13 @@ from app.models.login_attempt import LoginOutcome
|
|||||||
from app.models.refresh_token import RevocationReason
|
from app.models.refresh_token import RevocationReason
|
||||||
from app.repositories.audit_log import AuditLogRepository
|
from app.repositories.audit_log import AuditLogRepository
|
||||||
from app.repositories.login_attempt import LoginAttemptRepository
|
from app.repositories.login_attempt import LoginAttemptRepository
|
||||||
|
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||||
|
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||||
from app.repositories.refresh_token import RefreshTokenRepository
|
from app.repositories.refresh_token import RefreshTokenRepository
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Transaction(Protocol):
|
class Transaction(Protocol):
|
||||||
async def commit(self) -> None: ...
|
async def commit(self) -> None: ...
|
||||||
@@ -54,6 +62,10 @@ class RateLimitedError(AuthError):
|
|||||||
self.retry_after = retry_after
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidOrExpiredResetTokenError(AuthError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LoginPolicy:
|
class LoginPolicy:
|
||||||
window_seconds: int
|
window_seconds: int
|
||||||
@@ -62,6 +74,15 @@ class LoginPolicy:
|
|||||||
max_failures_per_identifier: 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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AuthenticatedSession:
|
class AuthenticatedSession:
|
||||||
principal: Principal
|
principal: Principal
|
||||||
@@ -83,6 +104,10 @@ class AuthService:
|
|||||||
token_policy: TokenPolicy,
|
token_policy: TokenPolicy,
|
||||||
login_policy: LoginPolicy,
|
login_policy: LoginPolicy,
|
||||||
refresh_ttl: timedelta,
|
refresh_ttl: timedelta,
|
||||||
|
reset_tokens: PasswordResetTokenRepository,
|
||||||
|
reset_attempts: PasswordResetAttemptRepository,
|
||||||
|
reset_policy: PasswordResetPolicy,
|
||||||
|
mailer: Mailer,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._users = users
|
self._users = users
|
||||||
self._attempts = attempts
|
self._attempts = attempts
|
||||||
@@ -93,6 +118,10 @@ class AuthService:
|
|||||||
self._token_policy = token_policy
|
self._token_policy = token_policy
|
||||||
self._login_policy = login_policy
|
self._login_policy = login_policy
|
||||||
self._refresh_ttl = refresh_ttl
|
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(
|
async def authenticate(
|
||||||
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
|
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
|
||||||
@@ -200,6 +229,102 @@ class AuthService:
|
|||||||
rafraichi = await self._users.get_by_id(principal.id)
|
rafraichi = await self._users.get_by_id(principal.id)
|
||||||
return self._session(self._en_principal(rafraichi or compte), secret)
|
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:
|
async def logout_all(self, principal: Principal) -> int:
|
||||||
revoquees = await self._refresh.revoke_all_for_user(
|
revoquees = await self._refresh.revoke_all_for_user(
|
||||||
principal.id, RevocationReason.DECONNEXION
|
principal.id, RevocationReason.DECONNEXION
|
||||||
@@ -307,6 +432,23 @@ class AuthService:
|
|||||||
await self._transaction.commit()
|
await self._transaction.commit()
|
||||||
raise RateLimitedError(politique.window_seconds)
|
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(
|
async def _echoue(
|
||||||
self,
|
self,
|
||||||
email: str,
|
email: str,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from app.models.energy import Recommendation
|
||||||
|
from app.repositories.recommendation import RecommendationRepository
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationNotFoundError(RecommendationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationService:
|
||||||
|
def __init__(self, *, recommendations: RecommendationRepository) -> None:
|
||||||
|
self._recommendations = recommendations
|
||||||
|
|
||||||
|
async def list_all(self) -> Sequence[Recommendation]:
|
||||||
|
return await self._recommendations.list_all()
|
||||||
|
|
||||||
|
async def get_by_id(self, recommendation_id: int) -> Recommendation:
|
||||||
|
recommendation = await self._recommendations.get_by_id(recommendation_id)
|
||||||
|
if recommendation is None:
|
||||||
|
raise RecommendationNotFoundError(recommendation_id)
|
||||||
|
return recommendation
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ dependencies = [
|
|||||||
"pyjwt>=2.10",
|
"pyjwt>=2.10",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
"anyio>=4.0",
|
"anyio>=4.0",
|
||||||
|
"aiosmtplib>=5.1.3",
|
||||||
|
"pandas>=3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -26,6 +28,7 @@ dev = [
|
|||||||
"pytest-asyncio>=1.4.0",
|
"pytest-asyncio>=1.4.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"pandas-stubs>=3.0.5.260914",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -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() == []
|
||||||
@@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role
|
|||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
|
InvalidOrExpiredResetTokenError,
|
||||||
RateLimitedError,
|
RateLimitedError,
|
||||||
SessionRejectedError,
|
SessionRejectedError,
|
||||||
)
|
)
|
||||||
@@ -27,15 +28,27 @@ PRINCIPAL = Principal(
|
|||||||
|
|
||||||
|
|
||||||
class FauxService:
|
class FauxService:
|
||||||
def __init__(self, erreur: Exception | None = None) -> None:
|
def __init__(self, erreur: Exception | None = None, *, jeton_valide: bool = True) -> None:
|
||||||
self._erreur = erreur
|
self._erreur = erreur
|
||||||
|
self._jeton_valide = jeton_valide
|
||||||
|
|
||||||
async def refresh(self, **_: object) -> AuthenticatedSession:
|
async def refresh(self, **_: object) -> AuthenticatedSession:
|
||||||
return await self.authenticate()
|
return await self.authenticate()
|
||||||
|
|
||||||
|
async def is_reset_token_valid(self, **_: object) -> bool:
|
||||||
|
return self._jeton_valide
|
||||||
|
|
||||||
async def logout(self, **_: object) -> None:
|
async def logout(self, **_: object) -> None:
|
||||||
return 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:
|
async def authenticate(self, **_: object) -> AuthenticatedSession:
|
||||||
if self._erreur is not None:
|
if self._erreur is not None:
|
||||||
raise self._erreur
|
raise self._erreur
|
||||||
@@ -206,3 +219,126 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
|
|||||||
response = await client.post("/api/v1/auth/logout")
|
response = await client.post("/api/v1/auth/logout")
|
||||||
|
|
||||||
assert response.status_code != 403
|
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
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Pourquoi : `openapi.json` est versionné, donc une route qui change son contrat public le montre
|
||||||
|
# dans la diff d'une pull request. `test_the_committed_contract_matches_the_generated_one` est ce
|
||||||
|
# qui empêche le fichier de dériver du code sans que personne ne le voie.
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import cli
|
||||||
|
|
||||||
|
METHODES = {"get", "post", "patch", "put", "delete"}
|
||||||
|
|
||||||
|
# `/auth/logout` lit le cookie mais ne le réclame pas : sans session elle répond 204, et un 401
|
||||||
|
# documenté y serait faux.
|
||||||
|
SANS_REFUS = {("POST", "/api/v1/auth/logout")}
|
||||||
|
|
||||||
|
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`.
|
||||||
|
ROUTES_A_ROLE = {
|
||||||
|
("GET", "/api/v1/users"),
|
||||||
|
("POST", "/api/v1/users"),
|
||||||
|
("PATCH", "/api/v1/users/{id}"),
|
||||||
|
("POST", "/api/v1/users/{id}/password-reset"),
|
||||||
|
("GET", "/api/v1/sites"),
|
||||||
|
("GET", "/api/v1/sites/{site_id}"),
|
||||||
|
("GET", "/api/v1/sites/{site_id}/current"),
|
||||||
|
("GET", "/api/v1/alerts"),
|
||||||
|
("GET", "/api/v1/recommendations"),
|
||||||
|
("GET", "/api/v1/recommendations/{recommendation_id}"),
|
||||||
|
("GET", "/api/v1/stats/summary"),
|
||||||
|
("GET", "/api/v1/readings"),
|
||||||
|
("GET", "/api/v1/sensors/status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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}"
|
||||||
@@ -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() == []
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.api.deps import get_current_principal, get_recommendation_service
|
||||||
|
from app.core.principal import Principal
|
||||||
|
from app.core.roles import AccountKind, Role
|
||||||
|
from app.models.energy import Recommendation
|
||||||
|
from app.services.recommendation import RecommendationNotFoundError
|
||||||
|
|
||||||
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(role: Role = Role.LECTEUR) -> Principal:
|
||||||
|
return Principal(
|
||||||
|
id=uuid4(),
|
||||||
|
email=f"{role.value}@enervision.fr",
|
||||||
|
role=role,
|
||||||
|
kind=AccountKind.HUMAIN,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
||||||
|
return Recommendation(
|
||||||
|
recommendation_id=recommendation_id,
|
||||||
|
alert_id=1,
|
||||||
|
action="Vérifier la consommation",
|
||||||
|
explanation="Pic détecté",
|
||||||
|
rule_reference="spike-v1",
|
||||||
|
created_at=MOMENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FauxService:
|
||||||
|
def __init__(self, erreur: Exception | None = None) -> None:
|
||||||
|
self._erreur = erreur
|
||||||
|
self.recommendation = recommendation()
|
||||||
|
|
||||||
|
async def list_all(self) -> list[Recommendation]:
|
||||||
|
return [self.recommendation]
|
||||||
|
|
||||||
|
async def get_by_id(self, recommendation_id: int) -> Recommendation:
|
||||||
|
if self._erreur is not None:
|
||||||
|
raise self._erreur
|
||||||
|
return self.recommendation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||||
|
app.dependency_overrides[get_current_principal] = lambda: principal()
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.pop(get_current_principal, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def servi(
|
||||||
|
app: FastAPI, lecteur_connecte: None
|
||||||
|
) -> Iterator[Callable[[Exception | None], FauxService]]:
|
||||||
|
def installe(erreur: Exception | None = None) -> FauxService:
|
||||||
|
service = FauxService(erreur)
|
||||||
|
app.dependency_overrides[get_recommendation_service] = lambda: service
|
||||||
|
return service
|
||||||
|
|
||||||
|
yield installe
|
||||||
|
app.dependency_overrides.pop(get_recommendation_service, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_recommendations_returns_the_recommendations(
|
||||||
|
servi: Callable[..., FauxService], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
servi()
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/recommendations")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
corps = response.json()
|
||||||
|
assert corps == [
|
||||||
|
{
|
||||||
|
"recommendation_id": 1,
|
||||||
|
"alert_id": 1,
|
||||||
|
"action": "Vérifier la consommation",
|
||||||
|
"explanation": "Pic détecté",
|
||||||
|
"rule_reference": "spike-v1",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recommendation_returns_the_matching_recommendation(
|
||||||
|
servi: Callable[..., FauxService], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
servi()
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/recommendations/1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["recommendation_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recommendation_returns_404_for_an_unknown_recommendation(
|
||||||
|
servi: Callable[..., FauxService], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
servi(RecommendationNotFoundError(404))
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/recommendations/404")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_recommendations_reaches_the_repository_through_the_session(
|
||||||
|
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
fake_session(result=[recommendation(1), recommendation(2)])
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/recommendations")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert [r["recommendation_id"] for r in response.json()] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recommendation_reaches_the_repository_through_the_session(
|
||||||
|
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
fake_session(result=recommendation(1))
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/recommendations/1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["recommendation_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
|
||||||
|
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
fake_session(result=None)
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/recommendations/404")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -18,6 +18,13 @@ ROUTES_PUBLIQUES = frozenset(
|
|||||||
("POST", "/api/v1/auth/login"),
|
("POST", "/api/v1/auth/login"),
|
||||||
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
|
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
|
||||||
("POST", "/api/v1/auth/logout"),
|
("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"),
|
("GET", "/metrics"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -74,3 +81,18 @@ async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
|
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
|
||||||
assert ("GET", chemin) in ROUTES_PUBLIQUES
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.etl.historical_import import (
|
||||||
|
SOURCE_NAME,
|
||||||
|
build_reading_batch,
|
||||||
|
classify_quality,
|
||||||
|
compute_sha256,
|
||||||
|
load_metadata,
|
||||||
|
normalize_timestamps,
|
||||||
|
validate_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_metadata() -> dict:
|
||||||
|
return {
|
||||||
|
"total_records": 2,
|
||||||
|
"sites": {
|
||||||
|
"SITE001": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_dataframe() -> pd.DataFrame:
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2023-01-01 00:00:00",
|
||||||
|
"site_id": "SITE001",
|
||||||
|
"site_type": "office",
|
||||||
|
"site_name": "Site 1",
|
||||||
|
"consumption_kwh": 10.5,
|
||||||
|
"consumption_euros": 2.5,
|
||||||
|
"temperature_celsius": 20.0,
|
||||||
|
"humidity_percent": 50.0,
|
||||||
|
"solar_irradiance_wm2": 0.0,
|
||||||
|
"hour": 0,
|
||||||
|
"day_of_week": 6,
|
||||||
|
"day_name": "Sunday",
|
||||||
|
"month": 1,
|
||||||
|
"is_weekend": True,
|
||||||
|
"is_working_hours": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2023-01-01 01:00:00",
|
||||||
|
"site_id": "SITE001",
|
||||||
|
"site_type": "office",
|
||||||
|
"site_name": "Site 1",
|
||||||
|
"consumption_kwh": 11.0,
|
||||||
|
"consumption_euros": 2.7,
|
||||||
|
"temperature_celsius": 19.5,
|
||||||
|
"humidity_percent": 52.0,
|
||||||
|
"solar_irradiance_wm2": 0.0,
|
||||||
|
"hour": 1,
|
||||||
|
"day_of_week": 6,
|
||||||
|
"day_name": "Sunday",
|
||||||
|
"month": 1,
|
||||||
|
"is_weekend": True,
|
||||||
|
"is_working_hours": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_sha256(tmp_path):
|
||||||
|
file_path = tmp_path / "dataset.csv"
|
||||||
|
content = b"hello-enervision"
|
||||||
|
|
||||||
|
file_path.write_bytes(content)
|
||||||
|
|
||||||
|
expected = hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
assert compute_sha256(file_path) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_metadata(tmp_path):
|
||||||
|
metadata_path = tmp_path / "metadata.json"
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"total_records": 2,
|
||||||
|
"sites": {
|
||||||
|
"SITE001": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_path.write_text(
|
||||||
|
json.dumps(metadata),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert load_metadata(metadata_path) == metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_source_accepts_valid_dataset():
|
||||||
|
frame = make_dataframe()
|
||||||
|
|
||||||
|
validate_source(
|
||||||
|
frame,
|
||||||
|
make_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_source_rejects_missing_column():
|
||||||
|
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Colonnes obligatoires absentes",
|
||||||
|
):
|
||||||
|
validate_source(
|
||||||
|
frame,
|
||||||
|
make_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_source_rejects_duplicates():
|
||||||
|
frame = make_dataframe()
|
||||||
|
|
||||||
|
frame.loc[1, "timestamp"] = frame.loc[
|
||||||
|
0,
|
||||||
|
"timestamp",
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="doublons",
|
||||||
|
):
|
||||||
|
validate_source(
|
||||||
|
frame,
|
||||||
|
make_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_source_rejects_unknown_site():
|
||||||
|
frame = make_dataframe()
|
||||||
|
|
||||||
|
frame.loc[1, "site_id"] = "SITE999"
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Sites incohérents",
|
||||||
|
):
|
||||||
|
validate_source(
|
||||||
|
frame,
|
||||||
|
make_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_timestamps_adds_timezone():
|
||||||
|
frame = make_dataframe()
|
||||||
|
|
||||||
|
normalized = normalize_timestamps(
|
||||||
|
frame,
|
||||||
|
"UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert normalized["timestamp"].dt.tz is not None
|
||||||
|
|
||||||
|
assert "_source_timestamp" in normalized.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_quality_good():
|
||||||
|
row = make_dataframe().iloc[0].to_dict()
|
||||||
|
|
||||||
|
quality, reasons = classify_quality(row)
|
||||||
|
|
||||||
|
assert quality == "good"
|
||||||
|
assert reasons == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_quality_degraded_when_consumption_missing():
|
||||||
|
row = make_dataframe().iloc[0].to_dict()
|
||||||
|
row["consumption_kwh"] = None
|
||||||
|
|
||||||
|
quality, reasons = classify_quality(row)
|
||||||
|
|
||||||
|
assert quality == "degraded"
|
||||||
|
|
||||||
|
assert "missing:consumption_kwh" in reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_reading_batch_respects_database_contract():
|
||||||
|
frame = normalize_timestamps(
|
||||||
|
make_dataframe(),
|
||||||
|
"UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = build_reading_batch(
|
||||||
|
frame.iloc[:1],
|
||||||
|
dataset_id=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
row = rows[0]
|
||||||
|
|
||||||
|
assert row["dataset_id"] == 3
|
||||||
|
|
||||||
|
# Important :
|
||||||
|
# contrainte ck_reading_dataset_source.
|
||||||
|
assert row["source"] == "csv"
|
||||||
|
assert SOURCE_NAME == "csv"
|
||||||
|
|
||||||
|
# Important :
|
||||||
|
# contrainte ck_reading_imputation.
|
||||||
|
assert row["imputed_values"] is None
|
||||||
|
assert row["imputation_method"] is None
|
||||||
|
|
||||||
|
assert row["data_quality"] == "good"
|
||||||
|
assert row["null_reasons"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_reading_batch_keeps_missing_values():
|
||||||
|
frame = make_dataframe()
|
||||||
|
|
||||||
|
frame.loc[0, "temperature_celsius"] = None
|
||||||
|
|
||||||
|
frame = normalize_timestamps(
|
||||||
|
frame,
|
||||||
|
"UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = build_reading_batch(
|
||||||
|
frame.iloc[:1],
|
||||||
|
dataset_id=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = rows[0]
|
||||||
|
|
||||||
|
assert row["temperature_celsius"] is None
|
||||||
|
|
||||||
|
assert "missing:temperature_celsius" in row["null_reasons"]
|
||||||
|
|
||||||
|
# RAW ingestion : aucune imputation.
|
||||||
|
assert row["imputed_values"] is None
|
||||||
|
assert row["imputation_method"] is None
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
@@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScalars:
|
||||||
|
"""Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies."""
|
||||||
|
|
||||||
|
def __init__(self, rows: Sequence[object]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def all(self) -> Sequence[object]:
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
|
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
|
||||||
|
|
||||||
@@ -25,6 +36,9 @@ class FakeSession:
|
|||||||
async def execute(self, *_: object, **__: object) -> object:
|
async def execute(self, *_: object, **__: object) -> object:
|
||||||
return self._repondre()
|
return self._repondre()
|
||||||
|
|
||||||
|
async def scalars(self, *_: object, **__: object) -> FakeScalars:
|
||||||
|
return FakeScalars(self._repondre() or [])
|
||||||
|
|
||||||
def _repondre(self) -> object:
|
def _repondre(self) -> object:
|
||||||
if self._failure is not None:
|
if self._failure is not None:
|
||||||
raise self._failure
|
raise self._failure
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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) == []
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await depot.create(
|
||||||
|
user_id=compte,
|
||||||
|
token_hash=fingerprint_refresh(secret),
|
||||||
|
expires_at=datetime.now(UTC) + DUREE,
|
||||||
|
client_ip=None,
|
||||||
|
user_agent=None,
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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_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) == []
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.energy import Alert, Recommendation, Site
|
||||||
|
from app.repositories.recommendation import RecommendationRepository
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def creer_site(session: AsyncSession) -> str:
|
||||||
|
site_id = f"TEST-{uuid.uuid4()}"
|
||||||
|
session.add(Site(site_id=site_id, site_name="Site de test", site_type="office"))
|
||||||
|
await session.flush()
|
||||||
|
return site_id
|
||||||
|
|
||||||
|
|
||||||
|
async def creer_alerte(session: AsyncSession) -> int:
|
||||||
|
site_id = await creer_site(session)
|
||||||
|
alerte = Alert(
|
||||||
|
source_alert_id=str(uuid.uuid4()),
|
||||||
|
site_id=site_id,
|
||||||
|
source="api_mock",
|
||||||
|
timestamp=MOMENT,
|
||||||
|
type="spike",
|
||||||
|
severity="high",
|
||||||
|
message="Test",
|
||||||
|
raw_data={},
|
||||||
|
)
|
||||||
|
session.add(alerte)
|
||||||
|
await session.flush()
|
||||||
|
return alerte.alert_id
|
||||||
|
|
||||||
|
|
||||||
|
async def creer(session: AsyncSession, **overrides: object) -> Recommendation:
|
||||||
|
recommendation = Recommendation(
|
||||||
|
alert_id=overrides.get("alert_id") or await creer_alerte(session),
|
||||||
|
action=overrides.get("action", "Vérifier la consommation"),
|
||||||
|
explanation=overrides.get("explanation", "Pic détecté"),
|
||||||
|
rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"),
|
||||||
|
)
|
||||||
|
session.add(recommendation)
|
||||||
|
await session.flush()
|
||||||
|
return recommendation
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None:
|
||||||
|
depot = RecommendationRepository(session)
|
||||||
|
cree = await creer(session)
|
||||||
|
|
||||||
|
trouve = await depot.get_by_id(cree.recommendation_id)
|
||||||
|
action = trouve.action if trouve else None
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert action == "Vérifier la consommation"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
trouve = await RecommendationRepository(session).get_by_id(0)
|
||||||
|
|
||||||
|
assert trouve is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_returns_the_recommendations_sorted_by_identifier(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
depot = RecommendationRepository(session)
|
||||||
|
premiere = await creer(session)
|
||||||
|
seconde = await creer(session)
|
||||||
|
|
||||||
|
recommendations = await depot.list_all()
|
||||||
|
identifiants = [
|
||||||
|
r.recommendation_id
|
||||||
|
for r in recommendations
|
||||||
|
if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id)
|
||||||
|
]
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert identifiants == sorted(identifiants)
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.models.energy import Alert
|
||||||
|
from app.services.alert import AlertService
|
||||||
|
|
||||||
|
|
||||||
|
def alert(
|
||||||
|
alert_id: int = 1,
|
||||||
|
site_id: str = "site-1",
|
||||||
|
severity: str = "high",
|
||||||
|
) -> Alert:
|
||||||
|
return Alert(
|
||||||
|
alert_id=alert_id,
|
||||||
|
source_alert_id=f"ALR-{alert_id}",
|
||||||
|
site_id=site_id,
|
||||||
|
source="enervision",
|
||||||
|
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
|
||||||
|
type="threshold",
|
||||||
|
severity=severity,
|
||||||
|
message="Dépassement du seuil configuré",
|
||||||
|
value=812.5,
|
||||||
|
threshold=720.0,
|
||||||
|
metric="consumption_kw",
|
||||||
|
prediction_id=None,
|
||||||
|
raw_data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self, alerts: list[Alert]) -> None:
|
||||||
|
self._alerts = alerts
|
||||||
|
self.appels: list[tuple[str | None, str | None]] = []
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
self, *, site_id: str | None = None, severity: str | None = None
|
||||||
|
) -> list[Alert]:
|
||||||
|
self.appels.append((site_id, severity))
|
||||||
|
return self._alerts
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_returns_the_repository_alerts() -> None:
|
||||||
|
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
|
||||||
|
|
||||||
|
alertes = await service.list_all()
|
||||||
|
|
||||||
|
assert [a.alert_id for a in alertes] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_relays_the_filters_to_the_repository() -> None:
|
||||||
|
depot = FakeRepository([])
|
||||||
|
service = AlertService(alerts=depot)
|
||||||
|
|
||||||
|
await service.list_all(site_id="site-1", severity="critical")
|
||||||
|
|
||||||
|
assert depot.appels == [("site-1", "critical")]
|
||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
from app.core.principal import Principal
|
from app.core.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role
|
from app.core.roles import AccountKind, Role
|
||||||
@@ -16,11 +17,15 @@ from app.core.security import (
|
|||||||
from app.models.login_attempt import LoginOutcome
|
from app.models.login_attempt import LoginOutcome
|
||||||
from app.models.refresh_token import RevocationReason
|
from app.models.refresh_token import RevocationReason
|
||||||
from app.repositories.login_attempt import FailureCounts
|
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.repositories.refresh_token import ClaimedToken
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
AuthService,
|
AuthService,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
|
InvalidOrExpiredResetTokenError,
|
||||||
LoginPolicy,
|
LoginPolicy,
|
||||||
|
PasswordResetPolicy,
|
||||||
RateLimitedError,
|
RateLimitedError,
|
||||||
SessionRejectedError,
|
SessionRejectedError,
|
||||||
)
|
)
|
||||||
@@ -37,6 +42,13 @@ POLITIQUE_CONNEXION = LoginPolicy(
|
|||||||
max_failures_per_ip=20,
|
max_failures_per_ip=20,
|
||||||
max_failures_per_identifier=50,
|
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
|
@dataclass
|
||||||
@@ -168,6 +180,49 @@ class FausseTransaction:
|
|||||||
self.validations += 1
|
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
|
@dataclass
|
||||||
class Attirail:
|
class Attirail:
|
||||||
service: AuthService
|
service: AuthService
|
||||||
@@ -176,6 +231,9 @@ class Attirail:
|
|||||||
jetons: FauxDepotJetons
|
jetons: FauxDepotJetons
|
||||||
audit: FauxDepotAudit
|
audit: FauxDepotAudit
|
||||||
hacheur: FauxHacheur
|
hacheur: FauxHacheur
|
||||||
|
jetons_reset: FauxDepotJetonsReset
|
||||||
|
tentatives_reset: FauxDepotTentativesReset
|
||||||
|
mailer: FauxMailer
|
||||||
|
|
||||||
|
|
||||||
def fabrique_service(
|
def fabrique_service(
|
||||||
@@ -184,12 +242,17 @@ def fabrique_service(
|
|||||||
compteurs: FailureCounts | None = None,
|
compteurs: FailureCounts | None = None,
|
||||||
hacheur: FauxHacheur | None = None,
|
hacheur: FauxHacheur | None = None,
|
||||||
jetons: FauxDepotJetons | None = None,
|
jetons: FauxDepotJetons | None = None,
|
||||||
|
jetons_reset: FauxDepotJetonsReset | None = None,
|
||||||
|
compteurs_reset: ResetRequestCounts | None = None,
|
||||||
) -> Attirail:
|
) -> Attirail:
|
||||||
comptes = FauxDepotComptes(compte)
|
comptes = FauxDepotComptes(compte)
|
||||||
tentatives = FauxDepotTentatives(compteurs)
|
tentatives = FauxDepotTentatives(compteurs)
|
||||||
depot_jetons = jetons or FauxDepotJetons()
|
depot_jetons = jetons or FauxDepotJetons()
|
||||||
audit = FauxDepotAudit()
|
audit = FauxDepotAudit()
|
||||||
hacheur = hacheur or FauxHacheur()
|
hacheur = hacheur or FauxHacheur()
|
||||||
|
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
|
||||||
|
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
|
||||||
|
mailer = FauxMailer()
|
||||||
service = AuthService(
|
service = AuthService(
|
||||||
users=comptes, # type: ignore[arg-type]
|
users=comptes, # type: ignore[arg-type]
|
||||||
attempts=tentatives, # type: ignore[arg-type]
|
attempts=tentatives, # type: ignore[arg-type]
|
||||||
@@ -200,8 +263,22 @@ def fabrique_service(
|
|||||||
token_policy=POLITIQUE_JETON,
|
token_policy=POLITIQUE_JETON,
|
||||||
login_policy=POLITIQUE_CONNEXION,
|
login_policy=POLITIQUE_CONNEXION,
|
||||||
refresh_ttl=timedelta(days=7),
|
refresh_ttl=timedelta(days=7),
|
||||||
|
reset_tokens=depot_jetons_reset, # type: ignore[arg-type]
|
||||||
|
reset_attempts=tentatives_reset, # type: ignore[arg-type]
|
||||||
|
reset_policy=POLITIQUE_RESET,
|
||||||
|
mailer=mailer, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
return Attirail(
|
||||||
|
service,
|
||||||
|
comptes,
|
||||||
|
tentatives,
|
||||||
|
depot_jetons,
|
||||||
|
audit,
|
||||||
|
hacheur,
|
||||||
|
depot_jetons_reset,
|
||||||
|
tentatives_reset,
|
||||||
|
mailer,
|
||||||
)
|
)
|
||||||
return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur)
|
|
||||||
|
|
||||||
|
|
||||||
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
|
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
|
||||||
@@ -493,3 +570,148 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
|
|||||||
|
|
||||||
assert attirail.jetons.revocations_par_compte == []
|
assert attirail.jetons.revocations_par_compte == []
|
||||||
assert attirail.jetons.crees == []
|
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 == []
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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([]))
|
||||||
|
|
||||||
|
with pytest.raises(FenetreInverseeError):
|
||||||
|
await service.list_history(
|
||||||
|
start=datetime(2026, 9, 2, tzinfo=UTC),
|
||||||
|
end=datetime(2026, 9, 1, tzinfo=UTC),
|
||||||
|
limit=500,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.energy import Recommendation
|
||||||
|
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
|
||||||
|
|
||||||
|
|
||||||
|
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
||||||
|
return Recommendation(
|
||||||
|
recommendation_id=recommendation_id,
|
||||||
|
alert_id=1,
|
||||||
|
action="Vérifier la consommation",
|
||||||
|
explanation="Pic détecté",
|
||||||
|
rule_reference="spike-v1",
|
||||||
|
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self, recommendations: list[Recommendation]) -> None:
|
||||||
|
self._recommendations = recommendations
|
||||||
|
|
||||||
|
async def list_all(self) -> list[Recommendation]:
|
||||||
|
return self._recommendations
|
||||||
|
|
||||||
|
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
|
||||||
|
return next(
|
||||||
|
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_returns_the_repository_recommendations() -> None:
|
||||||
|
service = RecommendationService(
|
||||||
|
recommendations=FakeRepository([recommendation(1), recommendation(2)])
|
||||||
|
)
|
||||||
|
|
||||||
|
recommendations = await service.list_all()
|
||||||
|
|
||||||
|
assert [r.recommendation_id for r in recommendations] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
||||||
|
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
|
||||||
|
|
||||||
|
trouve = await service.get_by_id(1)
|
||||||
|
|
||||||
|
assert trouve.recommendation_id == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
||||||
|
service = RecommendationService(recommendations=FakeRepository([]))
|
||||||
|
|
||||||
|
with pytest.raises(RecommendationNotFoundError):
|
||||||
|
await service.get_by_id(404)
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app import cli
|
from app import cli
|
||||||
|
from app.schemas.auth import valide_complexite
|
||||||
|
|
||||||
|
|
||||||
def test_build_parser_reads_the_create_admin_arguments() -> None:
|
def test_build_parser_reads_the_create_admin_arguments() -> None:
|
||||||
@@ -31,27 +35,86 @@ def test_read_password_generates_a_long_secret_when_asked(
|
|||||||
|
|
||||||
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
|
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
|
||||||
assert mot_de_passe in capsys.readouterr().out
|
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:
|
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
|
saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"])
|
||||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||||
|
|
||||||
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
|
assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1"
|
||||||
|
|
||||||
|
|
||||||
def test_read_password_refuses_a_password_below_the_minimum_length(
|
def test_read_password_refuses_a_password_below_the_minimum_length(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(cli, "getpass", lambda _: "court")
|
monkeypatch.setattr(cli, "getpass", lambda _: "Court1!")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli.read_password(generate=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_password_refuses_a_password_missing_a_character_class(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre")
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.read_password(generate=False)
|
cli.read_password(generate=False)
|
||||||
|
|
||||||
|
|
||||||
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
|
saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"])
|
||||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.read_password(generate=False)
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
Generated
+120
@@ -1,6 +1,20 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = "==3.14.*"
|
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]]
|
[[package]]
|
||||||
name = "alembic"
|
name = "alembic"
|
||||||
@@ -306,11 +320,13 @@ name = "enervision-backend"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "aiosmtplib" },
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "pandas" },
|
||||||
{ name = "prometheus-fastapi-instrumentator" },
|
{ name = "prometheus-fastapi-instrumentator" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -324,6 +340,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pandas-stubs" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
@@ -332,11 +349,13 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "aiosmtplib", specifier = ">=5.1.3" },
|
||||||
{ name = "alembic", specifier = ">=1.20.0" },
|
{ name = "alembic", specifier = ">=1.20.0" },
|
||||||
{ name = "anyio", specifier = ">=4.0" },
|
{ name = "anyio", specifier = ">=4.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||||
|
{ name = "pandas", specifier = ">=3.0.5" },
|
||||||
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
|
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
|
||||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
||||||
@@ -350,6 +369,7 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "mypy", specifier = ">=2.3.1" },
|
{ name = "mypy", specifier = ">=2.3.1" },
|
||||||
|
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
@@ -595,6 +615,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.3"
|
version = "26.3"
|
||||||
@@ -604,6 +653,47 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -788,6 +878,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -857,6 +959,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.52"
|
version = "2.0.52"
|
||||||
@@ -916,6 +1027,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.53.0"
|
version = "0.53.0"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ yarn-error.log
|
|||||||
.sass-cache/
|
.sass-cache/
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
|||||||
@@ -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;"]
|
||||||
@@ -76,6 +76,13 @@ Points à vérifier après toute regénération :
|
|||||||
côté backend. Le `docker-compose.yml` n'a aucun service frontend.
|
côté backend. Le `docker-compose.yml` n'a aucun service frontend.
|
||||||
4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx).
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -80,19 +81,11 @@
|
|||||||
"builder": "@angular/build:unit-test",
|
"builder": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"coverage": true,
|
"coverage": true,
|
||||||
|
"isolate": true,
|
||||||
"coverageReporters": [
|
"coverageReporters": [
|
||||||
"text-summary",
|
"text-summary",
|
||||||
"lcov",
|
"lcov",
|
||||||
"html"
|
"html"
|
||||||
],
|
|
||||||
"reporters": [
|
|
||||||
"default",
|
|
||||||
[
|
|
||||||
"junit",
|
|
||||||
{
|
|
||||||
"outputFile": "test-results/junit.xml"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+19
@@ -14,6 +14,7 @@
|
|||||||
"@angular/forms": "^22.1.0",
|
"@angular/forms": "^22.1.0",
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.0",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -2038,6 +2039,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||||
"version": "4.2.5",
|
"version": "4.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
|
||||||
@@ -4220,6 +4227,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@angular/forms": "^22.1.0",
|
"@angular/forms": "^22.1.0",
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.0",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
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 = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
|
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))));
|
||||||
|
}),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,353 +1 @@
|
|||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<router-outlet></router-outlet>
|
||||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
|
||||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
|
||||||
--french-violet: oklch(47.66% 0.246 305.88);
|
|
||||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
|
||||||
--hot-red: oklch(61.42% 0.238 15.34);
|
|
||||||
--orange-red: oklch(63.32% 0.24 31.68);
|
|
||||||
|
|
||||||
--gray-900: oklch(19.37% 0.006 300.98);
|
|
||||||
--gray-700: oklch(36.98% 0.014 302.71);
|
|
||||||
--gray-400: oklch(70.9% 0.015 304.04);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
|
|
||||||
font-family:
|
|
||||||
'Inter',
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
Helvetica,
|
|
||||||
Arial,
|
|
||||||
sans-serif,
|
|
||||||
'Apple Color Emoji',
|
|
||||||
'Segoe UI Emoji',
|
|
||||||
'Segoe UI Symbol';
|
|
||||||
box-sizing: border-box;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
display: block;
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.125rem;
|
|
||||||
color: var(--gray-900);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 100%;
|
|
||||||
letter-spacing: -0.125rem;
|
|
||||||
margin: 0;
|
|
||||||
font-family:
|
|
||||||
'Inter Tight',
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
Helvetica,
|
|
||||||
Arial,
|
|
||||||
sans-serif,
|
|
||||||
'Apple Color Emoji',
|
|
||||||
'Segoe UI Emoji',
|
|
||||||
'Segoe UI Symbol';
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
box-sizing: inherit;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.angular-logo {
|
|
||||||
max-width: 9.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700px;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1 {
|
|
||||||
margin-top: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
|
||||||
margin-inline: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
|
||||||
color: var(--pill-accent);
|
|
||||||
padding-inline: 0.75rem;
|
|
||||||
padding-block: 0.375rem;
|
|
||||||
border-radius: 2.75rem;
|
|
||||||
border: 0;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
font-family: var(--inter-font);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4rem;
|
|
||||||
letter-spacing: -0.00875rem;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill:hover {
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 1) {
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 2) {
|
|
||||||
--pill-accent: var(--electric-violet);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 3) {
|
|
||||||
--pill-accent: var(--french-violet);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 4),
|
|
||||||
.pill-group .pill:nth-child(6n + 5),
|
|
||||||
.pill-group .pill:nth-child(6n + 6) {
|
|
||||||
--pill-accent: var(--hot-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group svg {
|
|
||||||
margin-inline-start: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.73rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links path {
|
|
||||||
transition: fill 0.3s ease;
|
|
||||||
fill: var(--gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links a:hover svg path {
|
|
||||||
fill: var(--gray-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.content {
|
|
||||||
flex-direction: column;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
|
||||||
margin-block: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<div class="content">
|
|
||||||
<div class="left-side">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 982 239"
|
|
||||||
fill="none"
|
|
||||||
class="angular-logo"
|
|
||||||
>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path
|
|
||||||
fill="url(#b)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="url(#c)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<radialGradient
|
|
||||||
id="c"
|
|
||||||
cx="0"
|
|
||||||
cy="0"
|
|
||||||
r="1"
|
|
||||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#FF41F8" />
|
|
||||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
|
||||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient id="b" x1="0" x2="982" y1="192" y2="192" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F0060B" />
|
|
||||||
<stop offset="0" stop-color="#F0070C" />
|
|
||||||
<stop offset=".526" stop-color="#CC26D5" />
|
|
||||||
<stop offset="1" stop-color="#7702FF" />
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
<h1>Hello, {{ title() }}</h1>
|
|
||||||
<p>Congratulations! Your app is running. 🎉</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
|
||||||
<div class="right-side">
|
|
||||||
<div class="pill-group">
|
|
||||||
@for (
|
|
||||||
item of [
|
|
||||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
|
||||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
|
||||||
{
|
|
||||||
title: 'Prompt and best practices for AI',
|
|
||||||
link: 'https://angular.dev/ai/develop-with-ai',
|
|
||||||
},
|
|
||||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
|
||||||
{
|
|
||||||
title: 'Angular Language Service',
|
|
||||||
link: 'https://angular.dev/tools/language-service',
|
|
||||||
},
|
|
||||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
|
||||||
];
|
|
||||||
track item.title
|
|
||||||
) {
|
|
||||||
<a class="pill" [href]="item.link" target="_blank" rel="noopener">
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
width="14"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="social-links">
|
|
||||||
<a
|
|
||||||
href="https://github.com/angular/angular"
|
|
||||||
aria-label="Github"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="25"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 25 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Github"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="https://x.com/angular" aria-label="X" target="_blank" rel="noopener">
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="X"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
|
||||||
aria-label="Youtube"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="29"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 29 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Youtube"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
<router-outlet />
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user