Compare commits

...
Author SHA1 Message Date
ineszang44 d3047f53e8 Merge branch 'feat/sonar-dashboard' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/sonar-dashboard 2026-09-18 13:56:06 +02:00
ineszang44 ec63798807 fix: lcov -> xml pour le rapport de couverture 2026-09-18 13:55:46 +02:00
ineszangandGitHub 1e168ef03e Merge branch 'dev' into feat/sonar-dashboard 2026-09-18 13:48:18 +02:00
ineszang44 ddf7e17788 test: properties sonar 2026-09-18 13:39:51 +02:00
ineszang44 56e8f95729 test: jobs de tests et de build de sonarqube 2026-09-18 13:22:31 +02:00
ineszang44 91d435748c test: jobs de tests et de build de sonarqube 2026-09-18 13:17:31 +02:00
ineszang44 0b41580310 test: jobs de tests et de build de sonarqube 2026-09-18 13:14:50 +02:00
ineszang44 e3d436ea53 test: jobs de tests et de build de sonarqube 2026-09-18 13:13:23 +02:00
Johan LEROYandGitHub c453700b13 Merge pull request #101 from ineszang/chore/coverage-tests-frontend-ci
chore(ci): ajoute la couverture de tests au job test du frontend
2026-09-18 12:38:15 +02:00
ineszang44 f427f8a8f3 chore+feat: allègement du workflow front, workflow pour sonarqube 2026-09-18 12:32:18 +02:00
Johan LEROY 7be8a44e89 Merge branch 'dev' into chore/coverage-tests-frontend-ci
Résolution du conflit sur .github/workflows/frontend.yml :
- conserve les étapes upload/download de l'artifact lcov ajoutées sur dev,
  sans lesquelles le rapport ne quitte pas le runner du job test et
  n'atteint jamais le scanner Sonar ;
- conserve le job security-audit et le retrait du bloc deploy commenté ;
- retient la commande de test de la branche.

Le séparateur `--` manquait côté dev : npm consommait les trois flags
comme sa propre configuration et ng test tournait sans argument. De plus
`--code-coverage` n'existe pas dans le builder @angular/build:unit-test,
où l'option s'appelle `coverage`.
2026-09-18 12:30:10 +02:00
Johan LEROYandGitHub 2095ad6bd3 Merge pull request #100 from ineszang/chore/audit-dependances-ci
chore(ci): ajoute un audit de sécurité des dépendances (npm audit, pi…
2026-09-18 12:26:46 +02:00
Johan LEROY fb06bf0062 chore(ci): isole l'audit de sécurité et le fait porter sur le verrou
Backend / Audit des dépendances (push) Successful in 1m30s
Frontend / Audit des dépendances (push) Successful in 7s
Backend / Lint, typage et tests (push) Successful in 4m15s
Frontend / build (push) Successful in 11m3s
Frontend / test (push) Failing after 5m7s
Frontend / SonarQube (push) Skipped
L'audit backend était la dernière étape du job de vérification : un lint
ou un test en échec suffisait à le sauter, et `pip-audit` sans argument
auditait l'environnement courant, donc aussi les 28 paquets injectés par
son propre `--with`. Il audite maintenant l'export du verrou, dans un job
dédié, en symétrie avec le frontend.

Côté frontend, `npm audit` lit le verrou et n'a besoin ni de `npm ci` ni
du job `build`. Le workflow déclare enfin ses permissions, comme
backend.yml et ml.yml.
2026-09-18 12:23:50 +02:00
Johan LEROY e27142c7db Merge branch 'dev' into chore/audit-dependances-ci 2026-09-18 12:23:25 +02:00
ineszang44 77feabcbad Merge branch 'feat/sonar-dashboard' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/sonar-dashboard 2026-09-18 12:22:41 +02:00
ineszang44 18a4be6e38 feat+rollback: job de test sur le backend dans le workflow du front pour sonar, properties de sonar dans la racine du projet 2026-09-18 12:17:43 +02:00
ineszang44 12fb8860d1 chore: fichier de configuration pour chaque module (front et back) 2026-09-18 11:54:21 +02:00
ineszang44 7c2936f2ef chore: fichier de configuration pour chaque module (front et back) 2026-09-18 11:26:45 +02:00
Johan LEROYandGitHub 297d85a0ca Merge pull request #84 from ineszang/feat/endpoint-sites-current
feat(backend): expose GET /api/v1/sites/{site_id}/current
2026-09-18 10:33:53 +02:00
ineszangandGitHub af58172742 Merge pull request #99 from ineszang/feat/sonar-dashboard
test: ajout de propriétés dans le fichier de config pour sonarqube
2026-09-18 10:31:19 +02:00
Johan LEROY b433e01fa8 fix(backend): départage aussi les égalités de timestamp dans latest_by_site
Backend / Lint, typage et tests (push) Successful in 1m18s
`latest_by_site` portait le même défaut que `latest_for_site` : `DISTINCT ON (site_id)`
ordonné sur `site_id, timestamp DESC` sans départage, alors que `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère.
`/stats/summary` pouvait donc afficher une consommation différente d'un appel à
l'autre pour un site alimenté par un backfill CSV et une écriture live.

Test `integration` dédié, qui échoue sans le correctif.
2026-09-18 10:28:04 +02:00
Johan LEROY 5eb74aa64a fix(backend): traite la revue de phyri0s sur la PR #84
Tri non déterministe : `latest_for_site` départage désormais les égalités de
timestamp par `reading_id` décroissant, comme `list_history`. `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère, donc
le `LIMIT 1` pouvait renvoyer l'une ou l'autre d'un appel à l'autre.

Tests : trois tests `integration` sur `latest_for_site` (plus récente, égalité de
timestamp, isolation par site). Le test d'égalité échoue sans le correctif ci-dessus.

Duplication : `DataQuality` et le repli vers `critical` sortent dans
`app/services/data_quality.py`, partagé par `stats.py`, `site.py` et `sensor.py`,
qui en portaient trois copies indépendantes. Supprime au passage deux
`# type: ignore[assignment]`.
2026-09-18 10:28:04 +02:00
ineszang44 cc0a58ac4c test: sonarqube 2026-09-18 10:23:09 +02:00
ineszang44 2400b6f05e test: sonarqube 2026-09-18 10:21:56 +02:00
ineszang44 9e33c276d6 test: sonarqube 2026-09-18 10:16:31 +02:00
ineszang44 6cb9ac00cb test: sonarqube 2026-09-18 10:14:06 +02:00
ineszang44 c1f63889c1 test: sonarqube 2026-09-18 10:10:05 +02:00
ineszang44 5875e8c239 test: sonarqube 2026-09-18 10:01:28 +02:00
ineszang44 c8383014a8 Merge branch 'dev' into feat/sonar-dashboard 2026-09-18 09:52:27 +02:00
PhyriosandGitHub 3cf9194d4c Add progress update for project on 2026-09-18
Document progress update for the project as of September 18, 2026, detailing closed issues, merged PRs, and individual contributions.
2026-09-18 09:24:26 +02:00
PhyriosandGitHub 8def1e23af Add progress report for 15/09/2026 review meeting 2026-09-18 09:23:41 +02:00
Johan LEROYandGitHub 56c134beb0 Merge pull request #95 from ineszang/feat/site-list-view
feat(frontend): vue liste des sites
2026-09-17 16:04:09 +02:00
Johan LEROY 8fb5ab9f65 fix(frontend): traite la revue de phyri0s sur la PR #95
Frontend / build (push) Successful in 9m42s
Frontend / test (push) Failing after 5m23s
Frontend / SonarQube (push) Skipped
- Reutilise .ev-link pour le lien "Detail" de la liste des sites au
  lieu de dupliquer ses regles de style.
- site.location vide est traite comme absent (affiche "-"), pas
  seulement null/undefined.
- siteId de la page detail suit desormais route.paramMap de facon
  reactive plutot qu'une lecture ponctuelle du snapshot, pour rester
  a jour quand Angular reutilise l'instance du composant en changeant
  de site.
- Ajoute provideRouter([]) manquant dans un test dashboard existant,
  necessaire depuis l'ajout du lien "Voir les sites" au rebase sur dev.
2026-09-17 16:01:15 +02:00
Johan LEROY e22feac2c4 fix(frontend): navigation cohérente entre les pages authentifiées
Logo cliquable vers le tableau de bord (ev-brand-link) et fil d'Ariane
(ev-breadcrumb) sur les sous-pages, pour éviter les impasses de
navigation entre dashboard, liste des sites et détail de site.
2026-09-17 15:58:50 +02:00
Johan LEROY 5581cb1ef3 feat(frontend): vue liste des sites
Nouveau SitesService (GET /sites) et page SiteList consommant le design
système (ev-card, ev-badge, ev-alert, ev-brand). Ajoute la route /sites,
un lien depuis le dashboard, et une route détail /sites/:siteId pointant
vers un placeholder minimal en attendant l'issue #51.

Closes #49
2026-09-17 15:58:35 +02:00
Johan LEROYandGitHub 1d8c986386 Merge pull request #93 from ineszang/feat/design-system
feat(frontend): design système - tokens, composants ui et restylage des pages
2026-09-17 15:56:24 +02:00
Johan LEROY 85cb7c9eeb fix(frontend,backend): traite la revue de phyri0s sur la PR #93
Backend / Lint, typage et tests (push) Successful in 1m23s
Frontend / build (push) Successful in 9m38s
Frontend / test (push) Failing after 5m4s
Frontend / SonarQube (push) Skipped
Corrige les 10 points de la revue du systeme de design : garde-fou de
route explicite pour /docs, /redoc et /static, ton distinct pour les
alertes critical vs high, flex-shrink sur le bon element du badge,
mutualisation du bloc ev-card dans _auth-page.scss, bouton de
deconnexion migre vers ev-button (nouvel input fullWidth), tokens
manquants (--color-danger-hover, --color-warning-text,
--color-text-inverse, --color-critical), test de synchronisation des
deux copies du logo, openapi_avec_logo qui enveloppe application.openapi
au lieu de le reimplementer, doc du frontend et index mis a jour, et
suppression du CSS mort .form-error.
2026-09-17 15:53:55 +02:00
Johan LEROY 39b1d28ead Merge remote-tracking branch 'origin/dev' into feat/design-system
# Conflicts:
#	apps/frontend/src/app/features/auth/change-password/change-password.html
#	apps/frontend/src/app/features/auth/change-password/change-password.ts
#	apps/frontend/src/app/features/auth/login/login.html
#	apps/frontend/src/app/features/auth/login/login.ts
2026-09-17 15:44:10 +02:00
Johan LEROYandGitHub 5394257855 Merge pull request #90 from ineszang/feat/password-policy-forgot-password
feat(auth): politique de complexité du mot de passe et flux de réinitialisation
2026-09-17 15:34:26 +02:00
Johan LEROY c741ffc827 fix(auth): corrige la CI cassee par le nouvel endpoint de validation
Backend / Lint, typage et tests (push) Successful in 1m19s
/auth/reset-password/validate manquait a la liste explicite des routes
publiques (test_route_protection) et n'avait pas le modele de reponse
422 declare (openapi.json desynchronise du contrat genere).
2026-09-17 15:31:14 +02:00
Johan LEROY d7f775f9f7 feat(auth): verifie le lien de reset des le chargement, sans le consommer
Ajoute GET /auth/reset-password/validate (lecture seule, sans rate
limit : le jeton est un secret de 256 bits non brute-forcable) pour que
la page reset-password redirige immediatement vers /login si le lien
est invalide ou expire, plutot que d'attendre la soumission du
formulaire. La verification a la soumission (confirm_password_reset)
reste la seule source de verite atomique.
2026-09-17 15:28:50 +02:00
Johan LEROY e381e0de09 feat(frontend): checklist de complexite du mot de passe sur reset-password
Remplace l'indice statique sous le champ nouveau mot de passe par une
checklist qui coche chaque regle (longueur, majuscule, minuscule,
chiffre, caractere special) au fur et a mesure de la saisie. Les regles
individuelles (PASSWORD_REQUIREMENTS) sont exposees depuis le meme
validateur que PASSWORD_PATTERN pour rester la seule source de verite.
2026-09-17 14:57:05 +02:00
Johan LEROY 7674955637 fix(frontend): un lien de reset absent ou expire renvoie vers login avec un message standard
Avant, un token absent affichait un message inline sur /reset-password, et
un token invalide/expire ne se voyait qu'apres soumission du formulaire.
Les deux cas redirigent maintenant vers /login avec le motif
"lien-expire", qui y affiche le message standard "Ce lien de
reinitialisation est invalide ou a expire. Connectez-vous ou
redemandez-en un."
2026-09-17 14:49:46 +02:00
ineszang44 19cfac1cff fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:48:52 +02:00
ineszang44 1fce577a78 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:45:54 +02:00
Johan LEROY 063092f2c7 fix(frontend): le rafraichissement de session au demarrage ne doit pas ecraser un lien de reset
Le refresh de session lance par provideAppInitializer echoue silencieusement
sans cookie valide, mais l'intercepteur forcait quand meme un
router.navigate(['/login']) sur le 401 resultant, ecrasant la navigation
vers /reset-password?token=... venue de l'email. L'intercepteur ne
redirige plus quand on est deja sur une route invitee (login,
forgot-password, reset-password).
2026-09-17 14:40:53 +02:00
ineszang44 1afaee069f fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:39:48 +02:00
ineszang44 7bc9a09489 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:38:08 +02:00
Johan LEROY 921da48eb1 fix(auth): corrige 4 failles de la revue de securite sur la PR #90
Anti-enumeration cassee sur /auth/forgot-password : l'envoi SMTP etait
synchrone dans le chemin de reponse, donc un email existant prenait plus
de temps qu'un email inconnu (et pouvait renvoyer 500 si le relais SMTP
echouait, contre 202 sinon). L'envoi part desormais en BackgroundTasks,
apres que la reponse 202 a ete envoyee au client, avec un try/except qui
logue plutot que de laisser une exception SMTP remonter.

confirm_password_reset() ne revalidait pas is_active/kind du compte avant
de changer le mot de passe : un compte desactive dans les 15 minutes
suivant l'emission du lien pouvait quand meme voir son mot de passe
change et son must_change_password efface.

Les plages [A-ZA-Y]/[a-za-y] de la regle de complexite incluaient par
erreur x et / (U+00D7, U+00F7), donc un mot de passe sans aucune
majuscule ou minuscule pouvait passer la validation.

Le validateur frontend (JS, \w ASCII) et le validateur backend (Python,
\w Unicode) divergeaient sur les caracteres accentues : un mot de passe
comme "Securite1" passait cote front puis se faisait rejeter en 422 cote
back. Les deux cotes utilisent maintenant le meme jeu explicite de
caracteres speciaux (SPECIAL_CHARACTERS, partage aussi avec cli.py).
2026-09-17 14:35:14 +02:00
ineszang44 4ee2109628 fix(frontend): prise en compte du répertoire du reporter 2026-09-17 14:32:14 +02:00
Johan LEROYandGitHub ec1c05ad54 Merge pull request #102 from ineszang/feat/ml
feat(ml): initialise le pipeline d'entrainement LightGBM (ADR 0005)
2026-09-17 14:29:10 +02:00
ineszang44 334ca5982b fix(frontend): chemin vers lcov.info 2026-09-17 14:26:22 +02:00
ineszang44 2465021d61 fix(frontend): chemin vers lcov.info 2026-09-17 14:22:30 +02:00
ineszang44 278299c2b1 test: configuration sonarqube + config angular 2026-09-17 14:18:54 +02:00
Dorian 4f69199734 feat(ml): initialise le pipeline d'entrainement LightGBM (ADR 0005)
ML / Lint, typage et tests (push) Successful in 2m2s
2026-09-17 14:12:26 +02:00
Valentin 1cca4f130c chore(ci): ajoute la couverture de tests au job test du frontend
Frontend / build (push) Successful in 10m14s
Frontend / test (push) Failing after 5m1s
Frontend / SonarQube (push) Skipped
2026-09-17 13:55:17 +02:00
ValentinDeFariaandGitHub 016f226fdb Merge pull request #98 from ineszang/feat/scan-dépendances-dependabot
feat: ajout dependances dependabot
2026-09-17 13:43:25 +02:00
ValentinDeFariaandGitHub 88f4f9a601 chore(ci): ajoute la surveillance docker du frontend a dependabot 2026-09-17 13:42:02 +02:00
ineszang44 ed7311d4ef test: configuration sonarqube 2026-09-17 13:36:41 +02:00
ineszang44 ff68a51424 feat(frontend): fichier de config pour vitest 2026-09-17 13:36:21 +02:00
ineszang44 11f9b1bcd5 test(frontend): sonarqube 2026-09-17 13:04:36 +02:00
ineszang44 41c18a3bb1 test: sonarqube 2026-09-17 12:47:44 +02:00
ineszang44 00ef725249 test: sonarqube 2026-09-17 12:46:04 +02:00
ineszang44 34f35f3ca0 test: sonarqube 2026-09-17 12:26:04 +02:00
Valentin f5cac1c2a8 chore(ci): ajoute un audit de sécurité des dépendances (npm audit, pip-audit) 2026-09-17 12:25:15 +02:00
ineszang44 8e07168a5e test: ajout de propriétés dans le fichier de config pour sonarqube 2026-09-17 12:19:26 +02:00
Johan LEROY 916b5d246a Merge remote-tracking branch 'origin/dev' into feat/password-policy-forgot-password
# Conflicts:
#	apps/backend/app/api/deps.py
#	apps/backend/pyproject.toml
2026-09-17 12:18:48 +02:00
Johan LEROY d167b64188 Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current
# Conflicts:
#	apps/backend/app/repositories/reading.py
#	apps/backend/openapi.json
#	docs/architecture/20-backend.md
2026-09-17 12:17:05 +02:00
Johan LEROYandGitHub 7913518c4b Merge pull request #94 from ineszang/feat/get-readings
feat(backend): expose GET /api/v1/readings avec fenetre bornee et pag…
2026-09-17 12:12:55 +02:00
Valentin 2ad7692f1c Ajoute la configuration Dependabot (npm, uv, github-actions, docker) 2026-09-17 12:12:48 +02:00
Dorian a158d6f84c Merge remote-tracking branch 'origin/dev' into feat/get-readings 2026-09-17 11:58:27 +02:00
ineszangandGitHub 7dfd7a7e74 Merge pull request #88 from ineszang/feat/data-import
Ajout du pipeline d'import des données historiques
2026-09-17 11:57:58 +02:00
Dorian 8d28113f03 feat(backend): expose GET /api/v1/readings avec fenetre bornee et pagination 2026-09-17 11:50:20 +02:00
Johan LEROY 62932e57c3 style(backend): formatage ruff de cli.py 2026-09-17 11:42:44 +02:00
Johan LEROY cd4fd962be fix(backend): regenere openapi.json avec le x-logo ajoute a main.py
Oublie apres l'ajout de l'extension x-logo dans create_app() : le contrat
versionne divergeait du schema genere, faisant echouer
test_the_committed_contract_matches_the_generated_one en CI.
2026-09-17 11:39:24 +02:00
Johan LEROY 517144f7e5 fix(frontend): lockup logo via texte reel plutot qu'un raster recadre
Recomposer icone+texte en une seule image bitmap (recadrage pixel de
l'asset source) donnait un rendu bruite et un espacement fige, impossible
a ajuster proprement (cause du "gros espace entre le texte et l'image"
remonte). Nouveau composant ev-brand : icone PNG nette + texte "EnerVision"
reel en police systeme, tailles liees en em pour que le lockup grossisse en
gardant le meme rapport, et un ecart controlable en CSS plutot que fige
dans un fichier image.
2026-09-17 11:36:29 +02:00
Johan LEROY cc7ca2d359 fix(frontend): logo plus grand et fond de page coherent sur toutes les pages
Logo des pages auth agrandi (64px -> 96px) : encore trop petit avec la
premiere passe. Fond de page (--color-bg) applique globalement sur body
plutot que par page, pour que le dashboard et les pages auth partagent le
meme socle visuel. Logo et respiration du dashboard ajustes en consequence.
2026-09-17 11:30:19 +02:00
Johan LEROY bbafe7d119 fix(frontend): logo/favicon corrects et pages d'authentification plus posees
Le src="logo.png" relatif resolvait mal sur les routes autres que "/" :
chemin absolu "/logo.png". Titre de page "Frontend" -> "EnerVision", favicon
regenere depuis l'icone reelle du logo. Pages login/change-password
retravaillees (fond degrade de marque, carte plus large, logo et titre plus
presents) via une classe .auth-page partagee plutot que dupliquee par page.
2026-09-17 11:29:07 +02:00
Johan LEROY 9c78c6dc38 feat(frontend): design système - tokens, composants ui et restylage des pages
Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page
(login, change-password, dashboard) en tokens CSS partagés, ajoute un petit
set de composants standalone réutilisables (ev-button, ev-card, ev-alert,
ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans
Swagger/ReDoc côté backend.

Refs #91
2026-09-17 11:14:02 +02:00
Johan LEROY 9161b74874 feat(auth): politique de complexite du mot de passe et flux de reinitialisation
Remplace la regle de longueur seule (12 caracteres) par une exigence de
composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere
special), non documentee dans les exigences officielles du projet, par une
regle explicite partagee entre le backend (validateur Pydantic) et le
frontend.

Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici :
jeton a usage unique hache en base (meme principe que les refresh tokens),
expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib,
Mailpit en dev), avec limitation de debit dediee et reponse generique pour
eviter l'enumeration des comptes.

Closes #87
2026-09-17 10:53:58 +02:00
Meryemel-gham 6798d35572 fix(data): corrige le typage de l'import historique
Backend / Lint, typage et tests (push) Successful in 1m16s
2026-09-17 10:41:18 +02:00
Meryemel-gham f03dce5fe3 style(data): applique le formatage Ruff 2026-09-17 10:06:54 +02:00
Meryemel-gham 74ac1b4577 docs(data): documente le pipeline d'import historique 2026-09-17 09:55:27 +02:00
Meryemel-gham ebb72fb399 test(data): couvre l'import historique 2026-09-17 09:34:30 +02:00
Meryemel-gham b2d52823ba fix(data): aligne l'import historique avec les contraintes BDD 2026-09-17 09:34:30 +02:00
Meryemel-gham fcbfcc8eb2 feat(data): ajoute l'import historique des donnees 2026-09-17 09:34:30 +02:00
Johan LEROYandGitHub 3692d486c6 Merge pull request #83 from ineszang/feat/endpoint-sensors-status
feat(backend): expose GET /api/v1/sensors/status
2026-09-17 09:16:24 +02:00
ValentinDeFariaandGitHub 24bf8bf4b9 Update apps/backend/tests/services/test_sensor.py
Backend / Lint, typage et tests (push) Successful in 1m11s
2026-09-17 09:10:04 +02:00
Johan LEROYandGitHub 9f465538bf Merge pull request #85 from ineszang/feat/auth-front
feat(frontend): authentification frontend
2026-09-17 09:09:59 +02:00
Johan LEROY 515a92b395 fix(frontend): isole les fichiers de tests vitest pour eviter la pollution de mocks
Le test site-load-chart.spec.ts echouait de facon intermittente en CI : sans
isolation, vitest partage le registre de modules entre fichiers de spec, donc
le mock chart.js d'un fichier pouvait ecraser celui d'un autre selon l'ordre
d'execution.
2026-09-17 09:02:53 +02:00
ValentinDeFariaandGitHub 0174272bdd Update dashboard.html 2026-09-16 17:00:52 +02:00
ineszangandGitHub c740b61b24 Merge pull request #80 from ineszang/feat/pipeline-ci
Feat/pipeline ci
2026-09-16 16:41:55 +02:00
Valentin 5669cd63ec feat(frontend): authentification frontend
ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard
2026-09-16 16:07:28 +02:00
Johan LEROY 2f97e4d434 fix(backend): corrige formatage ruff et typage mypy sur sites/current
CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non
annoté, assignation Literal non étroite). Corrige sans changer le
comportement.
2026-09-16 15:27:05 +02:00
Johan LEROY 07ea8d21dc feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29
Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant
la vérification d'existence déjà en place pour GET /sites/{site_id} :
SiteService gagne une dépendance ReadingRepository, sur le modèle de
composition déjà utilisé par StatsService/SensorService. Un site connu
sans lecture rend 200 avec les champs de mesure à null et
data_quality="critical" ; seul un site_id absent rend 404.
2026-09-16 15:25:14 +02:00
ineszang44 06cb60463c fix(frontend): droit d'accès au fichier de config de nginx, réduction de code smells 2026-09-16 15:12:04 +02:00
ineszang44 1c6b6105bd chore(frontend): ajout de la configuration nginx 2026-09-16 14:54:12 +02:00
Johan LEROY 77440281f8 feat(backend): expose GET /api/v1/sensors/status pour l'issue #32
Dérive l'état de santé de 5 capteurs par site et un statut overall depuis
la dernière lecture (data_quality, null_reasons, nullité des colonnes),
sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin.
2026-09-16 14:53:54 +02:00
Johan LEROYandGitHub 63ee79cf32 Merge pull request #81 from ineszang/feat/endpoint-alertes-predictives
feat(backend): expose GET /api/v1/alerts
2026-09-16 14:44:27 +02:00
Johan LEROY 76fa90dfcb test(backend): exerce AlertSeverity comme enum plutot qu'une chaine dans les tests alerts
Backend / Lint, typage et tests (push) Successful in 1m8s
Le filtre severity passait par une chaine brute dans les tests, sans jamais
exercer le trajet reel AlertSeverity (enum) -> SQLAlchemy -> PostgreSQL.
2026-09-16 14:43:27 +02:00
ineszang44 6ecec1afef chore(frontend): ajout du dockerignore et du dockerfile 2026-09-16 14:40:55 +02:00
Johan LEROY e13096c62a Fusionne dev dans feat/endpoint-alertes-predictives
Resout les conflits additifs entre les routes alerts, recommendations
et stats mergees sur dev (PR #79, PR #82) pendant le developpement de
cette branche : deps.py, router.py, openapi.py, openapi.json,
test_openapi.py et 20-backend.md conservent desormais les trois routes.
2026-09-16 14:25:49 +02:00
ineszang44 970a4a50b8 fix(frontend): suppression de dépendance dans le service frontend 2026-09-16 14:22:50 +02:00
Johan LEROYandGitHub 3fb907d6f6 Merge pull request #82 from ineszang/feat/endpoint-recommandations
feat(backend): ajoute GET /recommendations et GET /recommendations/{r…
2026-09-16 14:21:08 +02:00
Johan LEROY 1654e4dd81 docs(backend): documente la checklist d'ajout d'une route metier
La generalisation de ROUTES_A_ROLE (commit precedent) avait deja ete
approuvee sur feat/openapi-contrat mais poussee apres la fermeture de
la PR #76 : elle n'a donc jamais atteint dev, et sa documentation non
plus. Complete ce qui manquait pour que le passage a l'echelle du
contrat OpenAPI soit reellement utilisable par la prochaine route.
2026-09-16 13:27:41 +02:00
Johan LEROY e50921c907 feat(backend): expose GET /api/v1/alerts
Consultation des alertes de consommation, filtrable par site_id et
severity a l'identique du contrat GET /alerts de l'API Mock. Reprend
le gabarit endpoints -> services -> repositories -> models pose par
sites, sur la table alert deja creee par la revision Alembic
e6d2026091501.

Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela
du seul tag users, pour que l'ajout d'alerts a la liste des routes
protegees par role soit reellement verifie.

Closes #59
2026-09-16 13:06:37 +02:00
ineszang44 730adb69b1 chore(frontend): faux positifs cwe 2026-09-16 12:32:28 +02:00
ineszang44 f43c9f76a0 test(frontend): workflow 2026-09-16 12:29:54 +02:00
ineszang44 04e4913952 fix(frontend): code smells 2026-09-16 12:20:15 +02:00
ineszang44 d1e4d8cfa0 test(frontend): lancement automatique du workflow après un push ou avec une pull request 2026-09-16 11:31:10 +02:00
ineszang44 1f0eb410eb test(frontend): suppression des conditions if 2026-09-16 11:28:49 +02:00
ineszang44 078983a41d test(frontend): suppression de la propriété 'pull-request' 2026-09-16 11:25:55 +02:00
ineszang44 596cf43eda test(frontend): lancement manuel du workflow 2026-09-16 11:22:58 +02:00
ineszang44 11baea7117 changement d'ordre des jobs + ajout des dépendances entre les jobs 2026-09-16 10:29:47 +02:00
ineszang44 61b3494d12 correction nom du workflow pour le frontend 2026-09-16 10:03:59 +02:00
ineszang b5cffbf56f fix(frontend): changement de version des actions pour raisons de compatibilité 2026-09-15 16:59:34 +02:00
ineszang 3ef7de5baa feat(frontend): ajout chemins dans le pipeline CI 2026-09-15 16:46:16 +02:00
ineszang ff6e3c288c feat(frontend): ajout du job de build 2026-09-15 16:35:50 +02:00
ineszang 2390e58f78 chore: sonarqube 2026-09-15 16:10:39 +02:00
ineszang b300be5186 chore: init de la config du frontend sur docker compose 2026-09-15 16:10:18 +02:00
ineszang b8f806518f feat(frontend): ajout sonarqube dans le pipeline 2026-09-15 16:08:31 +02:00
ineszangandGitHub 83392c7ff4 chore: init pipeline frontend 2026-09-15 14:56:33 +02:00
184 changed files with 15848 additions and 157 deletions
+27
View File
@@ -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
+40
View File
@@ -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"
+24
View File
@@ -56,3 +56,27 @@ jobs:
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture
run: uv run pytest --cov-fail-under=85
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
+64
View File
@@ -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
+59
View File
@@ -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
+132
View File
@@ -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
View File
@@ -52,11 +52,20 @@ standalone_admin_password.txt
secrets/
# Donnees locales
data/
data/raw/*
!data/raw/.gitkeep
*.sqlite3
monitoring/grafana/data/
monitoring/prometheus/data/
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
ml/data/
ml/models/*
!ml/models/.gitkeep
ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
# IDE et OS
.idea/
.vscode/
+22 -3
View File
@@ -1,15 +1,17 @@
BACKEND := apps/backend
FRONTEND := apps/frontend
ML := ml
.DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
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
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: install-backend install-frontend ## Installe les dépendances backend et frontend
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
@@ -17,6 +19,9 @@ install-backend: ## Installe les dépendances du backend
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 & \
@@ -55,6 +60,20 @@ 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 -t enervision-backend:local $(BACKEND)
+2
View File
@@ -25,6 +25,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
@@ -53,6 +54,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
├── infra/terraform/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
├── monitoring/
│ ├── prometheus/ Collecte et regles d'alerte
│ ├── grafana/ Provisioning et dashboards
+10
View File
@@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut.
APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password
# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS.
# À remplacer par un vrai relais en staging/prod.
APP_SMTP_HOST=localhost
APP_SMTP_PORT=1025
APP_SMTP_USE_TLS=false
APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr
+2
View File
@@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton |
| `/api/v1/auth/password` | Change son propre mot de passe | jeton |
| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public |
| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public |
| `/api/v1/auth/me` | Décrit le compte connecté | jeton |
| `/api/v1/users` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
@@ -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")
+56 -2
View File
@@ -16,20 +16,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.hashing import Argon2Hasher, build_hasher
from app.core.mailer import Mailer, SmtpConfig
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
from app.core.security import decode_access_token as decode_token
from app.db.session import get_session
from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.reading import ReadingRepository
from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.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
@@ -94,11 +101,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
return request.client.host if request.client else None
def get_mailer(settings: SettingsDep) -> Mailer:
return Mailer(
SmtpConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=(
settings.smtp_password.get_secret_value() if settings.smtp_password else None
),
use_tls=settings.smtp_use_tls,
from_address=settings.smtp_from_address,
)
)
def get_auth_service(
session: SessionDep,
settings: SettingsDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
mailer: Annotated[Mailer, Depends(get_mailer)],
) -> AuthService:
return AuthService(
users=UserRepository(session),
@@ -115,6 +138,16 @@ def get_auth_service(
max_failures_per_identifier=settings.login_max_failures_per_identifier,
),
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
reset_tokens=PasswordResetTokenRepository(session),
reset_attempts=PasswordResetAttemptRepository(session),
reset_policy=PasswordResetPolicy(
window_seconds=settings.password_reset_window_seconds,
max_requests_per_identifier=settings.password_reset_max_requests_per_identifier,
max_requests_per_ip=settings.password_reset_max_requests_per_ip,
token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds),
frontend_reset_url=settings.frontend_reset_password_url,
),
mailer=mailer,
)
@@ -138,12 +171,19 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session))
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
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))
@@ -158,6 +198,20 @@ def get_stats_service(session: SessionDep) -> StatsService:
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(
credentials: CredentialsDep,
session: SessionDep,
+30
View File
@@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
},
{
"name": "alerts",
"description": "Consultation des alertes de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "recommendations",
"description": (
@@ -66,6 +71,18 @@ TAGS: Final[list[dict[str, Any]]] = [
"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(
@@ -147,3 +164,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
},
}
REPONSE_LIMITE: Final[Reponses] = {
429: {
"model": ErrorResponse,
"description": "Trop de demandes sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
@@ -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]
+97 -1
View File
@@ -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
# 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 (
AuthServiceDep,
@@ -12,6 +12,7 @@ from app.api.deps import (
require_trusted_origin,
)
from app.api.openapi import (
REPONSE_LIMITE,
REPONSE_ORIGINE_REFUSEE,
REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES,
@@ -21,15 +22,19 @@ from app.api.openapi import (
from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
PrincipalResponse,
ResetPasswordRequest,
ResetTokenValidationResponse,
TokenResponse,
)
from app.schemas.errors import ErrorResponse
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
@@ -39,6 +44,7 @@ logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide"
DETAIL_LIEN_RESET = "Lien invalide ou expiré"
REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION,
@@ -85,6 +91,20 @@ REPONSES_MOT_DE_PASSE: Reponses = {
},
}
REPONSES_FORGOT_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_LIMITE,
}
REPONSES_RESET_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_ORIGINE_REFUSEE,
400: {
"model": ErrorResponse,
"description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).",
},
}
def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession
@@ -267,3 +287,79 @@ async def change_password(
logger.info("auth.password_changed user_id=%s", principal.id)
return repond(response, settings, session)
@router.post(
"/forgot-password",
status_code=status.HTTP_202_ACCEPTED,
summary="Demande un lien de réinitialisation par email",
responses=REPONSES_FORGOT_PASSWORD,
)
async def forgot_password(
payload: ForgotPasswordRequest,
request: Request,
response: Response,
service: AuthServiceDep,
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)
@@ -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,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)
+17 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteResponse
from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.services.site import SiteNotFoundError
router = APIRouter()
@@ -34,3 +34,19 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
+20 -1
View File
@@ -1,13 +1,26 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
from app.api.v1.endpoints import auth, health, recommendations, sites, stats, users
from app.api.v1.endpoints import (
alerts,
auth,
health,
readings,
recommendations,
sensors,
sites,
stats,
users,
)
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
api_router.include_router(
alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR
)
api_router.include_router(
recommendations.router,
prefix="/recommendations",
@@ -15,3 +28,9 @@ api_router.include_router(
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
)
+24 -4
View File
@@ -9,6 +9,7 @@ import argparse
import asyncio
import json
import secrets
import string
import sys
from getpass import getpass
from pathlib import Path
@@ -22,9 +23,9 @@ from app.core.roles import Role
from app.db.session import get_session_factory
from app.main import create_app
from app.repositories.user import UserRepository
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
LONGUEUR_MOT_DE_PASSE_GENERE = 24
LONGUEUR_MINIMALE = 12
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
@@ -111,15 +112,34 @@ def build_parser() -> argparse.ArgumentParser:
return parser
def genere_mot_de_passe() -> str:
tirage = secrets.SystemRandom()
classes = [
string.ascii_uppercase,
string.ascii_lowercase,
string.digits,
SPECIAL_CHARACTERS,
]
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
caracteres = [tirage.choice(classe) for classe in classes]
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
tirage.shuffle(caracteres)
return "".join(caracteres)
def read_password(*, generate: bool) -> str:
if generate:
mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE)
mot_de_passe = genere_mot_de_passe()
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
return mot_de_passe
mot_de_passe = getpass("Mot de passe : ")
if len(mot_de_passe) < LONGUEUR_MINIMALE:
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères")
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
raise SystemExit(f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères")
try:
valide_complexite(mot_de_passe)
except ValueError as erreur:
raise SystemExit(str(erreur)) from erreur
if mot_de_passe != getpass("Confirmation : "):
raise SystemExit("Les deux saisies diffèrent")
return mot_de_passe
+13
View File
@@ -54,6 +54,19 @@ class Settings(BaseSettings):
login_max_failures_per_ip: int = Field(default=20, ge=1)
login_max_failures_per_identifier: int = Field(default=50, ge=1)
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
password_reset_window_seconds: int = Field(default=900, ge=60)
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
smtp_host: str = "localhost"
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_username: str | None = None
smtp_password: SecretStr | None = None
smtp_use_tls: bool = False
smtp_from_address: str = "no-reply@enervision.fr"
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
trust_proxy_headers: bool = False
expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None
+48
View File
@@ -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)
View File
+621
View File
@@ -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()
+39 -2
View File
@@ -1,9 +1,14 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import HTMLResponse
from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware
@@ -18,6 +23,8 @@ logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager
@@ -43,11 +50,41 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_tags=TAGS,
debug=resolved.debug,
lifespan=lifespan,
docs_url="/docs" if documentee else None,
redoc_url="/redoc" if documentee else None,
docs_url=None,
redoc_url=None,
openapi_url="/openapi.json" if documentee else None,
)
if documentee:
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo
# en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon.
openapi_original = application.openapi
def openapi_avec_logo() -> dict[str, object]:
schema = openapi_original()
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
return schema
application.openapi = openapi_avec_logo # type: ignore[method-assign]
@application.get("/docs", include_in_schema=False)
async def docs_swagger(_: Request) -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="/openapi.json",
title=f"{application.title} · Swagger UI",
swagger_favicon_url=LOGO_URL,
)
@application.get("/redoc", include_in_schema=False)
async def docs_redoc(_: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title=f"{application.title} · ReDoc",
redoc_favicon_url=LOGO_URL,
)
application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins:
+4
View File
@@ -4,6 +4,8 @@
from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken
from app.models.refresh_token import RefreshToken
from app.models.user import AppUser
@@ -13,6 +15,8 @@ __all__ = [
"AuditLog",
"Dataset",
"LoginAttempt",
"PasswordResetAttempt",
"PasswordResetToken",
"Prediction",
"Reading",
"Recommendation",
+2
View File
@@ -29,6 +29,8 @@ class AuditAction(StrEnum):
COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested"
MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service"
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
@@ -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)
+21
View File
@@ -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())
+36 -2
View File
@@ -1,4 +1,5 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,10 +13,43 @@ class ReadingRepository:
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.
# 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())
.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()
+34
View File
@@ -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
+53 -2
View File
@@ -1,17 +1,45 @@
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix
# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur.
# Contrainte : `SPECIAL_CHARACTERS` doit rester identique à `password.validator.ts` côté
# frontend. `\w`/`\d` divergent entre Python (Unicode) et JavaScript (ASCII) : une classe
# explicite, plutôt qu'une négation, évite qu'un mot de passe soit accepté d'un côté et
# rejeté de l'autre (ex. "Sécurité1", où "é" comptait comme "spécial" pour Python seul).
import re
from typing import Literal, Self
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
PASSWORD_MIN_LENGTH = 12
PASSWORD_MIN_LENGTH = 8
PASSWORD_MAX_LENGTH = 128
SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?"
_MAJUSCULE = re.compile(r"[A-ZÀ-ÖØ-Þ]")
_MINUSCULE = re.compile(r"[a-zà-öø-þ]")
_CHIFFRE = re.compile(r"[0-9]")
_SPECIAL = re.compile(r"[" + re.escape(SPECIAL_CHARACTERS) + r"]")
def valide_complexite(mot_de_passe: str) -> str:
manquants = [
nom
for nom, motif in (
("une majuscule", _MAJUSCULE),
("une minuscule", _MINUSCULE),
("un chiffre", _CHIFFRE),
("un caractère spécial", _SPECIAL),
)
if not motif.search(mot_de_passe)
]
if manquants:
raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}")
return mot_de_passe
class LoginRequest(BaseModel):
email: EmailStr
@@ -22,6 +50,25 @@ class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=1)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class PrincipalResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -37,6 +84,10 @@ class PrincipalResponse(BaseModel):
return cls.model_validate(principal)
class ResetTokenValidationResponse(BaseModel):
valid: bool
class TokenResponse(BaseModel):
access_token: str
token_type: Literal["bearer"] = "bearer" # noqa: S105
+45
View File
@@ -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
+42
View File
@@ -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]
+20
View File
@@ -1,3 +1,6 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -10,3 +13,20 @@ class SiteResponse(BaseModel):
location: str | None
capacity_kw: float | None
status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
+14
View File
@@ -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)
+142
View File
@@ -14,7 +14,11 @@ from datetime import UTC, datetime, timedelta
from typing import NoReturn, Protocol
from uuid import UUID, uuid4
from fastapi import BackgroundTasks
from app.core.hashing import Argon2Hasher
from app.core.logging import get_logger
from app.core.mailer import Mailer
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.core.security import (
@@ -28,9 +32,13 @@ from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.user import UserRepository
logger = get_logger(__name__)
class Transaction(Protocol):
async def commit(self) -> None: ...
@@ -54,6 +62,10 @@ class RateLimitedError(AuthError):
self.retry_after = retry_after
class InvalidOrExpiredResetTokenError(AuthError):
pass
@dataclass(frozen=True, slots=True)
class LoginPolicy:
window_seconds: int
@@ -62,6 +74,15 @@ class LoginPolicy:
max_failures_per_identifier: int
@dataclass(frozen=True, slots=True)
class PasswordResetPolicy:
window_seconds: int
max_requests_per_identifier: int
max_requests_per_ip: int
token_ttl: timedelta
frontend_reset_url: str
@dataclass(frozen=True, slots=True)
class AuthenticatedSession:
principal: Principal
@@ -83,6 +104,10 @@ class AuthService:
token_policy: TokenPolicy,
login_policy: LoginPolicy,
refresh_ttl: timedelta,
reset_tokens: PasswordResetTokenRepository,
reset_attempts: PasswordResetAttemptRepository,
reset_policy: PasswordResetPolicy,
mailer: Mailer,
) -> None:
self._users = users
self._attempts = attempts
@@ -93,6 +118,10 @@ class AuthService:
self._token_policy = token_policy
self._login_policy = login_policy
self._refresh_ttl = refresh_ttl
self._reset_tokens = reset_tokens
self._reset_attempts = reset_attempts
self._reset_policy = reset_policy
self._mailer = mailer
async def authenticate(
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
@@ -200,6 +229,102 @@ class AuthService:
rafraichi = await self._users.get_by_id(principal.id)
return self._session(self._en_principal(rafraichi or compte), secret)
async def request_password_reset(
self,
*,
email: str,
client_ip: str | None,
user_agent: str | None,
background_tasks: BackgroundTasks,
) -> None:
await self._refuse_si_limite_reset(email=email, client_ip=client_ip)
compte = await self._users.get_by_email(email)
# Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme
# `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte
# inconnu, compte inactif, ou email envoyé avec succès. L'envoi SMTP lui-même est différé
# en tâche de fond : le laisser dans le chemin de réponse rouvrirait le même oracle par le
# temps (aller-retour réseau) et par la forme (500 si le relais SMTP échoue, contre 202).
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
await self._hasher.verify_dummy()
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
return
await self._reset_tokens.invalidate_all_for_user(compte.id)
secret = generate_refresh_secret()
await self._reset_tokens.create(
user_id=compte.id,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + self._reset_policy.token_ttl,
client_ip=client_ip,
user_agent=user_agent,
)
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE,
actor_label=compte.email,
target_type="app_user",
target_id=str(compte.id),
client_ip=client_ip,
user_agent=user_agent,
)
await self._transaction.commit()
lien = f"{self._reset_policy.frontend_reset_url}?token={secret}"
background_tasks.add_task(self._envoie_email_reset, compte.email, lien)
async def _envoie_email_reset(self, email: str, reset_url: str) -> None:
try:
await self._mailer.send_password_reset_email(to=email, reset_url=reset_url)
except Exception:
logger.exception("auth.password_reset.mail_failed")
# Piège : lecture seule, pas d'appel à `consume()`. Aucune limitation de débit n'est
# nécessaire ici : le jeton est un secret de 256 bits (`generate_refresh_secret`), donc
# non brute-forçable, et cette route n'apprend rien sur l'existence d'un compte ou d'un
# email, seulement si le lien déjà en main du visiteur est encore valide.
async def is_reset_token_valid(self, token: str) -> bool:
return await self._reset_tokens.exists_valid(fingerprint_refresh(token))
async def confirm_password_reset(
self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None
) -> AuthenticatedSession:
revendique = await self._reset_tokens.consume(fingerprint_refresh(token))
if revendique is None:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
# Piège : le jeton peut avoir été émis avant une désactivation du compte. Sans cette
# relecture, un lien encore valide (15 min) changerait quand même le mot de passe d'un
# compte désactivé, réutilisable dès sa réactivation.
compte = await self._users.get_by_id(revendique.user_id)
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
await self._users.update_password(
revendique.user_id, await self._hasher.hash(new_password), must_change_password=False
)
revoquees = await self._refresh.revoke_all_for_user(
revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
)
secret = await self._ouvre_une_famille(
user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent
)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI,
target_type="app_user",
target_id=str(revendique.user_id),
client_ip=client_ip,
user_agent=user_agent,
detail={"sessions_revoquees": revoquees},
)
await self._transaction.commit()
compte = await self._users.get_by_id(revendique.user_id)
if compte is None:
raise SessionRejectedError("Compte introuvable")
return self._session(self._en_principal(compte), secret)
async def logout_all(self, principal: Principal) -> int:
revoquees = await self._refresh.revoke_all_for_user(
principal.id, RevocationReason.DECONNEXION
@@ -307,6 +432,23 @@ class AuthService:
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None:
politique = self._reset_policy
compteurs = await self._reset_attempts.count_recent(
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
)
depasse = (
compteurs.per_identifier >= politique.max_requests_per_identifier
or compteurs.per_ip >= politique.max_requests_per_ip
)
if not depasse:
return
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _echoue(
self,
email: str,
+18
View File
@@ -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")
+59
View File
@@ -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)
+136
View File
@@ -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
)
+57 -1
View File
@@ -1,7 +1,11 @@
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):
@@ -12,9 +16,26 @@ class SiteNotFoundError(SiteError):
pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService:
def __init__(self, *, sites: SiteRepository) -> None:
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
@@ -24,3 +45,38 @@ class SiteService:
if site is None:
raise SiteNotFoundError(site_id)
return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
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),
)
+2 -9
View File
@@ -1,14 +1,10 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
@dataclass(frozen=True, slots=True)
@@ -58,13 +54,10 @@ class StatsService:
@staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
capacite = site.capacity_kw or 0
# Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le
# producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas,
# `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas.
qualite: DataQuality = "critical"
consommation = None
if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
qualite = derniere.data_quality # type: ignore[assignment]
qualite = qualite_ou_critique(derniere.data_quality)
consommation = derniere.consumption_kw
charge = (
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff
+1230 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -16,6 +16,8 @@ dependencies = [
"pyjwt>=2.10",
"argon2-cffi>=23.1",
"anyio>=4.0",
"aiosmtplib>=5.1.3",
"pandas>=3.0.5",
]
[dependency-groups]
@@ -26,6 +28,7 @@ dev = [
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
]
[build-system]
+137
View File
@@ -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() == []
+137 -1
View File
@@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
@@ -27,15 +28,27 @@ PRINCIPAL = Principal(
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._jeton_valide = jeton_valide
async def refresh(self, **_: object) -> AuthenticatedSession:
return await self.authenticate()
async def is_reset_token_valid(self, **_: object) -> bool:
return self._jeton_valide
async def logout(self, **_: object) -> None:
return None
async def request_password_reset(self, **_: object) -> None:
if self._erreur is not None:
raise self._erreur
return None
async def confirm_password_reset(self, **_: object) -> AuthenticatedSession:
return await self.authenticate()
async def authenticate(self, **_: object) -> AuthenticatedSession:
if self._erreur is not None:
raise self._erreur
@@ -206,3 +219,126 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
response = await client.post("/api/v1/auth/logout")
assert response.status_code != 403
async def test_forgot_password_answers_202_when_the_account_exists(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
)
assert response.status_code == 202
assert response.headers["cache-control"] == "no-store"
async def test_forgot_password_answers_202_identically_when_the_account_is_unknown(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"}
)
assert response.status_code == 202
async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = RateLimitedError(900)
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
)
assert response.status_code == 429
assert response.headers["retry-after"] == "900"
async def test_forgot_password_rejects_a_malformed_email(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"})
assert response.status_code == 422
@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
+4
View File
@@ -31,9 +31,13 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/sites/{site_id}/current"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
+198
View File
@@ -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() == []
@@ -18,6 +18,13 @@ ROUTES_PUBLIQUES = frozenset(
("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/forgot-password"),
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
("POST", "/api/v1/auth/reset-password"),
# 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"),
}
)
@@ -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:
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
+91
View File
@@ -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
+51 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
@@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Site
from app.services.site import SiteNotFoundError
from app.services.site import SiteCurrentReading, SiteNotFoundError
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.LECTEUR) -> Principal:
@@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site:
)
def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading:
return SiteCurrentReading(
timestamp=TIMESTAMP,
site_id=site_id,
site_type="industriel",
consumption_kw=87.34,
consumption_kwh=87.34,
voltage_v=401.2,
current_a=132.5,
power_factor=0.923,
temperature_celsius=22.1,
humidity_percent=58.4,
null_reasons=[],
data_quality="good",
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.site = site()
self.actuel = lecture_actuelle()
async def list_all(self) -> list[Site]:
return [self.site]
@@ -46,6 +67,11 @@ class FauxService:
raise self._erreur
return self.site
async def current(self, site_id: str) -> SiteCurrentReading:
if self._erreur is not None:
raise self._erreur
return self.actuel
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
@@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site(
assert response.status_code == 404
async def test_get_current_returns_the_latest_reading(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites/site-1/current")
assert response.status_code == 200
corps = response.json()
assert corps["site_id"] == "site-1"
assert corps["data_quality"] == "good"
assert corps["consumption_kw"] == 87.34
async def test_get_current_returns_404_for_an_unknown_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(SiteNotFoundError("site-inconnu"))
response = await client.get("/api/v1/sites/site-inconnu/current")
assert response.status_code == 404
async def test_list_sites_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
@@ -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
@@ -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()
@@ -6,6 +6,8 @@ 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
@@ -25,6 +27,20 @@ def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Read
)
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)
@@ -70,3 +86,177 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
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) == []
+61
View File
@@ -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)
+55
View File
@@ -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")]
+223 -1
View File
@@ -5,6 +5,7 @@ from typing import Any
from uuid import UUID, uuid4
import pytest
from fastapi import BackgroundTasks
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
@@ -16,11 +17,15 @@ from app.core.security import (
from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.login_attempt import FailureCounts
from app.repositories.password_reset_attempt import ResetRequestCounts
from app.repositories.password_reset_token import ConsumedResetToken
from app.repositories.refresh_token import ClaimedToken
from app.services.auth import (
AuthService,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
LoginPolicy,
PasswordResetPolicy,
RateLimitedError,
SessionRejectedError,
)
@@ -37,6 +42,13 @@ POLITIQUE_CONNEXION = LoginPolicy(
max_failures_per_ip=20,
max_failures_per_identifier=50,
)
POLITIQUE_RESET = PasswordResetPolicy(
window_seconds=900,
max_requests_per_identifier=3,
max_requests_per_ip=10,
token_ttl=timedelta(minutes=15),
frontend_reset_url="http://localhost:4200/reset-password",
)
@dataclass
@@ -168,6 +180,49 @@ class FausseTransaction:
self.validations += 1
class FauxDepotJetonsReset:
def __init__(
self, revendique: ConsumedResetToken | None = None, *, valide: bool = False
) -> None:
self.revendique = revendique
self.valide = valide
self.crees: list[UUID] = []
self.invalidations: list[UUID] = []
async def create(self, *, user_id: UUID, **_: object) -> None:
self.crees.append(user_id)
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
return self.revendique
async def exists_valid(self, token_hash: bytes) -> bool:
return self.valide
async def invalidate_all_for_user(self, user_id: UUID) -> int:
self.invalidations.append(user_id)
return len(self.invalidations)
class FauxDepotTentativesReset:
def __init__(self, compteurs: ResetRequestCounts | None = None) -> None:
self.compteurs = compteurs or ResetRequestCounts(0, 0)
self.enregistrees: list[str] = []
async def count_recent(self, **_: object) -> ResetRequestCounts:
return self.compteurs
async def record(self, *, email: str, **_: object) -> None:
self.enregistrees.append(email)
class FauxMailer:
def __init__(self) -> None:
self.envois: list[tuple[str, str]] = []
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
self.envois.append((to, reset_url))
@dataclass
class Attirail:
service: AuthService
@@ -176,6 +231,9 @@ class Attirail:
jetons: FauxDepotJetons
audit: FauxDepotAudit
hacheur: FauxHacheur
jetons_reset: FauxDepotJetonsReset
tentatives_reset: FauxDepotTentativesReset
mailer: FauxMailer
def fabrique_service(
@@ -184,12 +242,17 @@ def fabrique_service(
compteurs: FailureCounts | None = None,
hacheur: FauxHacheur | None = None,
jetons: FauxDepotJetons | None = None,
jetons_reset: FauxDepotJetonsReset | None = None,
compteurs_reset: ResetRequestCounts | None = None,
) -> Attirail:
comptes = FauxDepotComptes(compte)
tentatives = FauxDepotTentatives(compteurs)
depot_jetons = jetons or FauxDepotJetons()
audit = FauxDepotAudit()
hacheur = hacheur or FauxHacheur()
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
mailer = FauxMailer()
service = AuthService(
users=comptes, # type: ignore[arg-type]
attempts=tentatives, # type: ignore[arg-type]
@@ -200,8 +263,22 @@ def fabrique_service(
token_policy=POLITIQUE_JETON,
login_policy=POLITIQUE_CONNEXION,
refresh_ttl=timedelta(days=7),
reset_tokens=depot_jetons_reset, # type: ignore[arg-type]
reset_attempts=tentatives_reset, # type: ignore[arg-type]
reset_policy=POLITIQUE_RESET,
mailer=mailer, # type: ignore[arg-type]
)
return Attirail(
service,
comptes,
tentatives,
depot_jetons,
audit,
hacheur,
depot_jetons_reset,
tentatives_reset,
mailer,
)
return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur)
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
@@ -493,3 +570,148 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
assert attirail.jetons.revocations_par_compte == []
assert attirail.jetons.crees == []
async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None:
compte = FauxCompte()
attirail = fabrique_service(compte=compte)
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 == []
+153
View File
@@ -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)]
+224
View File
@@ -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"
+80 -7
View File
@@ -1,8 +1,13 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import pytest
from app.models.energy import Site
from app.services.site import SiteNotFoundError, SiteService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def site(site_id: str = "site-1") -> Site:
return Site(
@@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site:
)
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime = TIMESTAMP
consumption_kw: float | None = 87.34
consumption_kwh: float | None = 87.34
voltage_v: float | None = 401.2
current_a: float | None = 132.5
power_factor: float | None = 0.923
temperature_celsius: float | None = 22.1
humidity_percent: float | None = 58.4
null_reasons: list[str] | None = field(default_factory=list)
data_quality: str | None = "good"
class FakeRepository:
def __init__(self, sites: list[Site]) -> None:
self._sites = sites
@@ -26,24 +46,77 @@ class FakeRepository:
return next((s for s in self._sites if s.site_id == site_id), None)
async def test_list_all_returns_the_repository_sites() -> None:
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
class FauxDepotLectures:
def __init__(self, lectures: dict[str, FauxLecture]) -> None:
self._lectures = lectures
sites = await service.list_all()
async def latest_for_site(self, site_id: str) -> FauxLecture | None:
return self._lectures.get(site_id)
def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService:
return SiteService(
sites=FakeRepository(sites), # type: ignore[arg-type]
readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type]
)
async def test_list_all_returns_the_repository_sites() -> None:
svc = service([site("a"), site("b")])
sites = await svc.list_all()
assert [s.site_id for s in sites] == ["a", "b"]
async def test_get_by_id_returns_the_matching_site() -> None:
service = SiteService(sites=FakeRepository([site("a")]))
svc = service([site("a")])
trouve = await service.get_by_id("a")
trouve = await svc.get_by_id("a")
assert trouve.site_id == "a"
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
service = SiteService(sites=FakeRepository([]))
svc = service([])
with pytest.raises(SiteNotFoundError):
await service.get_by_id("inconnu")
await svc.get_by_id("inconnu")
async def test_current_raises_when_the_site_is_unknown() -> None:
svc = service([])
with pytest.raises(SiteNotFoundError):
await svc.current("inconnu")
async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None:
svc = service([site("a")])
actuel = await svc.current("a")
assert actuel.timestamp is None
assert actuel.consumption_kw is None
assert actuel.data_quality == "critical"
assert actuel.null_reasons == []
async def test_current_copies_every_field_from_the_latest_reading() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a")})
actuel = await svc.current("a")
assert actuel.timestamp == TIMESTAMP
assert actuel.site_type == "industriel"
assert actuel.consumption_kw == 87.34
assert actuel.voltage_v == 401.2
assert actuel.data_quality == "good"
async def test_current_treats_an_unknown_data_quality_as_critical() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)})
actuel = await svc.current("a")
assert actuel.data_quality == "critical"
+15 -4
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from app import cli
from app.schemas.auth import valide_complexite
def test_build_parser_reads_the_create_admin_arguments() -> None:
@@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked(
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
assert mot_de_passe in capsys.readouterr().out
valide_complexite(mot_de_passe)
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"])
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1"
def test_read_password_refuses_a_password_below_the_minimum_length(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cli, "getpass", lambda _: "court")
monkeypatch.setattr(cli, "getpass", lambda _: "Court1!")
with pytest.raises(SystemExit):
cli.read_password(generate=False)
def test_read_password_refuses_a_password_missing_a_character_class(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre")
with pytest.raises(SystemExit):
cli.read_password(generate=False)
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"])
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
with pytest.raises(SystemExit):
+13
View File
@@ -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()
+120
View File
@@ -1,6 +1,20 @@
version = 1
revision = 3
requires-python = "==3.14.*"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "aiosmtplib"
version = "5.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" },
]
[[package]]
name = "alembic"
@@ -306,11 +320,13 @@ name = "enervision-backend"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiosmtplib" },
{ name = "alembic" },
{ name = "anyio" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
@@ -324,6 +340,7 @@ dependencies = [
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -332,11 +349,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiosmtplib", specifier = ">=5.1.3" },
{ name = "alembic", specifier = ">=1.20.0" },
{ name = "anyio", specifier = ">=4.0" },
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
@@ -350,6 +369,7 @@ requires-dist = [
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@@ -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" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
]
[[package]]
name = "packaging"
version = "26.3"
@@ -604,6 +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" },
]
[[package]]
name = "pandas"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
]
[[package]]
name = "pandas-stubs"
version = "3.0.5.260914"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
@@ -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" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -857,6 +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" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -916,6 +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" },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"
+47
View File
@@ -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;"]
+7
View File
@@ -76,6 +76,13 @@ Points à vérifier après toute regénération :
côté backend. Le `docker-compose.yml` n'a aucun service frontend.
4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx).
## Design système
Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`,
`ev-alert`, `ev-badge`) sont documentés dans
[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md).
Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+1 -9
View File
@@ -81,19 +81,11 @@
"builder": "@angular/build:unit-test",
"options": {
"coverage": true,
"isolate": true,
"coverageReporters": [
"text-summary",
"lcov",
"html"
],
"reporters": [
"default",
[
"junit",
{
"outputFile": "test-results/junit.xml"
}
]
]
}
}
+32
View File
@@ -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;
}
}
}
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

+18
View File
@@ -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
+10 -2
View File
@@ -1,13 +1,21 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {catchError, firstValueFrom, of} from 'rxjs';
import {AuthService} from './core/services/auth.service';
import {authInterceptor} from './core/interceptors/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockApiInterceptor])),
provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor])),
provideAppInitializer(() => {
const auth = inject(AuthService);
// Un 401 ici est normal : ça veut juste dire qu'il n'y a pas de session.
return firstValueFrom(auth.refreshShared().pipe(catchError(() => of(null))));
}),
],
};
+20 -1
View File
@@ -1,9 +1,28 @@
import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
},
{
path: 'sites/:siteId',
canActivate: [authGuard],
loadComponent: () =>
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
(m) => m.SiteDetailPlaceholder,
),
},
];
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { Router, ActivatedRouteSnapshot } from '@angular/router';
import { vi } from 'vitest';
import { authGuard } from './auth-guard';
import { AuthService } from '../services/auth.service';
describe('authGuard', () => {
let authMock: { isAuthenticated: ReturnType<typeof vi.fn>; principal: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = { isAuthenticated: vi.fn(), principal: vi.fn() };
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
});
it('redirige vers /login si non authentifié', () => {
authMock.isAuthenticated.mockReturnValue(false);
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('redirige vers /login si le rôle ne correspond pas', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('autorise si authentifié et rôle correspondant', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'admin' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
it('autorise si authentifié et aucun rôle requis', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route) => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
router.navigate(['/login']);
return false;
}
const requiredRole = route.data['role'] as string | undefined;
if (requiredRole && auth.principal()?.role !== requiredRole) {
router.navigate(['/login']);
return false;
}
return true;
};
@@ -0,0 +1,177 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClient,
HttpHandlerFn,
HttpHeaders,
HttpRequest,
provideHttpClient,
withInterceptors
} from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { authInterceptor } from './auth-interceptor';
import { AuthService } from '../services/auth.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authMock: { getAccessToken: ReturnType<typeof vi.fn>; clearSession: ReturnType<typeof vi.fn>; refreshShared: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = {
getAccessToken: vi.fn().mockReturnValue('fake-token'),
clearSession: vi.fn(),
refreshShared: vi.fn(),
};
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
vi.restoreAllMocks();
});
it('ajoute le header Authorization quand un token est disponible', () => {
http.get('/api/v1/stats/summary').subscribe();
const req = httpMock.expectOne('/api/v1/stats/summary');
expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token');
req.flush({});
});
it("n'ajoute pas le header Authorization sur /auth/login", () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('ajoute withCredentials sur les routes /auth/*', () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.withCredentials).toBe(true);
req.flush({});
});
it('redirige vers /change-password sur un 403 avec ce detail précis', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'password_change_required' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('ne redirige pas sur un 403 avec un autre detail', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'Droits insuffisants' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it('déconnecte et redirige vers /login sur un 401 avec error="invalid_token"', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="invalid_token"' }) }
);
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte directement sur un 401 provenant de /auth/refresh, sans tenter de rafraîchir', () => {
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/refresh');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it("ne redirige pas vers /login sur un 401 de /auth/refresh si on est déjà sur /reset-password", () => {
vi.spyOn(window, 'location', 'get').mockReturnValue({
pathname: '/reset-password',
} as Location);
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/refresh');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => {
authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' }));
authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token');
let result: unknown;
http.get('/api/v1/dashboard').subscribe((r) => (result = r));
const firstReq = httpMock.expectOne('/api/v1/dashboard');
firstReq.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
const retriedReq = httpMock.expectOne('/api/v1/dashboard');
expect(retriedReq.request.headers.get('Authorization')).toBe('Bearer new-token');
retriedReq.flush({ ok: true });
expect(result).toEqual({ ok: true });
});
it('déconnecte si le rafraîchissement échoue après un 401 "expired"', () => {
authMock.refreshShared.mockReturnValue(throwError(() => new Error('refresh failed')));
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it("propage l'erreur telle quelle si ce n'est pas une HttpErrorResponse", () => {
const req = new HttpRequest('GET', '/api/v1/dashboard');
const boom = new Error('erreur inattendue, pas HTTP');
const next: HttpHandlerFn = () => throwError(() => boom);
let captured: unknown;
TestBed.runInInjectionContext(() => {
authInterceptor(req, next).subscribe({ error: (e) => (captured = e) });
});
expect(captured).toBe(boom);
});
it('propage un 401 sur /auth/login sans tenter de rafraîchir ni déconnecter', () => {
http.post('/api/v1/auth/login', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/login');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
it("propage un 401 dont le WWW-Authenticate ne correspond à aucun cas connu", () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="unknown_case"' }) }
);
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,93 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { TokenResponse } from '../../shared/models/auth.model';
function parseAuthError(response: HttpErrorResponse): string | null {
const header = response.headers?.get('WWW-Authenticate') ?? '';
const match = header.match(/error="([^"]+)"/);
return match ? match[1] : null;
}
const ROUTES_INVITEES = ['/login', '/forgot-password', '/reset-password'];
// Piège : le rafraîchissement de session lancé au démarrage de l'app (provideAppInitializer)
// échoue silencieusement sans cookie valide. `window.location.pathname` (pas `router.url`,
// pas encore fiable à ce stade) évite qu'un 401 de fond écrase la navigation vers le lien de
// reset reçu par email.
function surRouteInvitee(): boolean {
return ROUTES_INVITEES.some((chemin) => window.location.pathname.startsWith(chemin));
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const isAuthRoute = req.url.includes('/auth/');
let request = isAuthRoute ? req.clone({ withCredentials: true }) : req;
const token = auth.getAccessToken();
if (token && !req.url.endsWith('/auth/login')) {
request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(request).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
if (error.status === 403) {
const detail = (error.error as { detail?: string })?.detail;
if (detail === 'password_change_required') {
router.navigate(['/change-password']);
}
return throwError(() => error);
}
if (error.status !== 401 || req.url.endsWith('/auth/login')) {
return throwError(() => error);
}
if (req.url.endsWith('/auth/refresh')) {
auth.clearSession();
if (!surRouteInvitee()) {
router.navigate(['/login']);
}
return throwError(() => error);
}
const kind = parseAuthError(error);
if (kind === 'invalid_token') {
auth.clearSession();
if (!surRouteInvitee()) {
router.navigate(['/login']);
}
return throwError(() => error);
}
if (kind === 'expired' || kind === 'token_stale') {
return (auth.refreshShared() as Observable<TokenResponse>).pipe(
switchMap(() => {
const retried = request.clone({
setHeaders: { Authorization: `Bearer ${auth.getAccessToken()}` },
});
return next(retried);
}),
catchError((refreshError) => {
auth.clearSession();
if (!surRouteInvitee()) {
router.navigate(['/login']);
}
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
};
@@ -0,0 +1,99 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { environment } from '../../../environments/environment';
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
const tokenResponse = {
access_token: 'abc123',
token_type: 'bearer',
expires_in: 900,
principal: {
id: '1',
email: 'a@a.com',
role: 'admin' as const,
kind: 'human' as const,
must_change_password: false,
},
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('stocke le token et le principal après un login réussi', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/login`);
expect(req.request.withCredentials).toBe(true);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
expect(service.principal()?.email).toBe('a@a.com');
expect(service.isAuthenticated()).toBe(true);
});
it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
service.logout().subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/logout`).flush(null);
expect(service.getAccessToken()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
it("ne déclenche qu'un seul appel réseau si refreshShared est appelé plusieurs fois avant la réponse", () => {
service.refreshShared().subscribe();
service.refreshShared().subscribe();
service.refreshShared().subscribe();
const requests = httpMock.match(`${environment.apiUrl}/auth/refresh`);
expect(requests.length).toBe(1);
requests[0].flush(tokenResponse);
});
it('met à jour la session après un changement de mot de passe réussi', () => {
service.changePassword({ current_password: 'old', new_password: 'new-password-1234' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/password`);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
});
it('récupère le principal courant via /auth/me', () => {
let result: unknown;
service.me().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/auth/me`);
expect(req.request.method).toBe('GET');
req.flush(tokenResponse.principal);
expect(result).toEqual(tokenResponse.principal);
});
it('vérifie la validité du jeton de reset via GET /auth/reset-password/validate', () => {
let result: { valid: boolean } | undefined;
service.validateResetToken('un-secret-opaque').subscribe((r) => (result = r));
const req = httpMock.expectOne(
`${environment.apiUrl}/auth/reset-password/validate?token=un-secret-opaque`
);
expect(req.request.method).toBe('GET');
req.flush({ valid: true });
expect(result).toEqual({ valid: true });
});
});
@@ -0,0 +1,92 @@
import { Service, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, finalize, shareReplay } from 'rxjs';
import {
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
Principal,
ResetPasswordRequest,
TokenResponse,
} from '../../shared/models/auth.model';
import { environment } from '../../../environments/environment';
@Service()
export class AuthService {
private http = inject(HttpClient);
// Jamais de localStorage/sessionStorage/cookie côté JS : juste un signal en
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(null);
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
private rotation$?: Observable<TokenResponse>;
getAccessToken(): string | null {
return this.accessTokenSignal();
}
private setSession(response: TokenResponse): void {
this.accessTokenSignal.set(response.access_token);
this.principalSignal.set(response.principal);
}
clearSession(): void {
this.accessTokenSignal.set(null);
this.principalSignal.set(null);
}
login(credentials: LoginRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
// appelants (sinon le serveur révoque toute la session sur des rotations concurrentes).
refreshShared(): Observable<TokenResponse> {
this.rotation$ ??= this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true })
.pipe(
tap((response) => this.setSession(response)),
finalize(() => (this.rotation$ = undefined)),
shareReplay(1)
);
return this.rotation$;
}
logout(): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true })
.pipe(tap(() => this.clearSession()));
}
changePassword(payload: PasswordChangeRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
}
forgotPassword(payload: ForgotPasswordRequest): Observable<void> {
return this.http.post<void>(`${environment.apiUrl}/auth/forgot-password`, payload);
}
resetPassword(payload: ResetPasswordRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
validateResetToken(token: string): Observable<{ valid: boolean }> {
return this.http.get<{ valid: boolean }>(`${environment.apiUrl}/auth/reset-password/validate`, {
params: { token },
});
}
}
@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SitesService } from './sites.service';
import { environment } from '../../../environments/environment';
describe('SitesService', () => {
let service: SitesService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SitesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne la liste des sites', () => {
let result: unknown;
service.getSites().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites`);
expect(req.request.method).toBe('GET');
req.flush([
{
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
]);
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model';
@Service()
export class SitesService {
private http = inject(HttpClient);
getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
}
}
@@ -0,0 +1,38 @@
<div class="auth-page">
<form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<ev-brand class="auth-brand" />
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
</p>
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input
id="current_password"
class="form-input"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<label class="form-label" for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
class="form-input"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="form-hint">{{ passwordHint }}</span>
@if (errorMessage()) {
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
}
<ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</ev-button>
</ev-card>
</form>
</div>
@@ -0,0 +1,97 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ChangePassword', () => {
let authMock: { changePassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { changePassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ChangePassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'trop-court' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('incorrect');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: 'Un-nouveau-mot-de-passe1!',
});
});
});
@@ -0,0 +1,49 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
export class ChangePassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
isLoading = signal(false);
passwordHint = PASSWORD_HINT;
form = this.fb.nonNullable.group({
current_password: ['', Validators.required],
new_password: ['', passwordValidators],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({
next: (response) => {
this.router.navigate(['/dashboard']);
},
error: () => {
this.isLoading.set(false);
this.errorMessage.set(
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
);
},
});
}
}
@@ -0,0 +1,37 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Mot de passe oublié</h1>
<p class="auth-subtitle">Recevez un lien de réinitialisation par email</p>
@if (submitted()) {
<p class="auth-success">
Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé.
Il expire dans 15 minutes.
</p>
} @else {
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Envoi...' : 'Envoyer le lien' }}
</button>
}
<p class="auth-link"><a routerLink="/login">Retour à la connexion</a></p>
</form>
</div>
@@ -0,0 +1,104 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
.auth-success {
margin: 0.75rem 0 0;
color: #16a34a;
font-size: 0.85rem;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ForgotPassword } from './forgot-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ForgotPassword', () => {
let authMock: { forgotPassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { forgotPassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ForgotPassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
{ provide: ActivatedRoute, useValue: {} },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ForgotPassword);
fixture.componentInstance.onSubmit();
expect(authMock.forgotPassword).not.toHaveBeenCalled();
});
it('affiche le message générique après une soumission réussie', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'operateur@enervision.fr' });
authMock.forgotPassword.mockReturnValue(of(undefined));
component.onSubmit();
expect(component.submitted()).toBe(true);
});
it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'inconnu@enervision.fr' });
authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
component.onSubmit();
expect(component.submitted()).toBe(true);
});
it('affiche le délai à respecter quand le taux limite est atteint', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'operateur@enervision.fr' });
authMock.forgotPassword.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 429,
headers: new HttpHeaders({ 'Retry-After': '900' }),
})
)
);
component.onSubmit();
expect(component.submitted()).toBe(false);
expect(component.retryAfterSeconds()).toBe(900);
});
});
@@ -0,0 +1,53 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-forgot-password',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './forgot-password.html',
styleUrl: './forgot-password.scss',
})
export class ForgotPassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
errorMessage = signal<string | null>(null);
retryAfterSeconds = signal<number | null>(null);
submitted = signal(false);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.forgotPassword(this.form.getRawValue()).subscribe({
// Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse
// du serveur est déjà générique, l'écran doit l'être aussi.
next: () => {
this.isLoading.set(false);
this.submitted.set(true);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de demandes, réessayez plus tard.');
return;
}
this.submitted.set(true);
},
});
}
}
@@ -0,0 +1,43 @@
<div class="auth-page">
<form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<ev-brand class="auth-brand" />
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label class="form-label" for="email">Email</label>
<input
id="email"
class="form-input"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label class="form-label" for="password">Mot de passe</label>
<input
id="password"
class="form-input"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<ev-alert severity="danger">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</ev-alert>
}
<ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</ev-button>
<p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p>
</ev-card>
</form>
</div>
@@ -0,0 +1,9 @@
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -0,0 +1,134 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { Login } from './login';
import { AuthService } from '../../../core/services/auth.service';
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
function configure(queryParams: Record<string, string> = {}) {
const authMock = { login: vi.fn() };
const routerMock = { navigate: vi.fn() };
return {
authMock,
routerMock,
testBed: TestBed.configureTestingModule({
imports: [Login, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } },
},
],
}),
};
}
describe('Login', () => {
let authMock: { login: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
const attirail = configure();
authMock = attirail.authMock;
routerMock = attirail.routerMock;
await attirail.testBed.compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.componentInstance.onSubmit();
expect(authMock.login).not.toHaveBeenCalled();
});
it('redirige vers /change-password si must_change_password est vrai', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('redirige vers /dashboard si le mot de passe est déjà à jour', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it('affiche un message générique sur un 401', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
});
it("affiche le délai d'attente sur un 429 avec Retry-After", () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) }))
);
component.onSubmit();
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('30s');
});
it('affiche le message standard quand on arrive avec ?motif=lien-expire', async () => {
const attirail = configure({ motif: MOTIF_LIEN_RESET_INVALIDE });
await attirail.testBed.compileComponents();
const fixture = TestBed.createComponent(Login);
expect(fixture.componentInstance.errorMessage()).toContain('expiré');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
fixture.detectChanges();
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' });
});
});
@@ -0,0 +1,68 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import {
MESSAGE_LIEN_RESET_INVALIDE,
MOTIF_LIEN_RESET_INVALIDE,
} from '../../../shared/models/auth-redirect-reason';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand],
templateUrl: './login.html',
styleUrl: './login.scss',
})
export class Login {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
errorMessage = signal<string | null>(
this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE
? MESSAGE_LIEN_RESET_INVALIDE
: null,
);
retryAfterSeconds = signal<number | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: (response) => {
if (response.principal.must_change_password) {
this.router.navigate(['/change-password']);
return;
}
this.router.navigate(['/dashboard']);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de tentatives, réessayez plus tard.');
return;
}
this.errorMessage.set('Email ou mot de passe incorrect.');
},
});
}
}

Some files were not shown because too many files have changed in this diff Show More