diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index b146eb5..8ad1302 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -56,3 +56,87 @@ jobs: # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. - name: Tests et couverture run: uv run pytest --cov-fail-under=85 + + # Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première + # migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un + # écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre. + integration: + name: Tests exigeant une base + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + services: + db: + image: timescale/timescaledb-ha:pg17 + env: + POSTGRES_USER: enervision + POSTGRES_PASSWORD: change_me + POSTGRES_DB: enervision_test + ports: + - "5433:5432" + options: >- + --health-cmd "pg_isready -U enervision -d enervision_test" + --health-interval 10s + --health-timeout 5s + --health-retries 12 + --health-start-period 40s + + env: + DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test + APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur + PGPASSWORD: change_me + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + # Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est + # pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1. + - name: Active TimescaleDB sur la base de test + run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb" + + - name: Applique les migrations + run: uv run alembic upgrade head + + # `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job + # ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %. + - name: Tests d'intégration + run: uv run pytest -m integration --no-cov + + security-audit: + name: Audit des dépendances + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + # L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait + # aussi les paquets que son propre `--with` injecte, hors dépendances du projet. + - name: Audite les dépendances livrées + # Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe. + shell: bash + run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index aff8d71..daaa6e3 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,5 +1,4 @@ name: Frontend -# Pipeline à choix multiple on: push: @@ -10,8 +9,9 @@ on: paths: - "apps/frontend/**" - ".github/workflows/frontend.yml" -# Ordre de lancement des jobs -# build -> test -> sonarqube -> deploy + +permissions: + contents: read jobs: build: @@ -23,13 +23,25 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json - - - run: npm ci + + - run: npm ci working-directory: apps/frontend - run: npm run build working-directory: apps/frontend - test: + 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: @@ -39,30 +51,14 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci + - name : Installation des dépendances (Front) + run: npm ci working-directory: apps/frontend - - run: npm test --watch=false --code-coverage --coverageReporters=lcov + - name : Lancement des tests et génénration du rapport de couverture (Front) + run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - name: Upload coverage uses: actions/upload-artifact@v4 with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - - sonarqube: - needs: [build, test] - name: SonarQube - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Download coverage - uses: actions/download-artifact@v4 - with: - name: frontend-coverage - path: apps/frontend/coverage/frontend - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v8 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..b5a07eb --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -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 }} diff --git a/Makefile b/Makefile index 0bb1dcb..2a4b3d2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ML := ml .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train + ml-lint ml-typecheck ml-test ml-check ml-train ml-score 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}' @@ -74,6 +74,9 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),) +ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel + cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index e0daf47..30fcc5a 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -123,6 +123,11 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus. +Ces tests ne sont pas pour autant facultatifs : le job `integration` de +`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et +les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local, +`make db-up` puis `make test-integration`. + ## Couverture Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique @@ -142,14 +147,27 @@ uv run pytest tests/api/test_health.py # un seul fichier uv run pytest -k readiness # par motif de nom ``` -## Trois fichiers à connaître avant de toucher à l'authentification +## Quatre fichiers à connaître avant de toucher à l'authentification + +`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles : +`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas +un fichier de test, c'est la référence que les trois autres confrontent au comportement observé. +**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue +sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route. `tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose -donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la -diff d'une pull request. +donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff +d'une pull request. + +`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans +les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le +reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple +`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais +jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que +`dependency_overrides` court-circuite. `tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c544dae..c247f4c 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -27,6 +27,7 @@ from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.password_reset_attempt import PasswordResetAttemptRepository from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.prediction import PredictionRepository from app.repositories.reading import ReadingRepository from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository @@ -34,6 +35,7 @@ from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy +from app.services.prediction import PredictionService from app.services.reading import ReadingService from app.services.recommendation import RecommendationService from app.services.sensor import SensorService @@ -212,6 +214,15 @@ def get_sensor_service(session: SessionDep) -> SensorService: SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)] +def get_prediction_service(session: SessionDep) -> PredictionService: + return PredictionService( + sites=SiteRepository(session), predictions=PredictionRepository(session) + ) + + +PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 9937467..11b2604 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -83,6 +83,13 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sensors", "description": "État de santé des capteurs par site. Réservé au rôle `admin`.", }, + { + "name": "predictions", + "description": ( + "Dernière prévision de consommation par site, calculée hors ligne par le pipeline " + "de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." + ), + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/predictions.py b/apps/backend/app/api/v1/endpoints/predictions.py new file mode 100644 index 0000000..61a534a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/predictions.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.api.deps import LecteurDep, PredictionServiceDep +from app.schemas.prediction import PredictionSummaryResponse + +router = APIRouter() + + +@router.get( + "", + response_model=PredictionSummaryResponse, + summary="Dernière prédiction de consommation par site", +) +async def get_predictions( + _: LecteurDep, service: PredictionServiceDep +) -> PredictionSummaryResponse: + resume = await service.summary() + return PredictionSummaryResponse.model_validate(resume) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8c0bd9e..6079acf 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -5,6 +5,7 @@ from app.api.v1.endpoints import ( alerts, auth, health, + predictions, readings, recommendations, sensors, @@ -34,3 +35,6 @@ api_router.include_router( api_router.include_router( sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN ) +api_router.include_router( + predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR +) diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py new file mode 100644 index 0000000..f79311a --- /dev/null +++ b/apps/backend/app/repositories/prediction.py @@ -0,0 +1,28 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Prediction + + +class PredictionRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def latest_by_site(self) -> Sequence[Prediction]: + # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule + # ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que + # `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par + # `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus + # récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring. + requete = ( + select(Prediction) + .distinct(Prediction.site_id) + .order_by( + Prediction.site_id, + Prediction.target_at.desc(), + Prediction.prediction_id.desc(), + ) + ) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/schemas/prediction.py b/apps/backend/app/schemas/prediction.py new file mode 100644 index 0000000..b7eeea2 --- /dev/null +++ b/apps/backend/app/schemas/prediction.py @@ -0,0 +1,43 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class PredictionTargetMetric(StrEnum): + CONSUMPTION_KWH = "consumption_kwh" + CONSUMPTION_KW = "consumption_kw" + + +class PredictionStatus(StrEnum): + AVAILABLE = "available" + INSUFFICIENT_DATA = "insufficient_data" + ERROR = "error" + + +class SitePredictionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + target_at: datetime + target_metric: PredictionTargetMetric + period_minutes: int | None + predicted_value: float | None + status: PredictionStatus + failure_reason: str | None + model_reference: str + created_at: datetime + + +class SitePredictionSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + prediction: SitePredictionResponse | None + + +class PredictionSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + sites: list[SitePredictionSummaryResponse] diff --git a/apps/backend/app/services/prediction.py b/apps/backend/app/services/prediction.py new file mode 100644 index 0000000..6235bf2 --- /dev/null +++ b/apps/backend/app/services/prediction.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.models.energy import Prediction, Site +from app.repositories.prediction import PredictionRepository +from app.repositories.site import SiteRepository + + +@dataclass(frozen=True, slots=True) +class SitePrediction: + target_at: datetime + target_metric: str + period_minutes: int | None + predicted_value: float | None + status: str + failure_reason: str | None + model_reference: str + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class SitePredictionSummary: + site_id: str + site_name: str + prediction: SitePrediction | None + + +@dataclass(frozen=True, slots=True) +class PredictionSummary: + timestamp: datetime + sites: list[SitePredictionSummary] + + +class PredictionService: + def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None: + self._sites = sites + self._predictions = predictions + + async def summary(self) -> PredictionSummary: + sites = await self._sites.list_all() + dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()} + + return PredictionSummary( + timestamp=datetime.now(UTC), + sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites], + ) + + +def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary: + # Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui + # n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de + # fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`. + prediction = None + if derniere is not None: + prediction = SitePrediction( + target_at=derniere.target_at, + target_metric=derniere.target_metric, + period_minutes=derniere.period_minutes, + predicted_value=derniere.predicted_value, + status=derniere.status, + failure_reason=derniere.failure_reason, + model_reference=derniere.model_reference, + created_at=derniere.created_at, + ) + + return SitePredictionSummary( + site_id=site.site_id, site_name=site.site_name, prediction=prediction + ) diff --git a/apps/backend/coverage.lcov b/apps/backend/coverage.lcov new file mode 100644 index 0000000..3ce3aa4 --- /dev/null +++ b/apps/backend/coverage.lcov @@ -0,0 +1,3070 @@ +SF:app\__init__.py +end_of_record +SF:app\api\__init__.py +end_of_record +SF:app\api\deps.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:44,1 +DA:45,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:53,1 +DA:54,1 +DA:61,1 +DA:62,1 +DA:72,1 +DA:73,1 +DA:76,1 +DA:84,1 +DA:85,1 +DA:93,1 +DA:97,1 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,1 +DA:104,1 +DA:105,1 +DA:119,1 +DA:126,1 +DA:154,1 +DA:157,1 +DA:161,0 +DA:170,1 +DA:173,1 +DA:174,1 +DA:177,1 +DA:180,1 +DA:181,1 +DA:184,1 +DA:187,1 +DA:188,1 +DA:191,1 +DA:194,1 +DA:195,0 +DA:198,1 +DA:201,1 +DA:202,1 +DA:205,1 +DA:208,1 +DA:209,0 +DA:212,1 +DA:215,1 +DA:220,1 +DA:221,1 +DA:223,1 +DA:224,1 +DA:225,1 +DA:226,0 +DA:227,1 +DA:228,1 +DA:230,0 +DA:231,0 +DA:232,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:241,0 +DA:250,1 +DA:253,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:259,1 +DA:260,1 +DA:261,1 +DA:263,1 +DA:266,1 +DA:267,1 +DA:268,1 +DA:271,1 +DA:274,1 +DA:275,1 +DA:276,1 +DA:277,1 +DA:278,1 +LF:114 +LH:99 +FN:53,58,_non_authentifie +FNDA:1,_non_authentifie +FN:61,67,get_token_policy +FNDA:1,get_token_policy +FN:73,81,_hasher_cache +FNDA:1,_hasher_cache +FN:84,90,get_hasher +FNDA:1,get_hasher +FN:93,101,get_client_ip +FNDA:1,get_client_ip +FN:104,116,get_mailer +FNDA:1,get_mailer +FN:119,151,get_auth_service +FNDA:1,get_auth_service +FN:157,167,get_user_service +FNDA:0,get_user_service +FN:173,174,get_site_service +FNDA:1,get_site_service +FN:180,181,get_alert_service +FNDA:1,get_alert_service +FN:187,188,get_recommendation_service +FNDA:1,get_recommendation_service +FN:194,195,get_stats_service +FNDA:0,get_stats_service +FN:201,202,get_reading_service +FNDA:1,get_reading_service +FN:208,209,get_sensor_service +FNDA:0,get_sensor_service +FN:215,247,get_current_principal +FNDA:1,get_current_principal +FN:253,263,require_role +FNDA:1,require_role +FN:254,261,require_role.garde +FNDA:1,require_role.garde +FN:271,278,require_trusted_origin +FNDA:1,require_trusted_origin +FNF:18 +FNH:15 +BRDA:97,0,jump to line 98,0 +BRDA:97,0,jump to line 101,1 +BRDA:99,0,jump to line 100,- +BRDA:99,0,jump to line 101,- +BRDA:220,0,jump to line 221,1 +BRDA:220,0,jump to line 223,1 +BRDA:231,0,jump to line 232,- +BRDA:231,0,jump to line 236,- +BRDA:236,0,jump to line 237,- +BRDA:236,0,jump to line 238,- +BRDA:238,0,jump to line 239,- +BRDA:238,0,jump to line 241,- +BRDA:255,0,jump to line 256,1 +BRDA:255,0,jump to line 259,1 +BRDA:259,0,jump to line 260,1 +BRDA:259,0,jump to line 261,1 +BRDA:275,0,jump to line 276,1 +BRDA:275,0,jump to line 277,1 +BRDA:277,0,jump to line 278,1 +BRDA:277,0,return from function 'require_trusted_origin',0 +BRF:20 +BRH:10 +end_of_record +SF:app\api\errors.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:15,1 +DA:18,1 +DA:19,1 +DA:26,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:39,1 +DA:45,1 +DA:46,1 +DA:47,1 +LF:17 +LH:17 +FN:18,28,validation_error_handler +FNDA:1,validation_error_handler +FN:31,42,unhandled_error_handler +FNDA:1,unhandled_error_handler +FN:45,47,register_error_handlers +FNDA:1,register_error_handlers +FNF:3 +FNH:3 +end_of_record +SF:app\api\middleware.py +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:15,1 +DA:21,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:33,1 +DA:34,1 +DA:35,1 +LF:15 +LH:15 +FN:25,35,SecurityHeadersMiddleware.dispatch +FNDA:1,SecurityHeadersMiddleware.dispatch +FNF:1 +FNH:1 +BRDA:29,0,jump to line 30,1 +BRDA:29,0,jump to line 33,1 +BRDA:33,0,jump to line 34,1 +BRDA:33,0,jump to line 35,1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\openapi.py +DA:5,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:14,1 +DA:16,1 +DA:35,1 +DA:88,1 +DA:101,1 +DA:111,1 +DA:121,1 +DA:128,1 +DA:138,1 +DA:151,1 +DA:161,1 +DA:168,1 +LF:17 +LH:17 +end_of_record +SF:app\api\security.py +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +LF:11 +LH:11 +FN:13,23,require_metrics_token +FNDA:1,require_metrics_token +FNF:1 +FNH:1 +BRDA:15,0,jump to line 16,1 +BRDA:15,0,jump to line 18,1 +BRDA:20,0,jump to line 23,1 +BRDA:20,0,return from function 'require_metrics_token',1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\v1\__init__.py +end_of_record +SF:app\api\v1\endpoints\__init__.py +end_of_record +SF:app\api\v1\endpoints\alerts.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:16,1 +DA:22,1 +DA:23,1 +LF:9 +LH:9 +FN:16,23,list_alerts +FNDA:1,list_alerts +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\auth.py +DA:5,1 +DA:7,1 +DA:14,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:33,1 +DA:34,1 +DA:42,1 +DA:43,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:70,1 +DA:81,1 +DA:83,1 +DA:85,1 +DA:94,1 +DA:99,1 +DA:109,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:137,1 +DA:143,1 +DA:151,1 +DA:152,1 +DA:154,1 +DA:155,1 +DA:158,1 +DA:159,1 +DA:160,1 +DA:165,1 +DA:166,1 +DA:167,1 +DA:171,1 +DA:172,1 +DA:175,1 +DA:182,1 +DA:189,1 +DA:191,1 +DA:192,1 +DA:197,1 +DA:198,1 +DA:199,1 +DA:208,1 +DA:211,1 +DA:218,1 +DA:221,1 +DA:222,1 +DA:223,1 +DA:224,1 +DA:225,1 +DA:228,1 +DA:235,1 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:247,1 +DA:253,1 +DA:254,1 +DA:257,1 +DA:264,1 +DA:273,0 +DA:275,0 +DA:276,0 +DA:283,0 +DA:284,0 +DA:288,0 +DA:289,0 +DA:292,1 +DA:298,1 +DA:306,1 +DA:308,1 +DA:309,1 +DA:315,1 +DA:316,1 +DA:317,1 +DA:324,1 +DA:330,1 +DA:331,1 +DA:334,1 +DA:341,1 +DA:349,1 +DA:351,1 +DA:352,1 +DA:358,1 +DA:359,1 +DA:360,1 +DA:364,1 +DA:365,1 +LF:102 +LH:91 +FN:109,118,repond +FNDA:1,repond +FN:124,127,entete_de_suppression +FNDA:1,entete_de_suppression +FN:130,134,lit_le_cookie +FNDA:1,lit_le_cookie +FN:143,172,login +FNDA:1,login +FN:182,208,refresh +FNDA:1,refresh +FN:218,225,logout +FNDA:1,logout +FN:235,244,logout_all +FNDA:0,logout_all +FN:253,254,me +FNDA:1,me +FN:264,289,change_password +FNDA:0,change_password +FN:298,321,forgot_password +FNDA:1,forgot_password +FN:330,331,validate_reset_token +FNDA:1,validate_reset_token +FN:341,365,reset_password +FNDA:1,reset_password +FNF:12 +FNH:10 +BRDA:132,0,jump to line 133,1 +BRDA:132,0,jump to line 134,1 +BRDA:223,0,jump to line 224,1 +BRDA:223,0,jump to line 225,1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\v1\endpoints\health.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:46,1 +DA:47,1 +LF:25 +LH:25 +FN:17,23,liveness +FNDA:1,liveness +FN:27,47,readiness +FNDA:1,readiness +FNF:2 +FNH:2 +BRDA:39,0,jump to line 40,1 +BRDA:39,0,jump to line 46,1 +BRF:2 +BRH:2 +end_of_record +SF:app\api\v1\endpoints\readings.py +DA:1,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:13,1 +DA:25,1 +DA:31,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +DA:49,1 +DA:50,1 +DA:54,1 +LF:18 +LH:18 +FN:31,54,list_readings +FNDA:1,list_readings +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\recommendations.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:17,1 +DA:18,1 +DA:21,1 +DA:22,1 +DA:25,1 +DA:31,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:40,1 +LF:19 +LH:19 +FN:18,22,list_recommendations +FNDA:1,list_recommendations +FN:31,40,get_recommendation +FNDA:1,get_recommendation +FNF:2 +FNH:2 +end_of_record +SF:app\api\v1\endpoints\sensors.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:14,1 +DA:15,1 +DA:16,1 +LF:8 +LH:8 +FN:14,16,get_status +FNDA:1,get_status +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\sites.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:36,1 +DA:39,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:52,1 +LF:26 +LH:26 +FN:18,20,list_sites +FNDA:1,list_sites +FN:29,36,get_site +FNDA:1,get_site +FN:45,52,get_current +FNDA:1,get_current +FNF:3 +FNH:3 +end_of_record +SF:app\api\v1\endpoints\stats.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:14,1 +DA:15,1 +DA:16,1 +LF:8 +LH:8 +FN:14,16,get_summary +FNDA:1,get_summary +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\users.py +DA:1,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:25,1 +DA:30,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:49,1 +DA:56,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:71,1 +DA:72,1 +DA:76,1 +DA:77,1 +DA:83,1 +DA:89,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:103,1 +DA:104,1 +DA:107,1 +DA:108,1 +DA:113,1 +DA:114,1 +DA:117,0 +DA:118,0 +DA:121,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,0 +DA:134,0 +DA:138,1 +DA:139,1 +LF:51 +LH:47 +FN:44,46,list_users +FNDA:1,list_users +FN:56,80,create_user +FNDA:1,create_user +FN:89,118,update_user +FNDA:1,update_user +FN:127,142,reset_password +FNDA:1,reset_password +FNF:4 +FNH:4 +BRDA:97,0,jump to line 98,1 +BRDA:97,0,jump to line 99,1 +BRDA:99,0,jump to line 100,1 +BRDA:99,0,jump to line 113,1 +BRDA:113,0,jump to line 114,1 +BRDA:113,0,jump to line 117,0 +BRF:6 +BRH:5 +end_of_record +SF:app\api\v1\router.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:24,1 +DA:30,1 +DA:31,1 +DA:34,1 +LF:13 +LH:13 +end_of_record +SF:app\cli.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:18,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:35,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:52,0 +DA:58,0 +DA:60,0 +DA:69,1 +DA:70,1 +DA:81,1 +DA:82,1 +DA:83,1 +DA:86,1 +DA:87,1 +DA:90,1 +DA:91,1 +DA:92,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:104,1 +DA:108,1 +DA:111,1 +DA:112,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:123,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:137,1 +DA:138,1 +DA:139,1 +DA:140,1 +DA:141,1 +DA:142,1 +DA:143,1 +DA:144,1 +DA:145,1 +DA:148,1 +DA:149,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:155,0 +DA:157,0 +DA:165,0 +DA:166,0 +LF:83 +LH:68 +FN:32,63,create_admin +FNDA:0,create_admin +FN:69,78,settings_du_contrat +FNDA:1,settings_du_contrat +FN:81,83,schema_du_contrat +FNDA:1,schema_du_contrat +FN:86,87,rend_le_contrat +FNDA:1,rend_le_contrat +FN:90,92,export_openapi +FNDA:1,export_openapi +FN:95,112,build_parser +FNDA:1,build_parser +FN:115,127,genere_mot_de_passe +FNDA:1,genere_mot_de_passe +FN:130,145,read_password +FNDA:1,read_password +FN:148,166,main +FNDA:1,main +FNF:9 +FNH:8 +BRDA:46,0,jump to line 47,- +BRDA:46,0,jump to line 49,- +BRDA:49,0,jump to line 50,- +BRDA:49,0,jump to line 52,- +BRDA:131,0,jump to line 132,1 +BRDA:131,0,jump to line 136,1 +BRDA:137,0,jump to line 138,1 +BRDA:137,0,jump to line 139,1 +BRDA:143,0,jump to line 144,1 +BRDA:143,0,jump to line 145,1 +BRDA:151,0,jump to line 152,1 +BRDA:151,0,jump to line 155,0 +BRF:12 +BRH:7 +end_of_record +SF:app\core\__init__.py +end_of_record +SF:app\core\config.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:17,1 +DA:18,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:50,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:71,1 +DA:72,1 +DA:74,1 +DA:75,1 +DA:76,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:86,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:99,1 +DA:100,0 +DA:104,1 +DA:105,1 +DA:107,1 +DA:108,1 +DA:112,1 +DA:113,1 +DA:115,1 +DA:116,1 +DA:118,1 +DA:121,1 +DA:122,1 +DA:123,1 +LF:84 +LH:83 +FN:75,76,Settings.allowed_origins +FNDA:1,Settings.allowed_origins +FN:79,80,Settings.is_production +FNDA:1,Settings.is_production +FN:83,84,Settings.cookies_are_secure +FNDA:1,Settings.cookies_are_secure +FN:87,90,Settings.api_docs_are_exposed +FNDA:1,Settings.api_docs_are_exposed +FN:93,118,Settings._refuse_les_configurations_dangereuses +FNDA:1,Settings._refuse_les_configurations_dangereuses +FN:122,123,get_settings +FNDA:1,get_settings +FNF:6 +FNH:6 +BRDA:88,0,jump to line 89,1 +BRDA:88,0,jump to line 90,1 +BRDA:95,0,jump to line 96,1 +BRDA:95,0,jump to line 99,1 +BRDA:99,0,jump to line 100,0 +BRDA:99,0,jump to line 104,1 +BRDA:104,0,jump to line 105,1 +BRDA:104,0,jump to line 107,1 +BRDA:107,0,jump to line 108,1 +BRDA:107,0,jump to line 112,1 +BRDA:112,0,jump to line 113,1 +BRDA:112,0,jump to line 115,1 +BRDA:115,0,jump to line 116,1 +BRDA:115,0,jump to line 118,1 +BRF:14 +BRH:13 +end_of_record +SF:app\core\cookies.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:47,1 +DA:48,1 +DA:50,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +LF:22 +LH:22 +FN:24,33,RefreshCookie.build +FNDA:1,RefreshCookie.build +FN:36,45,RefreshCookie.expired +FNDA:1,RefreshCookie.expired +FN:47,48,RefreshCookie.as_kwargs +FNDA:1,RefreshCookie.as_kwargs +FN:50,55,RefreshCookie.as_deletion_kwargs +FNDA:1,RefreshCookie.as_deletion_kwargs +FN:58,61,cookie_name +FNDA:1,cookie_name +FNF:5 +FNH:5 +BRDA:59,0,jump to line 60,1 +BRDA:59,0,jump to line 61,1 +BRF:2 +BRH:2 +end_of_record +SF:app\core\hashing.py +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:31,1 +DA:32,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:47,1 +DA:54,1 +LF:29 +LH:29 +FN:20,23,Argon2Hasher.__init__ +FNDA:1,Argon2Hasher.__init__ +FN:25,26,Argon2Hasher.hash +FNDA:1,Argon2Hasher.hash +FN:28,29,Argon2Hasher.verify +FNDA:1,Argon2Hasher.verify +FN:31,32,Argon2Hasher.verify_dummy +FNDA:1,Argon2Hasher.verify_dummy +FN:34,38,Argon2Hasher.needs_rehash +FNDA:1,Argon2Hasher.needs_rehash +FN:40,44,Argon2Hasher._verify +FNDA:1,Argon2Hasher._verify +FN:47,63,build_hasher +FNDA:1,build_hasher +FNF:7 +FNH:7 +end_of_record +SF:app\core\logging.py +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:13,1 +DA:15,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:91,1 +DA:92,1 +LF:24 +LH:24 +FN:31,34,redact +FNDA:1,redact +FN:38,44,RedactingFilter.filter +FNDA:1,RedactingFilter.filter +FN:47,88,configure_logging +FNDA:1,configure_logging +FN:91,92,get_logger +FNDA:1,get_logger +FNF:4 +FNH:4 +BRDA:32,0,jump to line 33,1 +BRDA:32,0,jump to line 34,1 +BRDA:41,0,jump to line 42,1 +BRDA:41,0,jump to line 44,1 +BRF:4 +BRH:4 +end_of_record +SF:app\core\mailer.py +DA:4,1 +DA:5,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:40,0 +DA:48,0 +LF:18 +LH:11 +FN:25,26,Mailer.__init__ +FNDA:1,Mailer.__init__ +FN:28,48,Mailer.send_password_reset_email +FNDA:0,Mailer.send_password_reset_email +FNF:2 +FNH:1 +end_of_record +SF:app\core\principal.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:12,1 +DA:13,1 +LF:5 +LH:5 +end_of_record +SF:app\core\roles.py +DA:1,1 +DA:2,1 +DA:5,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:18,1 +DA:25,1 +DA:26,1 +LF:12 +LH:12 +FN:25,26,has_at_least +FNDA:1,has_at_least +FNF:1 +FNH:1 +end_of_record +SF:app\core\security.py +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:34,1 +DA:35,1 +DA:42,1 +DA:43,1 +DA:51,1 +DA:59,1 +DA:60,1 +DA:77,1 +DA:78,1 +DA:79,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,1 +DA:92,1 +DA:93,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:101,1 +DA:110,1 +DA:111,1 +DA:116,1 +DA:117,1 +LF:41 +LH:41 +FN:51,74,encode_access_token +FNDA:1,encode_access_token +FN:77,107,decode_access_token +FNDA:1,decode_access_token +FN:110,111,generate_refresh_secret +FNDA:1,generate_refresh_secret +FN:116,117,fingerprint_refresh +FNDA:1,fingerprint_refresh +FNF:4 +FNH:4 +BRDA:92,0,jump to line 93,1 +BRDA:92,0,jump to line 95,1 +BRF:2 +BRH:2 +end_of_record +SF:app\db\__init__.py +end_of_record +SF:app\db\base.py +DA:1,1 +DA:4,1 +LF:2 +LH:2 +end_of_record +SF:app\db\session.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:33,1 +LF:14 +LH:14 +FN:15,23,get_engine +FNDA:1,get_engine +FN:27,28,get_session_factory +FNDA:1,get_session_factory +FN:31,33,get_session +FNDA:1,get_session +FNF:3 +FNH:3 +end_of_record +SF:app\etl\__init__.py +end_of_record +SF:app\etl\historical_import.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:14,1 +DA:16,1 +DA:34,1 +DA:42,1 +DA:45,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:56,1 +DA:58,1 +DA:59,1 +DA:61,1 +DA:62,0 +DA:64,1 +DA:67,1 +DA:76,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:81,0 +DA:82,1 +DA:83,1 +DA:85,1 +DA:87,1 +DA:89,1 +DA:92,1 +DA:97,1 +DA:99,1 +DA:100,1 +DA:102,1 +DA:104,1 +DA:105,0 +DA:107,1 +DA:108,1 +DA:110,1 +DA:111,1 +DA:115,1 +DA:117,1 +DA:118,1 +DA:120,1 +DA:122,1 +DA:123,0 +DA:127,1 +DA:133,1 +DA:143,1 +DA:145,1 +DA:147,1 +DA:152,1 +DA:153,1 +DA:155,0 +DA:157,1 +DA:159,1 +DA:162,1 +DA:167,1 +DA:168,0 +DA:170,1 +DA:171,1 +DA:172,1 +DA:173,0 +DA:174,0 +DA:176,1 +DA:177,1 +DA:179,1 +DA:180,0 +DA:182,1 +DA:185,1 +DA:198,0 +DA:212,0 +DA:214,0 +DA:215,0 +DA:217,0 +DA:228,0 +DA:260,0 +DA:263,1 +DA:268,0 +DA:281,0 +DA:304,1 +DA:312,1 +DA:314,1 +DA:319,1 +DA:320,1 +DA:322,1 +DA:330,1 +DA:332,1 +DA:366,1 +DA:369,1 +DA:418,1 +DA:435,0 +DA:437,0 +DA:439,0 +DA:444,0 +DA:445,0 +DA:446,0 +DA:447,0 +DA:449,0 +DA:450,0 +DA:452,0 +DA:454,0 +DA:456,0 +DA:457,0 +DA:458,0 +DA:460,0 +DA:465,0 +DA:467,0 +DA:472,0 +DA:473,0 +DA:474,0 +DA:482,0 +DA:487,0 +DA:502,0 +DA:504,0 +DA:509,0 +DA:511,0 +DA:516,0 +DA:521,0 +DA:526,0 +DA:528,0 +DA:543,0 +DA:545,0 +DA:546,0 +DA:547,0 +DA:548,0 +DA:549,0 +DA:552,0 +DA:555,1 +DA:557,0 +DA:559,0 +DA:566,0 +DA:573,0 +DA:579,0 +DA:586,0 +DA:592,0 +DA:595,1 +DA:597,0 +DA:599,0 +DA:600,0 +DA:606,0 +DA:608,0 +DA:620,1 +DA:621,0 +LF:152 +LH:84 +FN:45,53,compute_sha256 +FNDA:1,compute_sha256 +FN:56,64,load_metadata +FNDA:1,load_metadata +FN:67,89,classify_quality +FNDA:1,classify_quality +FN:92,130,validate_source +FNDA:1,validate_source +FN:133,159,normalize_timestamps +FNDA:1,normalize_timestamps +FN:162,182,to_json_value +FNDA:1,to_json_value +FN:185,260,ensure_dataset +FNDA:0,ensure_dataset +FN:263,301,upsert_sites +FNDA:0,upsert_sites +FN:304,366,build_reading_batch +FNDA:1,build_reading_batch +FN:418,552,import_historical +FNDA:0,import_historical +FN:555,592,parse_args +FNDA:0,parse_args +FN:595,617,main +FNDA:0,main +FNF:12 +FNH:7 +BRDA:50,0,jump to line 51,1 +BRDA:50,0,jump to line 53,1 +BRDA:61,0,jump to line 62,0 +BRDA:61,0,jump to line 64,1 +BRDA:78,0,jump to line 79,1 +BRDA:78,0,jump to line 80,1 +BRDA:80,0,jump to line 81,0 +BRDA:80,0,jump to line 82,1 +BRDA:82,0,jump to line 83,1 +BRDA:82,0,jump to line 85,1 +BRDA:99,0,jump to line 100,1 +BRDA:99,0,jump to line 102,1 +BRDA:104,0,jump to line 105,0 +BRDA:104,0,jump to line 107,1 +BRDA:110,0,jump to line 111,1 +BRDA:110,0,jump to line 115,1 +BRDA:117,0,jump to line 118,1 +BRDA:117,0,jump to line 120,1 +BRDA:122,0,jump to line 123,0 +BRDA:122,0,jump to line 127,1 +BRDA:152,0,jump to line 153,1 +BRDA:152,0,jump to line 155,0 +BRDA:167,0,jump to line 168,0 +BRDA:167,0,jump to line 170,1 +BRDA:171,0,jump to line 172,1 +BRDA:171,0,jump to line 176,1 +BRDA:176,0,jump to line 177,1 +BRDA:176,0,jump to line 179,1 +BRDA:179,0,jump to line 180,0 +BRDA:179,0,jump to line 182,1 +BRDA:214,0,jump to line 215,- +BRDA:214,0,jump to line 217,- +BRDA:319,0,jump to line 320,1 +BRDA:319,0,jump to line 366,1 +BRDA:456,0,jump to line 457,- +BRDA:456,0,jump to line 460,- +BRDA:504,0,jump to line 509,- +BRDA:504,0,jump to line 528,- +BRDA:599,0,jump to line 600,- +BRDA:599,0,jump to line 606,- +BRDA:620,0,jump to line 621,0 +BRDA:620,0,exit the module,1 +BRF:42 +BRH:26 +end_of_record +SF:app\main.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,0 +DA:33,0 +DA:36,0 +DA:37,0 +DA:40,1 +DA:41,1 +DA:42,1 +DA:44,1 +DA:45,1 +DA:58,1 +DA:59,1 +DA:63,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:72,1 +DA:73,1 +DA:74,1 +DA:80,1 +DA:81,1 +DA:82,1 +DA:88,1 +DA:90,1 +DA:93,1 +DA:103,1 +DA:105,1 +DA:111,1 +DA:116,1 +DA:117,1 +DA:119,1 +LF:57 +LH:53 +FN:31,37,lifespan +FNDA:0,lifespan +FN:40,119,create_app +FNDA:1,create_app +FN:65,68,create_app.openapi_avec_logo +FNDA:1,create_app.openapi_avec_logo +FN:73,78,create_app.docs_swagger +FNDA:1,create_app.docs_swagger +FN:81,86,create_app.docs_redoc +FNDA:1,create_app.docs_redoc +FNF:5 +FNH:4 +BRDA:58,0,jump to line 59,1 +BRDA:58,0,jump to line 88,1 +BRDA:90,0,jump to line 93,1 +BRDA:90,0,jump to line 103,1 +BRDA:116,0,jump to line 117,1 +BRDA:116,0,jump to line 119,1 +BRF:6 +BRH:6 +end_of_record +SF:app\models\__init__.py +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +LF:8 +LH:8 +end_of_record +SF:app\models\audit_log.py +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:40,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:51,1 +DA:52,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +LF:41 +LH:41 +end_of_record +SF:app\models\energy.py +DA:3,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:24,1 +DA:25,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:81,1 +DA:82,1 +DA:85,1 +DA:86,1 +DA:87,1 +DA:91,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:103,1 +DA:104,1 +DA:105,1 +DA:108,1 +DA:111,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:145,1 +DA:146,1 +DA:149,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:155,1 +DA:156,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:179,1 +DA:180,1 +DA:181,1 +DA:184,1 +DA:185,1 +DA:186,1 +DA:187,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,1 +DA:193,1 +DA:196,1 +DA:197,1 +DA:198,1 +DA:202,1 +DA:203,1 +DA:207,1 +DA:208,1 +DA:209,1 +DA:210,1 +LF:87 +LH:87 +end_of_record +SF:app\models\login_attempt.py +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:37,1 +DA:38,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +LF:23 +LH:23 +end_of_record +SF:app\models\password_reset_attempt.py +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,1 +LF:12 +LH:12 +end_of_record +SF:app\models\password_reset_token.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:27,1 +DA:30,1 +DA:33,1 +DA:34,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +LF:18 +LH:18 +end_of_record +SF:app\models\refresh_token.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:47,1 +DA:50,1 +DA:51,1 +DA:54,1 +DA:55,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +LF:30 +LH:30 +end_of_record +SF:app\models\user.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:15,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:40,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:48,1 +LF:24 +LH:24 +end_of_record +SF:app\repositories\__init__.py +end_of_record +SF:app\repositories\alert.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,0 +DA:19,1 +DA:20,0 +DA:21,1 +LF:14 +LH:12 +FN:10,11,AlertRepository.__init__ +FNDA:1,AlertRepository.__init__ +FN:13,21,AlertRepository.list_all +FNDA:1,AlertRepository.list_all +FNF:2 +FNH:2 +BRDA:17,0,jump to line 18,0 +BRDA:17,0,jump to line 19,1 +BRDA:19,0,jump to line 20,0 +BRDA:19,0,jump to line 21,1 +BRF:4 +BRH:2 +end_of_record +SF:app\repositories\audit_log.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:26,1 +DA:27,0 +DA:28,0 +DA:29,0 +DA:32,1 +DA:33,1 +DA:34,1 +DA:36,1 +DA:49,0 +LF:15 +LH:11 +FN:26,29,assemble_detail +FNDA:0,assemble_detail +FN:33,34,AuditLogRepository.__init__ +FNDA:1,AuditLogRepository.__init__ +FN:36,62,AuditLogRepository.record +FNDA:0,AuditLogRepository.record +FNF:3 +FNH:1 +BRDA:27,0,jump to line 28,- +BRDA:27,0,jump to line 29,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\login_attempt.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:26,1 +DA:34,0 +DA:43,1 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:60,0 +DA:63,0 +LF:20 +LH:13 +FN:23,24,LoginAttemptRepository.__init__ +FNDA:1,LoginAttemptRepository.__init__ +FN:26,41,LoginAttemptRepository.record +FNDA:0,LoginAttemptRepository.record +FN:43,67,LoginAttemptRepository.count_recent_failures +FNDA:0,LoginAttemptRepository.count_recent_failures +FNF:3 +FNH:1 +end_of_record +SF:app\repositories\password_reset_attempt.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:21,0 +DA:25,1 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:41,0 +DA:42,0 +LF:19 +LH:12 +FN:17,18,PasswordResetAttemptRepository.__init__ +FNDA:1,PasswordResetAttemptRepository.__init__ +FN:20,23,PasswordResetAttemptRepository.record +FNDA:0,PasswordResetAttemptRepository.record +FN:25,42,PasswordResetAttemptRepository.count_recent +FNDA:0,PasswordResetAttemptRepository.count_recent +FNF:3 +FNH:1 +end_of_record +SF:app\repositories\password_reset_token.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:25,1 +DA:34,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,1 +DA:46,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:63,1 +DA:64,0 +DA:69,0 +DA:71,1 +DA:72,0 +DA:78,0 +LF:28 +LH:15 +FN:22,23,PasswordResetTokenRepository.__init__ +FNDA:1,PasswordResetTokenRepository.__init__ +FN:25,43,PasswordResetTokenRepository.create +FNDA:0,PasswordResetTokenRepository.create +FN:45,59,PasswordResetTokenRepository.consume +FNDA:0,PasswordResetTokenRepository.consume +FN:63,69,PasswordResetTokenRepository.exists_valid +FNDA:0,PasswordResetTokenRepository.exists_valid +FN:71,78,PasswordResetTokenRepository.invalidate_all_for_user +FNDA:0,PasswordResetTokenRepository.invalidate_all_for_user +FNF:5 +FNH:1 +BRDA:57,0,jump to line 58,- +BRDA:57,0,jump to line 59,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\reading.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:14,1 +DA:18,0 +DA:23,0 +DA:25,1 +DA:28,0 +DA:34,0 +DA:35,0 +DA:37,1 +DA:46,1 +DA:53,1 +DA:54,0 +DA:55,1 +LF:20 +LH:14 +FN:11,12,ReadingRepository.__init__ +FNDA:1,ReadingRepository.__init__ +FN:14,23,ReadingRepository.latest_by_site +FNDA:0,ReadingRepository.latest_by_site +FN:25,35,ReadingRepository.latest_for_site +FNDA:0,ReadingRepository.latest_for_site +FN:37,55,ReadingRepository.list_history +FNDA:1,ReadingRepository.list_history +FNF:4 +FNH:2 +BRDA:53,0,jump to line 54,0 +BRDA:53,0,jump to line 55,1 +BRF:2 +BRH:1 +end_of_record +SF:app\repositories\recommendation.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:21,1 +DA:22,1 +LF:14 +LH:14 +FN:10,11,RecommendationRepository.__init__ +FNDA:1,RecommendationRepository.__init__ +FN:13,15,RecommendationRepository.list_all +FNDA:1,RecommendationRepository.list_all +FN:17,22,RecommendationRepository.get_by_id +FNDA:1,RecommendationRepository.get_by_id +FNF:3 +FNH:3 +end_of_record +SF:app\repositories\refresh_token.py +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:38,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,1 +DA:51,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:81,1 +DA:82,0 +DA:83,0 +DA:85,1 +DA:86,0 +DA:90,1 +DA:91,0 +DA:97,0 +DA:99,1 +DA:100,0 +DA:106,0 +LF:33 +LH:17 +FN:25,26,RefreshTokenRepository.__init__ +FNDA:1,RefreshTokenRepository.__init__ +FN:28,48,RefreshTokenRepository.create +FNDA:0,RefreshTokenRepository.create +FN:50,79,RefreshTokenRepository.claim_for_rotation +FNDA:0,RefreshTokenRepository.claim_for_rotation +FN:81,83,RefreshTokenRepository.inspect +FNDA:0,RefreshTokenRepository.inspect +FN:85,88,RefreshTokenRepository.link_replacement +FNDA:0,RefreshTokenRepository.link_replacement +FN:90,97,RefreshTokenRepository.revoke_family +FNDA:0,RefreshTokenRepository.revoke_family +FN:99,106,RefreshTokenRepository.revoke_all_for_user +FNDA:0,RefreshTokenRepository.revoke_all_for_user +FNF:7 +FNH:1 +BRDA:72,0,jump to line 73,- +BRDA:72,0,jump to line 74,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\site.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +LF:14 +LH:14 +FN:10,11,SiteRepository.__init__ +FNDA:1,SiteRepository.__init__ +FN:13,15,SiteRepository.list_all +FNDA:1,SiteRepository.list_all +FN:17,20,SiteRepository.get_by_id +FNDA:1,SiteRepository.get_by_id +FNF:3 +FNH:3 +end_of_record +SF:app\repositories\user.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:21,0 +DA:22,0 +DA:24,1 +DA:25,0 +DA:27,1 +DA:28,0 +DA:29,0 +DA:31,1 +DA:32,0 +DA:37,0 +DA:39,1 +DA:49,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,1 +DA:64,0 +DA:74,1 +DA:76,0 +DA:80,1 +DA:81,0 +DA:85,1 +DA:86,0 +DA:92,1 +DA:93,0 +LF:35 +LH:19 +FN:17,18,UserRepository.__init__ +FNDA:1,UserRepository.__init__ +FN:20,22,UserRepository.get_by_email +FNDA:0,UserRepository.get_by_email +FN:24,25,UserRepository.get_by_id +FNDA:0,UserRepository.get_by_id +FN:27,29,UserRepository.list_all +FNDA:0,UserRepository.list_all +FN:31,37,UserRepository.count_active_admins +FNDA:0,UserRepository.count_active_admins +FN:39,59,UserRepository.create +FNDA:0,UserRepository.create +FN:61,72,UserRepository.update_password +FNDA:0,UserRepository.update_password +FN:74,78,UserRepository.rehash_password +FNDA:0,UserRepository.rehash_password +FN:80,83,UserRepository.touch_last_login +FNDA:0,UserRepository.touch_last_login +FN:85,90,UserRepository.set_role +FNDA:0,UserRepository.set_role +FN:92,97,UserRepository.set_active +FNDA:0,UserRepository.set_active +FNF:11 +FNH:1 +end_of_record +SF:app\schemas\__init__.py +DA:1,1 +DA:3,1 +LF:2 +LH:2 +end_of_record +SF:app\schemas\alert.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +LF:16 +LH:16 +end_of_record +SF:app\schemas\auth.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:29,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:46,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:59,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:67,1 +DA:68,1 +DA:69,1 +DA:70,1 +DA:73,1 +DA:74,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:87,1 +DA:91,1 +DA:93,1 +LF:43 +LH:43 +FN:28,41,valide_complexite +FNDA:1,valide_complexite +FN:55,56,PasswordChangeRequest._new_password_est_complexe +FNDA:1,PasswordChangeRequest._new_password_est_complexe +FN:69,70,ResetPasswordRequest._new_password_est_complexe +FNDA:1,ResetPasswordRequest._new_password_est_complexe +FN:83,84,PrincipalResponse.from_principal +FNDA:1,PrincipalResponse.from_principal +FNF:4 +FNH:4 +BRDA:39,0,jump to line 40,1 +BRDA:39,0,jump to line 41,1 +BRF:2 +BRH:2 +end_of_record +SF:app\schemas\errors.py +DA:5,1 +DA:8,1 +DA:12,1 +DA:17,1 +DA:21,1 +LF:5 +LH:5 +end_of_record +SF:app\schemas\health.py +DA:1,1 +DA:3,1 +DA:6,1 +DA:16,1 +LF:4 +LH:4 +end_of_record +SF:app\schemas\reading.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +LF:16 +LH:16 +end_of_record +SF:app\schemas\recommendation.py +DA:1,1 +DA:3,1 +DA:6,1 +DA:7,1 +LF:4 +LH:4 +end_of_record +SF:app\schemas\sensor.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:19,1 +DA:20,1 +DA:29,1 +DA:30,1 +DA:38,1 +DA:39,1 +LF:12 +LH:12 +end_of_record +SF:app\schemas\site.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:18,1 +DA:19,1 +LF:7 +LH:7 +end_of_record +SF:app\schemas\stats.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:18,1 +DA:19,1 +LF:7 +LH:7 +end_of_record +SF:app\schemas\user.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:24,1 +DA:25,1 +DA:38,1 +LF:12 +LH:12 +end_of_record +SF:app\services\__init__.py +end_of_record +SF:app\services\alert.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:14,1 +LF:8 +LH:8 +FN:8,9,AlertService.__init__ +FNDA:1,AlertService.__init__ +FN:11,14,AlertService.list_all +FNDA:1,AlertService.list_all +FNF:2 +FNH:2 +end_of_record +SF:app\services\auth.py +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:43,1 +DA:47,1 +DA:48,1 +DA:51,1 +DA:52,1 +DA:55,1 +DA:56,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:65,1 +DA:66,1 +DA:69,1 +DA:70,1 +DA:77,1 +DA:78,1 +DA:86,1 +DA:87,1 +DA:94,1 +DA:95,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:119,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:124,1 +DA:126,1 +DA:129,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:137,1 +DA:141,1 +DA:142,1 +DA:146,1 +DA:147,1 +DA:149,1 +DA:150,1 +DA:153,1 +DA:156,1 +DA:158,1 +DA:160,1 +DA:163,1 +DA:164,1 +DA:165,1 +DA:166,1 +DA:168,1 +DA:169,1 +DA:170,1 +DA:171,1 +DA:172,1 +DA:174,1 +DA:175,1 +DA:183,1 +DA:184,1 +DA:186,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,1 +DA:194,1 +DA:203,1 +DA:204,1 +DA:205,1 +DA:207,1 +DA:212,1 +DA:215,1 +DA:218,1 +DA:227,1 +DA:229,1 +DA:230,1 +DA:232,1 +DA:240,1 +DA:242,1 +DA:248,1 +DA:249,1 +DA:250,1 +DA:251,1 +DA:252,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:263,1 +DA:264,1 +DA:272,1 +DA:274,1 +DA:275,1 +DA:277,1 +DA:278,1 +DA:279,1 +DA:280,1 +DA:281,1 +DA:287,1 +DA:288,1 +DA:290,1 +DA:293,1 +DA:294,1 +DA:295,1 +DA:300,1 +DA:301,1 +DA:302,1 +DA:304,1 +DA:307,1 +DA:310,1 +DA:313,1 +DA:321,1 +DA:323,1 +DA:324,1 +DA:325,0 +DA:326,1 +DA:328,1 +DA:329,1 +DA:332,1 +DA:337,1 +DA:338,1 +DA:340,1 +DA:341,1 +DA:347,1 +DA:354,1 +DA:355,1 +DA:363,1 +DA:366,1 +DA:367,1 +DA:375,1 +DA:377,1 +DA:380,1 +DA:381,1 +DA:382,1 +DA:384,1 +DA:385,1 +DA:389,1 +DA:392,1 +DA:401,1 +DA:402,1 +DA:404,1 +DA:407,1 +DA:408,1 +DA:412,1 +DA:417,1 +DA:418,1 +DA:420,1 +DA:423,1 +DA:424,1 +DA:432,1 +DA:433,1 +DA:435,1 +DA:436,1 +DA:437,1 +DA:441,1 +DA:445,1 +DA:446,1 +DA:448,1 +DA:449,1 +DA:450,1 +DA:452,1 +DA:460,1 +DA:463,1 +DA:464,1 +LF:190 +LH:189 +FN:60,62,RateLimitedError.__init__ +FNDA:1,RateLimitedError.__init__ +FN:95,124,AuthService.__init__ +FNDA:1,AuthService.__init__ +FN:126,158,AuthService.authenticate +FNDA:1,AuthService.authenticate +FN:160,186,AuthService.refresh +FNDA:1,AuthService.refresh +FN:188,192,AuthService.logout +FNDA:1,AuthService.logout +FN:194,230,AuthService.change_password +FNDA:1,AuthService.change_password +FN:232,275,AuthService.request_password_reset +FNDA:1,AuthService.request_password_reset +FN:277,281,AuthService._envoie_email_reset +FNDA:1,AuthService._envoie_email_reset +FN:287,288,AuthService.is_reset_token_valid +FNDA:1,AuthService.is_reset_token_valid +FN:290,326,AuthService.confirm_password_reset +FNDA:1,AuthService.confirm_password_reset +FN:328,338,AuthService.logout_all +FNDA:1,AuthService.logout_all +FN:340,352,AuthService._session +FNDA:1,AuthService._session +FN:354,361,AuthService._en_principal +FNDA:1,AuthService._en_principal +FN:363,375,AuthService._ouvre_une_famille +FNDA:1,AuthService._ouvre_une_famille +FN:377,402,AuthService._traite_rotation_refusee +FNDA:1,AuthService._traite_rotation_refusee +FN:404,433,AuthService._refuse_si_limite +FNDA:1,AuthService._refuse_si_limite +FN:435,450,AuthService._refuse_si_limite_reset +FNDA:1,AuthService._refuse_si_limite_reset +FN:452,464,AuthService._echoue +FNDA:1,AuthService._echoue +FNF:18 +FNH:18 +BRDA:132,0,jump to line 133,1 +BRDA:132,0,jump to line 136,1 +BRDA:136,0,jump to line 137,1 +BRDA:136,0,jump to line 141,1 +BRDA:141,0,jump to line 142,1 +BRDA:141,0,jump to line 146,1 +BRDA:146,0,jump to line 147,1 +BRDA:146,0,jump to line 149,1 +BRDA:165,0,jump to line 166,1 +BRDA:165,0,jump to line 168,1 +BRDA:169,0,jump to line 170,1 +BRDA:169,0,jump to line 174,1 +BRDA:190,0,jump to line 191,1 +BRDA:190,0,jump to line 192,1 +BRDA:204,0,jump to line 205,1 +BRDA:204,0,jump to line 207,1 +BRDA:248,0,jump to line 249,1 +BRDA:248,0,jump to line 254,1 +BRDA:294,0,jump to line 295,1 +BRDA:294,0,jump to line 300,1 +BRDA:301,0,jump to line 302,1 +BRDA:301,0,jump to line 304,1 +BRDA:324,0,jump to line 325,0 +BRDA:324,0,jump to line 326,1 +BRDA:381,0,jump to line 382,1 +BRDA:381,0,jump to line 384,1 +BRDA:384,0,jump to line 385,1 +BRDA:384,0,jump to line 389,1 +BRDA:417,0,jump to line 418,1 +BRDA:417,0,jump to line 420,1 +BRDA:423,0,jump to line 424,1 +BRDA:423,0,jump to line 432,1 +BRDA:445,0,jump to line 446,1 +BRDA:445,0,jump to line 448,1 +BRF:34 +BRH:33 +end_of_record +SF:app\services\data_quality.py +DA:6,1 +DA:8,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +LF:8 +LH:8 +FN:15,18,qualite_ou_critique +FNDA:1,qualite_ou_critique +FNF:1 +FNH:1 +BRDA:16,0,jump to line 17,1 +BRDA:16,0,jump to line 18,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\reading.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:15,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:23,1 +DA:32,1 +DA:33,1 +DA:37,1 +DA:38,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +LF:29 +LH:29 +FN:20,21,ReadingService.__init__ +FNDA:1,ReadingService.__init__ +FN:23,35,ReadingService.list_history +FNDA:1,ReadingService.list_history +FN:38,53,ReadingService._resoudre_fenetre +FNDA:1,ReadingService._resoudre_fenetre +FN:56,59,_vers_utc +FNDA:1,_vers_utc +FNF:4 +FNH:4 +BRDA:46,0,jump to line 47,1 +BRDA:46,0,jump to line 49,1 +BRDA:49,0,jump to line 50,1 +BRDA:49,0,jump to line 51,1 +BRDA:51,0,jump to line 52,1 +BRDA:51,0,jump to line 53,1 +BRDA:57,0,jump to line 58,1 +BRDA:57,0,jump to line 59,1 +BRF:8 +BRH:8 +end_of_record +SF:app\services\recommendation.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +LF:17 +LH:17 +FN:16,17,RecommendationService.__init__ +FNDA:1,RecommendationService.__init__ +FN:19,20,RecommendationService.list_all +FNDA:1,RecommendationService.list_all +FN:22,26,RecommendationService.get_by_id +FNDA:1,RecommendationService.get_by_id +FNF:3 +FNH:3 +BRDA:24,0,jump to line 25,1 +BRDA:24,0,jump to line 26,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\sensor.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:21,1 +DA:29,1 +DA:30,1 +DA:35,1 +DA:36,1 +DA:44,1 +DA:45,1 +DA:52,1 +DA:53,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:67,1 +DA:73,1 +DA:74,1 +DA:75,1 +DA:82,1 +DA:83,1 +DA:85,1 +DA:86,1 +DA:93,1 +DA:99,1 +DA:113,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:126,1 +DA:132,1 +DA:133,1 +DA:134,1 +LF:49 +LH:49 +FN:59,61,SensorService.__init__ +FNDA:1,SensorService.__init__ +FN:63,70,SensorService.status +FNDA:1,SensorService.status +FN:73,110,_sante_site +FNDA:1,_sante_site +FN:113,118,_overall_depuis_qualite +FNDA:1,_overall_depuis_qualite +FN:121,129,_diagnostic +FNDA:1,_diagnostic +FN:132,136,_tout_en_echec +FNDA:1,_tout_en_echec +FNF:6 +FNH:6 +BRDA:74,0,jump to line 75,1 +BRDA:74,0,jump to line 82,1 +BRDA:85,0,jump to line 86,1 +BRDA:85,0,jump to line 93,1 +BRDA:114,0,jump to line 115,1 +BRDA:114,0,jump to line 116,1 +BRDA:116,0,jump to line 117,1 +BRDA:116,0,jump to line 118,1 +BRF:8 +BRH:8 +end_of_record +SF:app\services\site.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:54,1 +DA:69,1 +LF:30 +LH:30 +FN:36,38,SiteService.__init__ +FNDA:1,SiteService.__init__ +FN:40,41,SiteService.list_all +FNDA:1,SiteService.list_all +FN:43,47,SiteService.get_by_id +FNDA:1,SiteService.get_by_id +FN:49,82,SiteService.current +FNDA:1,SiteService.current +FNF:4 +FNH:4 +BRDA:45,0,jump to line 46,1 +BRDA:45,0,jump to line 47,1 +BRDA:53,0,jump to line 54,1 +BRDA:53,0,jump to line 69,1 +BRF:4 +BRH:4 +end_of_record +SF:app\services\stats.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:20,1 +DA:21,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:63,1 +DA:67,1 +LF:31 +LH:31 +FN:31,33,StatsService.__init__ +FNDA:1,StatsService.__init__ +FN:35,52,StatsService.summary +FNDA:1,StatsService.summary +FN:55,74,StatsService._resume_site +FNDA:1,StatsService._resume_site +FNF:3 +FNH:3 +BRDA:59,0,jump to line 60,1 +BRDA:59,0,jump to line 63,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\user.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:21,1 +DA:24,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:33,1 +DA:36,1 +DA:37,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +DA:50,1 +DA:51,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:66,1 +DA:67,0 +DA:69,1 +DA:72,1 +DA:73,1 +DA:75,1 +DA:76,1 +DA:83,1 +DA:90,1 +DA:91,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:109,1 +DA:110,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:115,0 +DA:117,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:129,1 +DA:130,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:139,1 +DA:140,1 +DA:147,1 +DA:148,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:156,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:162,1 +DA:163,1 +DA:164,1 +LF:85 +LH:83 +FN:51,64,UserService.__init__ +FNDA:1,UserService.__init__ +FN:66,67,UserService.list_all +FNDA:0,UserService.list_all +FN:69,91,UserService.create +FNDA:1,UserService.create +FN:93,110,UserService.change_role +FNDA:1,UserService.change_role +FN:112,130,UserService.set_active +FNDA:1,UserService.set_active +FN:132,148,UserService.reset_password +FNDA:1,UserService.reset_password +FN:150,154,UserService._exige +FNDA:1,UserService._exige +FN:156,164,UserService._refuse_si_dernier_admin +FNDA:1,UserService._refuse_si_dernier_admin +FNF:8 +FNH:7 +BRDA:72,0,jump to line 73,1 +BRDA:72,0,jump to line 75,1 +BRDA:95,0,jump to line 96,1 +BRDA:95,0,jump to line 98,1 +BRDA:114,0,jump to line 115,0 +BRDA:114,0,jump to line 117,1 +BRDA:121,0,jump to line 122,1 +BRDA:121,0,jump to line 123,1 +BRDA:152,0,jump to line 153,1 +BRDA:152,0,jump to line 154,1 +BRDA:161,0,jump to line 162,1 +BRDA:161,0,jump to line 163,1 +BRDA:163,0,jump to line 164,1 +BRDA:163,0,return from function '_refuse_si_dernier_admin',1 +BRF:14 +BRH:13 +end_of_record diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 114215b..f7c445c 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1719,6 +1719,62 @@ } ] } + }, + "/api/v1/predictions": { + "get": { + "tags": [ + "predictions" + ], + "summary": "Dernière prédiction de consommation par site", + "operationId": "get_predictions_api_v1_predictions_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PredictionSummaryResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } } }, "components": { @@ -1972,6 +2028,45 @@ ], "title": "PasswordChangeRequest" }, + "PredictionStatus": { + "type": "string", + "enum": [ + "available", + "insufficient_data", + "error" + ], + "title": "PredictionStatus" + }, + "PredictionSummaryResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SitePredictionSummaryResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "sites" + ], + "title": "PredictionSummaryResponse" + }, + "PredictionTargetMetric": { + "type": "string", + "enum": [ + "consumption_kwh", + "consumption_kw" + ], + "title": "PredictionTargetMetric" + }, "PrincipalResponse": { "properties": { "id": { @@ -2518,6 +2613,104 @@ ], "title": "SiteCurrentResponse" }, + "SitePredictionResponse": { + "properties": { + "target_at": { + "type": "string", + "format": "date-time", + "title": "Target At" + }, + "target_metric": { + "$ref": "#/components/schemas/PredictionTargetMetric" + }, + "period_minutes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Period Minutes" + }, + "predicted_value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Predicted Value" + }, + "status": { + "$ref": "#/components/schemas/PredictionStatus" + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Reason" + }, + "model_reference": { + "type": "string", + "title": "Model Reference" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "target_at", + "target_metric", + "period_minutes", + "predicted_value", + "status", + "failure_reason", + "model_reference", + "created_at" + ], + "title": "SitePredictionResponse" + }, + "SitePredictionSummaryResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "prediction": { + "anyOf": [ + { + "$ref": "#/components/schemas/SitePredictionResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "prediction" + ], + "title": "SitePredictionSummaryResponse" + }, "SiteResponse": { "properties": { "site_id": { @@ -2973,6 +3166,10 @@ { "name": "sensors", "description": "État de santé des capteurs par site. Réservé au rôle `admin`." + }, + { + "name": "predictions", + "description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py new file mode 100644 index 0000000..e3b641b --- /dev/null +++ b/apps/backend/tests/api/acces.py @@ -0,0 +1,87 @@ +# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py, +# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune +# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que +# rien ne le signale. +# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est +# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture. + +from typing import Final + +from app.core.roles import Role + +Route = tuple[str, str] + +ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset( + { + ("GET", "/api/v1/health/live"), + ("GET", "/api/v1/health/ready"), + ("POST", "/api/v1/auth/login"), + # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), + # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un + # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. + ("GET", "/api/v1/auth/reset-password/validate"), + ("GET", "/metrics"), + } +) + +# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal` +# n'est construit et `require_role` n'entre jamais en jeu. +ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")}) + +# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du +# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état +# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py. +ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset( + { + ("GET", "/api/v1/auth/me"), + ("POST", "/api/v1/auth/password"), + ("POST", "/api/v1/auth/logout-all"), + } +) + +ROLE_MINIMUM: Final[dict[Route, Role]] = { + ("GET", "/api/v1/sites"): Role.LECTEUR, + ("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR, + ("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR, + ("GET", "/api/v1/alerts"): Role.LECTEUR, + ("GET", "/api/v1/recommendations"): Role.LECTEUR, + ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, + ("GET", "/api/v1/stats/summary"): Role.LECTEUR, + ("GET", "/api/v1/readings"): Role.LECTEUR, + ("GET", "/api/v1/predictions"): Role.LECTEUR, + ("GET", "/api/v1/sensors/status"): Role.ADMIN, + ("GET", "/api/v1/users"): Role.ADMIN, + ("POST", "/api/v1/users"): Role.ADMIN, + ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, + ("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN, +} + +# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution +# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test +# passerait en prouvant autre chose que ce qu'il annonce. +SUBSTITUTIONS: Final[dict[str, str]] = { + "{user_id}": "00000000-0000-0000-0000-000000000000", + "{site_id}": "site-absent-du-jeu-de-donnees", + "{recommendation_id}": "999999999", +} + + +def chemin_concret(chemin: str) -> str: + for gabarit, valeur in SUBSTITUTIONS.items(): + chemin = chemin.replace(gabarit, valeur) + return chemin + + +def routes_du_schema(schema: dict[str, object]) -> list[Route]: + chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment] + return [ + (methode.upper(), chemin) + for chemin, operations in chemins.items() + for methode in operations + if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} + ] diff --git a/apps/backend/tests/api/test_matrice_acces.py b/apps/backend/tests/api/test_matrice_acces.py new file mode 100644 index 0000000..5def3a7 --- /dev/null +++ b/apps/backend/tests/api/test_matrice_acces.py @@ -0,0 +1,279 @@ +# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà, +# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement +# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce +# fichier est ce qui la confronte au comportement observé. +# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut +# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le +# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base. +# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en +# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite. + +import uuid +from collections.abc import AsyncIterator, Callable, Iterator + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient, Response +from sqlalchemy import text + +from app.api.deps import get_current_principal +from app.core.hashing import build_hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role, has_at_least +from app.db.session import get_session, get_session_factory +from app.repositories.user import UserRepository +from tests.api.acces import ROLE_MINIMUM, chemin_concret + +ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN] +IDS_DE_ROLE = ["lecteur", "operateur", "admin"] +REFUS_DE_DROITS = "Droits insuffisants" +REFUS_DE_MOT_DE_PASSE = "password_change_required" +MOT_DE_PASSE = "un-mot-de-passe-de-recette" + + +# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui +# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base +# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la +# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté. +class ResultatVide: + def scalars(self) -> ResultatVide: + return self + + def all(self) -> list[object]: + return [] + + def first(self) -> None: + return None + + def one_or_none(self) -> None: + return None + + def scalar_one_or_none(self) -> None: + return None + + def mappings(self) -> ResultatVide: + return self + + def __iter__(self) -> Iterator[object]: + return iter(()) + + +class SessionMuette: + async def scalar(self, *_: object, **__: object) -> None: + return None + + async def execute(self, *_: object, **__: object) -> ResultatVide: + return ResultatVide() + + async def scalars(self, *_: object, **__: object) -> ResultatVide: + return ResultatVide() + + async def get(self, *_: object, **__: object) -> None: + return None + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + def add(self, *_: object, **__: object) -> None: + return None + + +@pytest.fixture +def base_muette(app: FastAPI) -> None: + async def override() -> AsyncIterator[SessionMuette]: + yield SessionMuette() + + app.dependency_overrides[get_session] = override + + +def principal(role: Role, *, must_change_password: bool = False) -> Principal: + return Principal( + id=uuid.uuid4(), + email=f"matrice-{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=must_change_password, + ) + + +@pytest.fixture +def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]: + def installe(acteur: Principal) -> None: + app.dependency_overrides[get_current_principal] = lambda: acteur + + yield installe + app.dependency_overrides.pop(get_current_principal, None) + + +async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response: + return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type] + + +def motif_du_refus(response: Response) -> str | None: + if response.status_code != 403: + return None + detail = response.json().get("detail") + return detail if isinstance(detail, str) else None + + +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, + role: Role, +) -> None: + connecte(principal(role)) + laissees_passer: list[tuple[str, str, int]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + if has_at_least(role, minimum): + continue + response = await appelle(client, methode, chemin) + if motif_du_refus(response) != REFUS_DE_DROITS: + laissees_passer.append((methode, chemin, response.status_code)) + + assert laissees_passer == [] + + +# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur +# `/sites`, ne ferait échouer aucun test du dépôt. +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, + role: Role, +) -> None: + connecte(principal(role)) + refusees: list[tuple[str, str]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + if not has_at_least(role, minimum): + continue + response = await appelle(client, methode, chemin) + if motif_du_refus(response) == REFUS_DE_DROITS: + refusees.append((methode, chemin)) + + assert refusees == [] + + +async def test_a_pending_password_change_is_refused_on_every_guarded_route( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, +) -> None: + connecte(principal(Role.ADMIN, must_change_password=True)) + laissees_passer: list[tuple[str, str, int]] = [] + + for methode, chemin in ROLE_MINIMUM: + response = await appelle(client, methode, chemin) + if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE: + laissees_passer.append((methode, chemin, response.status_code)) + + assert laissees_passer == [] + + +@pytest.fixture +async def comptes_par_role() -> AsyncIterator[dict[Role, str]]: + marque = uuid.uuid4().hex[:12] + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES} + + async with get_session_factory()() as session: + depot = UserRepository(session) + for role, email in adresses.items(): + await depot.create(email=email, password_hash=empreinte, role=role) + await session.commit() + + yield adresses + + async with get_session_factory()() as session: + await session.execute( + text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"} + ) + await session.commit() + + +async def authentifie(client: AsyncClient, email: str) -> dict[str, str]: + reponse = await client.post( + "/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE} + ) + assert reponse.status_code == 200, reponse.text + return {"Authorization": f"Bearer {reponse.json()['access_token']}"} + + +@pytest.mark.integration +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_real_token_reaches_exactly_the_routes_of_its_rank( + comptes_par_role: dict[Role, str], client: AsyncClient, role: Role +) -> None: + entetes = await authentifie(client, comptes_par_role[role]) + ecarts: list[tuple[str, str, int, str]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + response = await appelle(client, methode, chemin, headers=entetes) + refuse = motif_du_refus(response) == REFUS_DE_DROITS + if refuse is has_at_least(role, minimum): + ecarts.append((methode, chemin, response.status_code, response.text[:120])) + + assert ecarts == [] + + +# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture +# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur +# arrive sans que `ROLE_MINIMUM` soit mis à jour. +@pytest.mark.integration +async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank( + comptes_par_role: dict[Role, str], client: AsyncClient +) -> None: + lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR]) + operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR]) + divergences: list[tuple[str, str]] = [] + + for methode, chemin in ROLE_MINIMUM: + cote_lecteur = await appelle(client, methode, chemin, headers=lecteur) + cote_operateur = await appelle(client, methode, chemin, headers=operateur) + if cote_lecteur.status_code != cote_operateur.status_code: + divergences.append((methode, chemin)) + + assert divergences == [] + + +# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate +# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé. +@pytest.mark.integration +async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all( + client: AsyncClient, +) -> None: + marque = uuid.uuid4().hex[:12] + email = f"matrice-{marque}-provisoire@enervision.fr" + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + + async with get_session_factory()() as session: + await UserRepository(session).create( + email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True + ) + await session.commit() + + try: + entetes = await authentifie(client, email) + sites = await client.get("/api/v1/sites", headers=entetes) + identite = await client.get("/api/v1/auth/me", headers=entetes) + fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes) + + assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE + assert identite.status_code == 200 + assert fermeture.status_code == 204 + finally: + async with get_session_factory()() as session: + await session.execute(text("delete from app_user where email = :e"), {"e": email}) + await session.commit() diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 85432c4..50e3c3a 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -8,6 +8,7 @@ from typing import Any import pytest from app import cli +from tests.api.acces import ROLE_MINIMUM METHODES = {"get", "post", "patch", "put", "delete"} @@ -24,21 +25,11 @@ ORIGINE_VERIFIEE = { # Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour # `password_change_required`, pas seulement les routes `admin`. -ROUTES_A_ROLE = { - ("GET", "/api/v1/users"), - ("POST", "/api/v1/users"), - ("PATCH", "/api/v1/users/{id}"), - ("POST", "/api/v1/users/{id}/password-reset"), - ("GET", "/api/v1/sites"), - ("GET", "/api/v1/sites/{site_id}"), - ("GET", "/api/v1/sites/{site_id}/current"), - ("GET", "/api/v1/alerts"), - ("GET", "/api/v1/recommendations"), - ("GET", "/api/v1/recommendations/{recommendation_id}"), - ("GET", "/api/v1/stats/summary"), - ("GET", "/api/v1/readings"), - ("GET", "/api/v1/sensors/status"), -} +# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat +# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous +# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et +# `test_every_declared_route_is_classified` interdit l'entrée morte. +ROUTES_A_ROLE = frozenset(ROLE_MINIMUM) @pytest.fixture(scope="module") diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py new file mode 100644 index 0000000..184afc6 --- /dev/null +++ b/apps/backend/tests/api/test_predictions.py @@ -0,0 +1,85 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_prediction_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary + +TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) +CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +def lecteur() -> Principal: + # Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE` + # de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec + # autre chose que sa valeur par défaut. + return Principal( + id=uuid4(), + email="lecteur@enervision.fr", + role=Role.LECTEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxService: + def __init__(self) -> None: + self.resume = PredictionSummary( + timestamp=datetime.now(UTC), + sites=[ + SitePredictionSummary( + site_id="SITE001", + site_name="Bureau Paris La Défense", + prediction=SitePrediction( + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=812.5, + status="available", + failure_reason=None, + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ), + ), + SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None), + ], + ) + + async def summary(self) -> PredictionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_prediction_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: lecteur() + return service + + yield installe + app.dependency_overrides.pop(get_prediction_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_predictions_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/predictions") + + assert response.status_code == 200 + corps = response.json() + premier, second = corps["sites"] + assert premier["site_id"] == "SITE001" + assert premier["prediction"]["predicted_value"] == 812.5 + assert premier["prediction"]["status"] == "available" + assert second["site_id"] == "SITE002" + assert second["prediction"] is None diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 25e4760..ebaa8ff 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -1,6 +1,6 @@ # Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier -# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et -# demande une justification au relecteur. +# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull +# request et demande une justification au relecteur. # Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de # dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route # peut porter la bonne dépendance tout en répondant quand même. @@ -11,58 +11,64 @@ import pytest from fastapi import FastAPI from httpx import AsyncClient -ROUTES_PUBLIQUES = frozenset( - { - ("GET", "/api/v1/health/live"), - ("GET", "/api/v1/health/ready"), - ("POST", "/api/v1/auth/login"), - # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. - ("POST", "/api/v1/auth/logout"), - ("POST", "/api/v1/auth/forgot-password"), - # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune - # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. - ("POST", "/api/v1/auth/reset-password"), - # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un - # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. - ("GET", "/api/v1/auth/reset-password/validate"), - ("GET", "/metrics"), - } +from tests.api.acces import ( + ROLE_MINIMUM, + ROUTE_COOKIE, + ROUTES_PUBLIQUES, + ROUTES_SANS_ROLE, + Route, + chemin_concret, + routes_du_schema, ) -VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000" STATUTS_DE_REFUS = {401, 403} +HORS_SCHEMA = {("GET", "/metrics")} -def routes_declarees(app: FastAPI) -> list[tuple[str, str]]: +def routes_declarees(app: FastAPI) -> list[Route]: schema: dict[str, Any] = app.openapi() - return [ - (methode.upper(), chemin) - for chemin, operations in schema["paths"].items() - for methode in operations - if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} - ] + return routes_du_schema(schema) -def routes_protegees(app: FastAPI) -> list[tuple[str, str]]: +def routes_protegees(app: FastAPI) -> list[Route]: return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES] def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None: - declarees = set(routes_declarees(app)) | {("GET", "/metrics")} + declarees = set(routes_declarees(app)) | HORS_SCHEMA inconnues = ROUTES_PUBLIQUES - declarees assert inconnues == set() +# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite +# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue. +def test_every_declared_route_is_classified(app: FastAPI) -> None: + classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM) + + non_classees = set(routes_declarees(app)) - classees + fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA + + assert non_classees == set(), "classer la route dans tests/api/acces.py" + assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin" + + +def test_the_four_classes_of_routes_stay_disjoint() -> None: + classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)] + + for rang, classe in enumerate(classes): + for autre in classes[rang + 1 :]: + assert classe & autre == frozenset() + + async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public( app: FastAPI, client: AsyncClient ) -> None: ouvertes: list[tuple[str, str, int]] = [] for methode, chemin in routes_protegees(app): - concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION) - response = await client.request(methode, concret, json={}) + response = await client.request(methode, chemin_concret(chemin), json={}) if response.status_code not in STATUTS_DE_REFUS: ouvertes.append((methode, chemin, response.status_code)) @@ -95,4 +101,5 @@ async def test_the_documentation_routes_are_public_by_design( app: FastAPI, client: AsyncClient, chemin: str ) -> None: response = await client.get(chemin) + assert response.status_code == 200 diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py new file mode 100644 index 0000000..2be7ddd --- /dev/null +++ b/apps/backend/tests/repositories/test_prediction.py @@ -0,0 +1,92 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Prediction +from app.repositories.prediction import PredictionRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +pytestmark = pytest.mark.integration + + +async def creer_prediction( + session: AsyncSession, *, site_id: str, **overrides: object +) -> Prediction: + prediction = Prediction( + site_id=site_id, + target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)), + target_metric=overrides.get("target_metric", "consumption_kwh"), + period_minutes=overrides.get("period_minutes", 60), + predicted_value=overrides.get("predicted_value", 42.0), + model_reference=overrides.get("model_reference", "lightgbm-test"), + status=overrides.get("status", "available"), + failure_reason=overrides.get("failure_reason"), + ) + session.add(prediction) + await session.flush() + return prediction + + +async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + ancienne = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.latest_by_site() + identifiants = [ + p.prediction_id + for p in resultats + if p.prediction_id in (ancienne.prediction_id, recente.prediction_id) + ] + await session.rollback() + + assert identifiants == [recente.prediction_id] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = PredictionRepository(session) + voulue_premier = await creer_prediction(session, site_id=premier.site_id) + voulue_second = await creer_prediction(session, site_id=second.site_id) + + resultats = await depot.latest_by_site() + identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)} + await session.rollback() + + assert identifiants == {voulue_premier.site_id, voulue_second.site_id} + + +async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + voulue = await creer_prediction( + session, + site_id=site.site_id, + status="insufficient_data", + predicted_value=None, + failure_reason="pas assez d'historique", + ) + + resultats = await depot.latest_by_site() + identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id] + await session.rollback() + + assert identifiants == [voulue.prediction_id] + + +async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = PredictionRepository(session) + + resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()] + + assert resultats == [] diff --git a/apps/backend/tests/services/test_prediction.py b/apps/backend/tests/services/test_prediction.py new file mode 100644 index 0000000..a402b24 --- /dev/null +++ b/apps/backend/tests/services/test_prediction.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.services.prediction import PredictionService + +TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) +CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +@dataclass +class FauxSite: + site_id: str + site_name: str + + +@dataclass +class FauxPrediction: + site_id: str + target_at: datetime + target_metric: str + period_minutes: int | None + predicted_value: float | None + status: str + failure_reason: str | None + model_reference: str + created_at: datetime + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotPredictions: + def __init__(self, predictions: list[FauxPrediction]) -> None: + self._predictions = predictions + + async def latest_by_site(self) -> list[FauxPrediction]: + return self._predictions + + +def prediction_disponible(site_id: str = "A") -> FauxPrediction: + return FauxPrediction( + site_id=site_id, + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=812.5, + status="available", + failure_reason=None, + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ) + + +async def test_summary_attaches_the_latest_prediction_to_its_site() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.site_id == "A" + assert site.prediction is not None + assert site.prediction.predicted_value == 812.5 + assert site.prediction.status == "available" + + +async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.sites[0].prediction is None + + +async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None: + insuffisante = FauxPrediction( + site_id="A", + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=None, + status="insufficient_data", + failure_reason="pas assez d'historique", + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ) + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.prediction is not None + assert site.prediction.status == "insufficient_data" + assert site.prediction.predicted_value is None + assert site.prediction.failure_reason == "pas assez d'historique" + + +async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].prediction is not None + assert par_site["B"].prediction is None diff --git a/apps/frontend/sonar-project.properties b/apps/frontend/sonar-project.properties new file mode 100644 index 0000000..46a6cce --- /dev/null +++ b/apps/frontend/sonar-project.properties @@ -0,0 +1,18 @@ +sonar.projectKey=ProjetPiscine_EnerVision +sonar.organization=groupe3-ener-vision +sonar.sourceEncoding=UTF-8 + +# Dossier contenant le code source +sonar.sources=apps/frontend/src,apps/backend/app +sonar.tests=apps/backend/tests + +# Liste des fichiers et dossiers à exclure de l'analyse +# Liste des fichiers et dossiers à exclure de l'analyse +sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json + +# Chemin vers le rapport de couverture de code +# Fichier généré par Vitest +# Chemin vers le rapport de couverture de code +# Fichier généré par Vitest +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info +sonar.python.coverage.reportPaths=apps/backend/cov.info \ No newline at end of file diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index d8fe1d6..de0315f 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -21,9 +21,7 @@ export const routes: Routes = [ path: 'sites/:siteId', canActivate: [authGuard], loadComponent: () => - import('./features/sites/site-detail-placeholder/site-detail-placeholder').then( - (m) => m.SiteDetailPlaceholder, - ), + import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, { path: 'monitoring/sensors', diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 5d6e343..4483313 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -64,4 +64,13 @@ describe('mockApiInterceptor', () => { httpMock.expectNone(`${environment.apiUrl}/alerts`); expect((result as unknown[]).length).toBeGreaterThan(0); }); + + it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => { + environment.useMockFixtures = true; + + http.get(`${environment.apiUrl}/predictions`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] }); + }); }); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts index 58287ff..7c48936 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -26,5 +26,7 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => { if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); } + // Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies + // par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel). return next(req); }; diff --git a/apps/frontend/src/app/core/services/predictions.service.spec.ts b/apps/frontend/src/app/core/services/predictions.service.spec.ts new file mode 100644 index 0000000..b3aaf7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { PredictionsService } from './predictions.service'; +import { environment } from '../../../environments/environment'; + +describe('PredictionsService', () => { + let service: PredictionsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PredictionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne un résumé de prévisions', () => { + let result: unknown; + service.getPredictions().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-18T09:00:00Z', + sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }], + }); + + expect((result as { sites: unknown[] }).sites.length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/predictions.service.ts b/apps/frontend/src/app/core/services/predictions.service.ts new file mode 100644 index 0000000..4245f7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { PredictionSummary } from '../../shared/models/prediction.model'; + +@Service() +export class PredictionsService { + private http = inject(HttpClient); + + getPredictions() { + return this.http.get(`${environment.apiUrl}/predictions`); + } +} diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts new file mode 100644 index 0000000..2513f08 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { ReadingsService } from './readings.service'; +import { environment } from '../../../environments/environment'; + +describe('ReadingsService', () => { + let service: ReadingsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ReadingsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("demande l'historique du site avec la fenêtre temporelle donnée", () => { + let result: unknown; + service + .getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z') + .subscribe((r) => (result = r)); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z'); + expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z'); + + req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]); + + expect((result as unknown[]).length).toBe(1); + }); + + it('ne pose pas de paramètres start/end quand ils sont omis', () => { + service.getHistory('SITE001').subscribe(); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.has('start')).toBe(false); + expect(req.request.params.has('end')).toBe(false); + + req.flush([]); + }); +}); diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts new file mode 100644 index 0000000..00a2711 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -0,0 +1,20 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Reading } from '../../shared/models/reading.model'; + +@Service() +export class ReadingsService { + private http = inject(HttpClient); + + getHistory(siteId: string, start?: string, end?: string) { + let params = new HttpParams().set('site_id', siteId); + if (start) { + params = params.set('start', start); + } + if (end) { + params = params.set('end', end); + } + return this.http.get(`${environment.apiUrl}/readings`, { params }); + } +} diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 45aee2c..7f475e7 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -38,4 +38,50 @@ describe('SitesService', () => { expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); }); + + it('appelle le bon endpoint et retourne un site', () => { + let result: unknown; + service.getSite('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`); + expect(req.request.method).toBe('GET'); + + req.flush({ + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }); + + expect((result as { site_id: string }).site_id).toBe('SITE001'); + }); + + it('appelle le bon endpoint et retourne la mesure courante du site', () => { + let result: unknown; + service.getCurrent('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial', + }); + + expect((result as { null_reasons: string[] }).null_reasons).toEqual([ + 'electrical_sensor_failure', + ]); + }); }); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts index 85754cc..c613c7c 100644 --- a/apps/frontend/src/app/core/services/sites.service.ts +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../environments/environment'; import { Site } from '../../shared/models/site.model'; +import { SiteCurrent } from '../../shared/models/site-current.model'; @Service() export class SitesService { @@ -10,4 +11,12 @@ export class SitesService { getSites() { return this.http.get(`${environment.apiUrl}/sites`); } + + getSite(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}`); + } + + getCurrent(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 43751bf..2f48539 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -24,7 +24,13 @@ - @if (error(); as message) { + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); as message) { } @@ -75,4 +81,33 @@ } + + @if (predictions().length > 0) { +
+

Prévisions de consommation

+
    + @for (site of predictions(); track site.site_id) { +
  • + {{ site.site_name }} + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + {{ prediction.predicted_value | number: '1.0-1' }} kWh + {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} + + } @else { + {{ + prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' + }} + } + } @else { + Pas encore de prévision + } +
  • + } +
+
+ } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 6f96c92..9c89f56 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -130,3 +130,40 @@ h2 { .alert-item__message { font-size: 0.9rem; } + +.predictions-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prediction-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); +} + +.prediction-item__site { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__value { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__target { + margin-left: 0.35rem; + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index a655a36..910b0f6 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import {AuthService} from '../../core/services/auth.service'; import {Router, provideRouter} from '@angular/router'; @@ -17,18 +18,24 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +function predictionsMock(sites: unknown[] = []) { + return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; +} + describe('Dashboard', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats et les alertes au démarrage', async () => { + it('charge les stats, les alertes et les prévisions au démarrage', async () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; + const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]); TestBed.configureTestingModule({ imports: [Dashboard], providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, provideRouter([]), ], }); @@ -42,8 +49,12 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); + expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.predictions().length).toBe(1); + expect(fixture.componentInstance.statsError()).toBeNull(); + expect(fixture.componentInstance.alertsError()).toBeNull(); + expect(fixture.componentInstance.predictionsError()).toBeNull(); }); it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { @@ -61,6 +72,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -70,13 +82,13 @@ describe('Dashboard', () => { vi.advanceTimersByTime(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).not.toBeNull(); expect(fixture.componentInstance.stats()).toBeNull(); vi.advanceTimersByTime(10000); expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(fixture.componentInstance.stats()).not.toBeNull(); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it("n'interrompt pas la page quand le chargement des alertes échoue", () => { @@ -88,6 +100,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -96,6 +109,62 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.alerts().length).toBe(0); + expect(fixture.componentInstance.alertsError()).not.toBeNull(); + }); + + it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictions().length).toBe(0); + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + }); + + it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { + vi.useFakeTimers(); + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + + // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit + // toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée. + vi.advanceTimersByTime(30000); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { @@ -111,6 +180,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -141,6 +211,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -168,6 +239,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -183,4 +255,26 @@ describe('Dashboard', () => { dashboard.badgeToneForSeverity('critical'), ); }); + + it('distingue le ton des statuts de prévision', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + const dashboard = fixture.componentInstance; + + expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); + expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning'); + expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger'); + }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index efd0ffa..bfc5785 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ -import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; -import { DecimalPipe } from '@angular/common'; +import { DecimalPipe, DatePipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { Card } from '../../shared/components/ui/card/card'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; @@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record = { critical: 'critical', }; +// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le +// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que +// tomber sur `undefined` si ce statut apparaît un jour. +const TON_PAR_STATUT_PREDICTION: Record = { + available: 'success', + insufficient_data: 'warning', + error: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, imports: [ DecimalPipe, + DatePipe, RouterLink, ConsumptionGauge, SiteLoadChart, @@ -48,30 +60,52 @@ export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); public auth = inject(AuthService); + private predictionsService = inject(PredictionsService); + private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); alerts = signal([]); - error = signal(null); + predictions = signal([]); + + // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) + // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, + // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + statsError = signal(null); + alertsError = signal(null); + predictionsError = signal(null); ngOnInit(): void { this.alertsService .getAlerts() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((alerts) => this.alerts.set(alerts)); + .pipe(catchError(() => this.reportUnavailable(this.alertsError))) + .subscribe((alerts) => { + this.alertsError.set(null); + this.alerts.set(alerts); + }); + + // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul + // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. + this.predictionsService + .getPredictions() + .pipe(catchError(() => this.reportUnavailable(this.predictionsError))) + .subscribe((summary) => { + this.predictionsError.set(null); + this.predictions.set(summary.sites); + }); // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), ), takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { - this.error.set(null); + this.statsError.set(null); this.stats.set(stats); }); } @@ -80,6 +114,10 @@ export class Dashboard implements OnInit { return TON_PAR_SEVERITE[severity]; } + badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { + return TON_PAR_STATUT_PREDICTION[status]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), @@ -91,8 +129,8 @@ export class Dashboard implements OnInit { }); } - private reportUnavailable(): Observable { - this.error.set(UNAVAILABLE_MESSAGE); + private reportUnavailable(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); return EMPTY; } } diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html deleted file mode 100644 index 9533177..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html +++ /dev/null @@ -1,19 +0,0 @@ -
- - -
- - -

Site {{ siteId() }}

-
- - -

Le détail de ce site est à venir (voir issue #51).

- Retour aux sites -
-
diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss deleted file mode 100644 index ca04d4a..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss +++ /dev/null @@ -1,28 +0,0 @@ -:host { - display: block; - color: var(--color-text); - padding: 2.5rem 2rem; - max-width: 640px; - margin: 0 auto; -} - -.site-detail-placeholder__header { - display: flex; - align-items: center; - gap: 0.85rem; - margin-bottom: 1.5rem; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - } -} - -.site-detail-placeholder__logo { - font-size: 1.3rem; -} - -ev-card p { - margin: 0 0 0.75rem; -} diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts deleted file mode 100644 index f229e36..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; -import { SiteDetailPlaceholder } from './site-detail-placeholder'; - -describe('SiteDetailPlaceholder', () => { - it("affiche l'identifiant du site depuis la route", () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE001'); - }); - - it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - paramMap.next(convertToParamMap({ siteId: 'SITE002' })); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE002'); - expect(fixture.nativeElement.textContent).not.toContain('SITE001'); - }); -}); diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts deleted file mode 100644 index 39e53dc..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { map } from 'rxjs'; -import { Card } from '../../../shared/components/ui/card/card'; -import { Brand } from '../../../shared/components/ui/brand/brand'; - -@Component({ - selector: 'app-site-detail-placeholder', - standalone: true, - imports: [RouterLink, Card, Brand], - templateUrl: './site-detail-placeholder.html', - styleUrl: './site-detail-placeholder.scss', -}) -export class SiteDetailPlaceholder { - private route = inject(ActivatedRoute); - - siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId')))); -} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html new file mode 100644 index 0000000..9c4a4bc --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -0,0 +1,85 @@ +
+ + +
+ + +
+

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

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

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

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

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

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

Historique de consommation

+ +
+ } + } @else { + + } + } + + Retour aux sites +
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss new file mode 100644 index 0000000..a0032be --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -0,0 +1,119 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.site-detail__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.site-detail__logo { + font-size: 1.3rem; +} + +.site-detail__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.site-detail__badges { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.banner-empty { + display: block; + margin: 0 0 1.5rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + padding: 1.25rem; + gap: 0.35rem; +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.card__unavailable { + color: var(--color-text-muted); + margin: 0; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem 1.5rem; + margin: 0.5rem 0 0; +} + +.metric { + dt { + font-size: 0.75rem; + color: var(--color-text-muted); + } + + dd { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.metric__unavailable { + color: var(--color-text-muted); + font-weight: 400; +} + +.metric__reason { + font-size: 0.8rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.chart-section { + margin-bottom: 2rem; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts new file mode 100644 index 0000000..5e3cf46 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -0,0 +1,281 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { SiteDetail } from './site-detail'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; + +const SITE = { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', +}; + +const CURRENT_COMPLET = { + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: [] as string[], + data_quality: 'good' as const, +}; + +const SANS_MESURE = { + ...CURRENT_COMPLET, + timestamp: null, + consumption_kw: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + data_quality: 'critical' as const, +}; + +const LECTURE = { + reading_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-17T09:00:00Z', + source: 'api_history' as const, + consumption_kw: 118, + consumption_kwh: null, + consumption_euros: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + solar_irradiance_wm2: null, + is_working_hours: true, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +function setup( + siteId: string, + sitesMock: Partial, + readingsMock: Partial, +) { + const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); + TestBed.configureTestingModule({ + imports: [SiteDetail], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { paramMap } }, + { provide: SitesService, useValue: sitesMock }, + { provide: ReadingsService, useValue: readingsMock }, + ], + }); + return { fixture: TestBed.createComponent(SiteDetail), paramMap }; +} + +describe('SiteDetail', () => { + it('charge le site, la mesure courante et son historique au démarrage', () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + expect(fixture.componentInstance.current()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.history().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("signale l'indisponibilité quand un des appels échoue", () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + }); + + it('efface les données du site précédent quand le chargement du suivant échoue', () => { + const getSite = vi + .fn() + .mockReturnValueOnce(of(SITE)) + .mockReturnValueOnce(throwError(() => new Error('404'))); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + expect(fixture.componentInstance.current()).toBeNull(); + expect(fixture.componentInstance.history()).toEqual([]); + expect(fixture.nativeElement.textContent).not.toContain('Site 1'); + }); + + it('interroge le site et sa mesure courante en parallèle', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET)); + const { fixture } = setup( + 'SITE001', + { getSite, getCurrent }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE001'); + expect(getCurrent).toHaveBeenCalledWith('SITE001'); + }); + + it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => { + const sansConsommation = { + ...CURRENT_COMPLET, + consumption_kw: null, + null_reasons: ['consumption_sensor_failure'], + data_quality: 'partial' as const, + }; + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(sansConsommation)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionKw()).toBeNull(); + expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Consommation indisponible'); + }); + + it('trace la jauge pour une consommation nulle réellement mesurée', () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible'); + }); + + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { + const partielle = { + ...CURRENT_COMPLET, + voltage_v: null, + current_a: null, + power_factor: null, + null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial' as const, + }; + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(partielle)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v'); + expect(tension?.value).toBeNull(); + expect(tension?.reason).toBe('capteur électrique en panne'); + + const texte = fixture.nativeElement.textContent; + expect(texte).toContain('Indisponible'); + expect(texte).toContain('capteur électrique en panne'); + expect(texte).toContain('Données partielles'); + }); + + it('recharge les données quand le paramètre de route siteId change', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE002'); + }); + + it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith( + 'SITE001', + '2026-09-16T10:00:00.000Z', + '2026-09-17T10:00:00Z', + ); + }); + + it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)), + }, + { getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).not.toHaveBeenCalled(); + expect(fixture.componentInstance.hasMeasurement()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.'); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts new file mode 100644 index 0000000..21e716d --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -0,0 +1,213 @@ +import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; +import { Site } from '../../../shared/models/site.model'; +import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model'; +import { SiteCurrent } from '../../../shared/models/site-current.model'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; +import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; + +const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; +const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.'; +const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000; + +const TON_PAR_STATUT: Record = { + actif: 'success', + maintenance: 'warning', + hors_service: 'danger', +}; + +const TON_PAR_QUALITE: Record = { + good: 'success', + partial: 'warning', + degraded: 'danger', + critical: 'critical', +}; + +const LIBELLE_PAR_QUALITE: Record = { + good: 'Données complètes', + partial: 'Données partielles', + degraded: 'Données dégradées', + critical: 'Données critiques', +}; + +type MetricKey = + | 'consumption_kw' + | 'voltage_v' + | 'current_a' + | 'power_factor' + | 'temperature_celsius' + | 'humidity_percent'; + +interface MetricDef { + key: MetricKey; + label: string; + format: (value: number) => string; +} + +const CONSUMPTION_DEF: MetricDef = { + key: 'consumption_kw', + label: 'Consommation', + format: (v) => `${v.toFixed(1)} kW`, +}; + +const METRIC_DEFS: MetricDef[] = [ + CONSUMPTION_DEF, + { key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` }, + { key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` }, + { key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) }, + { key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` }, + { key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` }, +]; + +// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ; +// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide. +const RAISONS_PAR_CHAMP: Record = { + consumption_kw: ['consumption_sensor_failure', 'network_loss'], + voltage_v: ['electrical_sensor_failure', 'network_loss'], + current_a: ['electrical_sensor_failure', 'network_loss'], + power_factor: ['electrical_sensor_failure', 'network_loss'], + temperature_celsius: ['temperature_sensor_failure', 'network_loss'], + humidity_percent: ['humidity_sensor_failure', 'network_loss'], +}; + +const LIBELLE_PAR_RAISON: Record = { + consumption_sensor_failure: 'capteur de consommation en panne', + electrical_sensor_failure: 'capteur électrique en panne', + temperature_sensor_failure: 'capteur de température en panne', + humidity_sensor_failure: "capteur d'humidité en panne", + network_loss: 'perte réseau', +}; + +export interface MetricView { + key: MetricKey; + label: string; + value: string | null; + reason: string; +} + +@Component({ + selector: 'app-site-detail', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], + templateUrl: './site-detail.html', + styleUrl: './site-detail.scss', +}) +export class SiteDetail { + private route = inject(ActivatedRoute); + private sitesService = inject(SitesService); + private readingsService = inject(ReadingsService); + private destroyRef = inject(DestroyRef); + + readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE; + + siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); + + site = signal(null); + current = signal(null); + history = signal([]); + error = signal(null); + + hasMeasurement = computed(() => this.current()?.timestamp != null); + + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); + + consumptionLabel = computed(() => { + const kw = this.consumptionKw(); + return kw != null ? CONSUMPTION_DEF.format(kw) : null; + }); + + consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current())); + + qualityLabel = computed(() => { + const quality = this.current()?.data_quality; + return quality ? LIBELLE_PAR_QUALITE[quality] : null; + }); + + qualityTone = computed(() => { + const quality = this.current()?.data_quality; + return quality ? TON_PAR_QUALITE[quality] : 'neutral'; + }); + + metrics = computed(() => { + const current = this.current(); + return METRIC_DEFS.map((def) => { + const valeur = current ? current[def.key] : null; + return { + key: def.key, + label: def.label, + value: valeur != null ? def.format(valeur) : null, + reason: valeur == null ? this.reasonFor(def.key, current) : '', + }; + }); + }); + + constructor() { + toObservable(this.siteId) + .pipe( + filter((siteId): siteId is string => !!siteId), + // Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un + // nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché. + switchMap((siteId) => this.load(siteId)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((result) => { + this.error.set(null); + this.site.set(result.site); + this.current.set(result.current); + this.history.set(result.history); + }); + } + + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string) { + return forkJoin({ + site: this.sitesService.getSite(siteId), + current: this.sitesService.getCurrent(siteId), + }).pipe( + switchMap(({ site, current }) => + this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))), + ), + catchError(() => this.reportUnavailable()), + ); + } + + private loadHistory(siteId: string, current: SiteCurrent): Observable { + // Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la + // dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide. + const end = current.timestamp; + if (end === null) { + return of([]); + } + const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString(); + return this.readingsService.getHistory(siteId, start, end); + } + + private reasonFor(field: MetricKey, current: SiteCurrent | null): string { + const raisons = RAISONS_PAR_CHAMP[field]; + const trouvees = (current?.null_reasons ?? []) + .filter((raison) => raisons.includes(raison)) + .map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison); + return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; + } + + // Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé + // sous le bandeau et laisse lire les chiffres de A en croyant regarder B. + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + this.site.set(null); + this.current.set(null); + this.history.set([]); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts new file mode 100644 index 0000000..63be883 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -0,0 +1,101 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { Chart } from 'chart.js'; +import { ReadingHistoryChart } from './reading-history-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + static instances: ChartMock[] = []; + static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data: { labels?: unknown[]; datasets: Record[] } = { datasets: [{}] }; + constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) { + if (config?.data) { + this.data = config.data; + } + ChartMock.instances.push(this); + } + } + return { Chart: ChartMock, registerables: [] }; +}); + +type ChartDouble = { + destroy: ReturnType; + data: { labels?: unknown[]; datasets: Record[] }; +}; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + +const READING = { + reading_id: 1, + site_id: 'S1', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_history' as const, + consumption_kw: 42, + consumption_kwh: null, + consumption_euros: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + solar_irradiance_wm2: null, + is_working_hours: null, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +describe('ReadingHistoryChart', () => { + it('se crée sans erreur avec une liste de lectures valide', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('met à jour le graphique quand les lectures changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const }, + ]); + fixture.detectChanges(); + + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + // L'API trie en timestamp décroissant : le composant doit rétablir la chronologie. + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 }, + { ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']); + expect(chart?.data.datasets[0]['data']).toEqual([42, 60]); + }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts new file mode 100644 index 0000000..17d1e06 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -0,0 +1,95 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { Reading, ReadingDataQuality } from '../../models/reading.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#3b82f6', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; +const UNKNOWN_QUALITY_COLOR = '#9ca3af'; + +interface ChartSeries { + labels: string[]; + values: number[]; + colors: string[]; +} + +// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history +// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours. +function toSeries(readings: Reading[]): ChartSeries { + const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return { + labels: ordered.map((r) => r.timestamp), + values: ordered.map((r) => r.consumption_kw ?? 0), + colors: ordered.map((r) => + r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR, + ), + }; +} + +@Component({ + selector: 'app-reading-history-chart', + standalone: true, + templateUrl: './reading-history-chart.html', + styleUrl: './reading-history-chart.scss', +}) +export class ReadingHistoryChart implements AfterViewInit, OnDestroy { + readings = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart<'line'>; + + constructor() { + effect(() => { + const series = toSeries(this.readings()); + if (this.chart) { + this.chart.data.labels = series.labels; + this.chart.data.datasets[0].data = series.values; + this.chart.data.datasets[0].pointBackgroundColor = series.colors; + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const series = toSeries(this.readings()); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'line', + data: { + labels: series.labels, + datasets: [ + { + data: series.values, + borderColor: '#3b82f6', + pointBackgroundColor: series.colors, + tension: 0.25, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } }, + }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} diff --git a/apps/frontend/src/app/shared/models/prediction.model.ts b/apps/frontend/src/app/shared/models/prediction.model.ts new file mode 100644 index 0000000..50d726a --- /dev/null +++ b/apps/frontend/src/app/shared/models/prediction.model.ts @@ -0,0 +1,24 @@ +export type PredictionStatus = 'available' | 'insufficient_data' | 'error'; +export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw'; + +export interface SitePrediction { + target_at: string; + target_metric: PredictionTargetMetric; + period_minutes: number | null; + predicted_value: number | null; + status: PredictionStatus; + failure_reason: string | null; + model_reference: string; + created_at: string; +} + +export interface SitePredictionSummary { + site_id: string; + site_name: string; + prediction: SitePrediction | null; +} + +export interface PredictionSummary { + timestamp: string; + sites: SitePredictionSummary[]; +} diff --git a/apps/frontend/src/app/shared/models/reading.model.ts b/apps/frontend/src/app/shared/models/reading.model.ts new file mode 100644 index 0000000..daba555 --- /dev/null +++ b/apps/frontend/src/app/shared/models/reading.model.ts @@ -0,0 +1,23 @@ +export type ReadingSource = 'csv' | 'api_current' | 'api_history'; +export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical'; + +export interface Reading { + reading_id: number; + site_id: string; + timestamp: string; + source: ReadingSource; + consumption_kw: number | null; + consumption_kwh: number | null; + consumption_euros: string | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + solar_irradiance_wm2: number | null; + is_working_hours: boolean | null; + data_quality: ReadingDataQuality | null; + null_reasons: string[] | null; + imputed_values: Record | null; + imputation_method: string | null; +} diff --git a/apps/frontend/src/app/shared/models/site-current.model.ts b/apps/frontend/src/app/shared/models/site-current.model.ts new file mode 100644 index 0000000..9f1e6f5 --- /dev/null +++ b/apps/frontend/src/app/shared/models/site-current.model.ts @@ -0,0 +1,16 @@ +import { ReadingDataQuality } from './reading.model'; + +export interface SiteCurrent { + timestamp: string | null; + site_id: string; + site_type: string; + consumption_kw: number | null; + consumption_kwh: number | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + null_reasons: string[]; + data_quality: ReadingDataQuality; +} diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts index 8409d18..3c946c9 100644 --- a/apps/frontend/src/environments/environment.development.ts +++ b/apps/frontend/src/environments/environment.development.ts @@ -1,5 +1,5 @@ export const environment = { production: false, apiUrl: '/api/v1', - useMockFixtures: true, // a passer a false une fois le backend prêt + useMockFixtures: false, }; diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index e2b68b1..a650a1e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,10 +74,10 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| -| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | -| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index ac14fb1..09d3b3d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, sensors"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors, predictions"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, SensorService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] md["models
10 tables"] db[("PostgreSQL")] @@ -149,19 +149,22 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | +| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier `openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. -**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. +**Sept routes du contrat sont publiques** : les deux sondes, `/auth/login`, `/auth/logout`, +`/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans +le jeton à usage unique plutôt que dans un `Principal`. `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose -donc de modifier la liste dans ce fichier de test. +donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`. `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour -`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches +`GET /alerts` puis pour les suivantes (`dataset`) : les quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une @@ -179,6 +182,18 @@ dernière `Reading` du site : un site connu sans lecture rend `200` avec tous le détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). +`GET /predictions` reprend ce même sous-gabarit « dernière valeur par site » (`SiteRepository` + +`PredictionRepository`, un `SitePredictionSummaryResponse` par site plutôt qu'une table brute). +Différence avec `stats`/`sensors` : `prediction` est une vraie table accumulée par un processus +externe (`enervision_ml.score`, cf. `ml/README.md`), pas une valeur recalculée à la volée depuis +`reading` à chaque appel. `PredictionRepository.latest_by_site()` isole donc un `DISTINCT ON +(site_id)` ordonné par `target_at DESC` (couvert par l'index `ix_prediction_site_target`), le même +mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend `prediction: null` +plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte +`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais +LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. +[ML-START.md](../../ML-START.md) section 3. + `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni @@ -267,17 +282,22 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv ### Ajouter une route métier Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ -`readings`/`sensors` (`dataset`, `prediction`) : +`readings`/`sensors`/`predictions` (`dataset`) : 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux (404, 409, ...) directement sur l'endpoint qui les rend. 2. Décrire son tag dans `TAGS`. -3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à - `ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, - l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : - une route oubliée n'y est pas détectée automatiquement. -4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. +3. **La classer dans `tests/api/acces.py`** : `ROLE_MINIMUM` avec son rôle minimum si elle passe + par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), `ROUTES_SANS_ROLE` si elle se + contente de `CurrentPrincipalDep`, `ROUTES_PUBLIQUES` si elle est ouverte. L'oubli n'est plus + silencieux : `test_every_declared_route_is_classified` échoue sur une route non classée comme + sur une entrée qui ne correspond plus à aucune route. `ROUTES_A_ROLE` de `test_openapi.py` en + est dérivée, et `test_matrice_acces.py` vérifie le niveau réellement monté. +4. Si elle passe par `require_trusted_origin`, l'ajouter à `ORIGINE_VERIFIEE` dans + `tests/api/test_openapi.py`. **Cette liste-là reste maintenue à la main.** +5. `make openapi`, puis `uv run pytest tests/api/test_openapi.py tests/api/test_route_protection.py + tests/api/test_matrice_acces.py`. ## Sécurité @@ -328,9 +348,14 @@ Le reste, par ordre de surface : Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). -Trois fichiers méritent d'être connus avant de toucher à l'authentification : +Quatre fichiers méritent d'être connus avant de toucher à l'authentification : +- `tests/api/acces.py` : la classification des routes, `ROUTES_PUBLIQUES` et `ROLE_MINIMUM` en + tête. Ce n'est pas un test, c'est la référence que les deux suivants confrontent au + comportement observé. - `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. +- `tests/api/test_matrice_acces.py` : chaque route gardée croisée avec chacun des trois rôles, + dans les deux sens, puis rejouée sous `integration` avec de vrais jetons. - `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro appel quand la limite est atteinte. diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index de73599..fb10f92 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -13,24 +13,29 @@ Ce qui est en place : - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et `provideHttpClient(withInterceptors([mockApiInterceptor]))`. - Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le +- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et + `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification + (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, + `features/dashboard` la page principale, `shared/components` la jauge de consommation et le graphique de charge par site, tous deux construits sur Chart.js. +- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, + changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit réutiliser plutôt que redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. +- Vitest via le builder `@angular/build:unit-test`, couverture activée. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. -- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que - les routes métier de l'API en exigent un. Voir - [31-contrat-authentification.md](31-contrat-authentification.md). +- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien + ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. + `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette + PR (voir plus bas). - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -84,19 +89,19 @@ sequenceDiagram `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il +suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide +et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. -En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue -le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. - -**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, -pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : -telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier -déploiement, en même temps que sera tranchée la question de l'ingress dans -[10-infra.md](10-infra.md). +En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif +(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur +`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...` +sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le +backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md). ## Exécution @@ -124,9 +129,10 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun - intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui - que les fixtures. +- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et + `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le + rafraîchissement sur 401. Détail complet dans + [31-contrat-authentification.md](31-contrat-authentification.md). ## Tests diff --git a/ml/README.md b/ml/README.md index 4d69363..c7814fe 100644 --- a/ml/README.md +++ b/ml/README.md @@ -57,6 +57,46 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs lags/moyennes glissantes, une fuite qui masquerait un surapprentissage. +## Scoring + +```bash +uv run python -m enervision_ml.score --csv data/all_sites_combined.csv +# ou, une fois la base peuplee et ML_DATABASE_URL positionnee : +uv run python -m enervision_ml.score +``` + +Calcule, pour chaque site (ou un seul avec `--site-id`), la consommation prevue de l'heure suivant +sa derniere lecture connue, et ecrit une ligne dans `prediction`. Etapes, cf. `ML-START.md` +section 2 : + +1. Lit une fenetre recente de `reading`+`site` (21 jours par defaut, une marge au-dessus des 168h + necessaires au lag hebdomadaire) plutot que tout l'historique -- le meme piege que celui deja + corrige sur `GET /readings` (fenetre non plafonnee sur une hypertable). +2. Ajoute une ligne "future" par site (l'heure suivante) et calcule ses features avec + `enervision_ml.features.build_features`, **exactement** la meme fonction qu'a l'entrainement. +3. Si le lag de 168h est absent (moins d'une semaine d'historique pour ce site) : ecrit + `status="insufficient_data"` directement, sans jamais appeler LightGBM. +4. Sinon : appelle `booster.predict(...)` et ecrit `status="available"` avec la valeur predite. + +`--model` pointe vers le fichier entraine (`models/lightgbm-consumption.txt` par defaut). +`model_reference` en base est le hache SHA-256 (tronque) du fichier modele, pas son nom de +fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne +distinguerait pas deux versions du modele. + +En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future" +calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le +pipeline sans base joignable. + +**Limite assumee** : la feature `is_working_hours` de la ligne future est recopiee depuis la +derniere lecture reelle, pas recalculee -- il n'existe aucune regle horaire ouvrable dans ce +depot (elle vit dans le generateur du jeu de donnees d'origine). L'approximation n'est fausse +qu'aux heures de bascule ouverture/fermeture, sur une seule feature parmi une dizaine, pour une +prevision a un seul pas. + +`prediction` n'a pas de contrainte d'unicite sur `(site_id, target_at)` : chaque run de scoring +insere une nouvelle ligne plutot que d'ecraser la precedente, pour garder une trace de chaque +prevision (utile plus tard pour comparer prevision et realise, surveillance de derive #44/#45). + ## Commandes ```bash @@ -81,8 +121,14 @@ environnement de developpement pour le moment. ## Piege a connaitre `enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features -du modele, a l'entrainement comme au futur scoring (service #37, pas encore construit). Si les -deux divergent meme legerement (une fenetre de moyenne glissante calculee differemment, par -exemple), le modele recoit en production des features qui ne ressemblent plus a ce qu'il a -appris, et ses predictions deviennent silencieusement mauvaises sans qu'aucune erreur ne se -declenche. Ne jamais reecrire cette logique ailleurs : importer `enervision_ml.features`. +du modele, a l'entrainement comme au scoring (`enervision_ml.score`). Si les deux divergent meme +legerement (une fenetre de moyenne glissante calculee differemment, par exemple), le modele +recoit en production des features qui ne ressemblent plus a ce qu'il a appris, et ses predictions +deviennent silencieusement mauvaises sans qu'aucune erreur ne se declenche. Ne jamais reecrire +cette logique ailleurs : importer `enervision_ml.features`. + +## Et cote API ? + +`GET /api/v1/predictions` (backend, `apps/backend`) lit ce que `enervision_ml.score` a ecrit dans +`prediction` -- la derniere prevision par site, jamais un recalcul a la volee. FastAPI ne fait +jamais tourner LightGBM lui-meme, cf. `ML-START.md` section 3. diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index e7b5853..f8e7bf9 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -16,6 +16,7 @@ Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`, colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante. """ +from datetime import datetime from pathlib import Path import pandas as pd @@ -34,6 +35,14 @@ OUTPUT_COLUMNS = [ "capacity_kw", ] +NUMERIC_COLUMNS = [ + "consumption_kwh", + "temperature_celsius", + "humidity_percent", + "solar_irradiance_wm2", + "capacity_kw", +] + _READING_QUERY = text( """ SELECT @@ -53,10 +62,43 @@ _READING_QUERY = text( ) +_RECENT_READING_QUERY = text( + """ + SELECT + r.site_id, + r.timestamp, + r.consumption_kwh, + r.temperature_celsius, + r.humidity_percent, + r.solar_irradiance_wm2, + r.is_working_hours, + s.site_type, + s.capacity_kw + FROM reading r + JOIN site s ON s.site_id = r.site_id + WHERE r.timestamp >= :since + ORDER BY r.site_id, r.timestamp + """ +) + + def load_from_database(connection: Connectable) -> pd.DataFrame: - """Lit l'historique complet `reading` + `site` depuis PostgreSQL.""" + """Lit l'historique complet `reading` + `site` depuis PostgreSQL. Entrainement seulement : + le scoring n'a besoin que d'une fenetre recente, cf. `load_recent_from_database`. + """ frame = pd.read_sql(_READING_QUERY, connection) - return frame[OUTPUT_COLUMNS] + return _typer(frame[OUTPUT_COLUMNS]) + + +def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame: + """Lit `reading` + `site` depuis `since` seulement, pour le scoring. + + Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain + pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non + plafonnee sur une table pouvant porter des annees d'historique). + """ + frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since}) + return _typer(frame[OUTPUT_COLUMNS]) def load_from_csv(csv_path: Path) -> pd.DataFrame: @@ -65,4 +107,25 @@ def load_from_csv(csv_path: Path) -> pd.DataFrame: frame["capacity_kw"] = float("nan") frame["is_working_hours"] = frame["is_working_hours"].astype(bool) - return frame[OUTPUT_COLUMNS] + return _typer(frame[OUTPUT_COLUMNS]) + + +def _typer(frame: pd.DataFrame) -> pd.DataFrame: + """Force le typage numerique attendu par LightGBM. + + Piege reel, pas theorique : `site.capacity_kw` n'est peuple par aucun pipeline d'ingestion + aujourd'hui (`historical_import.py` ne pose que `site_type`/`site_name`). Une colonne + entierement `NULL` revient de `pd.read_sql` en dtype `object` plutot que `float64`, ce que + LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi + n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas + seulement `capacity_kw`. + + Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas + seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement + `NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au + scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees. + """ + typee = frame.copy() + for colonne in NUMERIC_COLUMNS: + typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce") + return typee diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py new file mode 100644 index 0000000..c0b7e3f --- /dev/null +++ b/ml/enervision_ml/score.py @@ -0,0 +1,318 @@ +"""Scoring du modele LightGBM : calcule et enregistre la consommation prevue du prochain pas +horaire, par site. + +CLI autonome, sur le meme gabarit que `enervision_ml.train` et +`apps/backend/app/etl/historical_import.py`. Cf. `docs/ML-START.md`, section 2. + + uv run python -m enervision_ml.score --csv ../ml/data/all_sites_combined.csv + uv run python -m enervision_ml.score # lit ML_DATABASE_URL, ecrit dans `prediction` + +Reutilise `enervision_ml.features.build_features` tel quel (jamais reecrit) : c'est la garantie +contre le train/serve skew documentee dans ce module. +""" + +import argparse +import hashlib +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, cast + +import lightgbm as lgb +import pandas as pd +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Connection + +from enervision_ml import config +from enervision_ml.data import load_from_csv, load_recent_from_database +from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_features, feature_columns + +# Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure. +LOOKBACK = timedelta(days=21) + +# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure +# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont +# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait +# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard) +# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours. +MAX_STALENESS = timedelta(hours=24) + +TARGET_METRIC = "consumption_kwh" +PERIOD_MINUTES = 60 +LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" +INSUFFICIENT_DATA_REASON = ( + "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site." +) + + +def _stale_reason(age: pd.Timedelta) -> str: + return ( + f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h " + f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement " + "arrêtée pour ce site." + ) + + +@dataclass(frozen=True, slots=True) +class ScoredSite: + site_id: str + target_at: datetime + status: str + predicted_value: float | None + failure_reason: str | None + + +def model_reference(model_path: Path) -> str: + """Identifiant stable du modele utilise, insensible au fait que `train.py` reecrive + toujours le meme nom de fichier a chaque entrainement (pas de versioning par nom, cf. + `ml/README.md`).""" + empreinte = hashlib.sha256(model_path.read_bytes()).hexdigest() + return f"lightgbm-{empreinte[:12]}" + + +def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> pd.DataFrame: + """Ajoute une ligne future (l'heure suivant la derniere lecture connue) par site, et calcule + ses features par `build_features` -- exactement comme a l'entrainement, seule la cible de + cette ligne est inconnue. + + Piege assume : `is_working_hours` de la ligne future est copie de la derniere lecture reelle, + pas recalcule. Il n'existe aucune regle horaire ouvrable dans ce depot (elle vit dans le + generateur du jeu de donnees d'origine, hors de ce code) ; l'approximation n'est fausse + qu'aux heures de bascule (ouverture/fermeture), sur une seule feature parmi une dizaine, pour + une prevision a un pas seulement. + """ + travail = recent if site_id is None else recent[recent["site_id"] == site_id] + if travail.empty: + return build_features(travail) + + dernieres = ( + travail.sort_values("timestamp").groupby("site_id", as_index=False, sort=False).tail(1) + ).copy() + dernieres["timestamp"] = dernieres["timestamp"] + pd.Timedelta(hours=1) + dernieres[TARGET_COLUMN] = float("nan") + # Meteo future inconnue (cf. piege documente dans `enervision_ml.features.build_features`) : + # laisser `NaN` ici n'a aucun effet sur les features utilisees, qui ne prennent la meteo que + # decalee. + for colonne in WEATHER_COLUMNS: + dernieres[colonne] = float("nan") + + etendu = pd.concat([travail, dernieres], ignore_index=True) + features = build_features(etendu) + return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True) + + +def score( + booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime +) -> list[ScoredSite]: + resultats: list[ScoredSite] = [] + + # `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) : + # on en deduit l'age de cette derniere lecture par rapport a `instant`. + travail = scoring_frame.copy() + travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1)) + + perimes = travail[travail["_age"] > MAX_STALENESS] + for enregistrement in _records(perimes): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=_stale_reason(enregistrement["_age"]), + ) + ) + + a_jour = travail[travail["_age"] <= MAX_STALENESS] + + insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()] + for enregistrement in _records(insuffisants): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=INSUFFICIENT_DATA_REASON, + ) + ) + + suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()] + if not suffisants.empty: + typee = suffisants.copy() + typee["site_type"] = typee["site_type"].astype("category") + predictions = booster.predict(typee[feature_columns()]) + for enregistrement, valeur in zip(_records(suffisants), predictions, strict=True): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="available", + predicted_value=float(valeur), + failure_reason=None, + ) + ) + + return resultats + + +def _records(frame: pd.DataFrame) -> list[dict[str, Any]]: + return cast(list[dict[str, Any]], frame.to_dict(orient="records")) + + +_INSERT_PREDICTION = text( + """ + INSERT INTO prediction ( + site_id, target_at, target_metric, period_minutes, + predicted_value, model_reference, status, failure_reason + ) VALUES ( + :site_id, :target_at, :target_metric, :period_minutes, + :predicted_value, :model_reference, :status, :failure_reason + ) + """ +) + + +def write_predictions( + connection: Connection, resultats: list[ScoredSite], *, reference: str +) -> None: + """Ecrit une ligne par site score. Insertion seule, jamais de mise a jour : `prediction` + n'a pas de contrainte d'unicite sur `(site_id, target_at)`, chaque run garde sa propre trace + plutot que d'ecraser la precedente -- utile plus tard pour comparer prevision et realise + (surveillance de derive, #44/#45).""" + if not resultats: + return + + lignes = [ + { + "site_id": r.site_id, + "target_at": r.target_at, + "target_metric": TARGET_METRIC, + "period_minutes": PERIOD_MINUTES, + "predicted_value": r.predicted_value, + "model_reference": reference, + "status": r.status, + "failure_reason": r.failure_reason, + } + for r in resultats + ] + connection.execute(_INSERT_PREDICTION, lignes) + + +def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]: + brute = load_from_csv(csv_path) + instant = now or ( + brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) + ) + return brute[brute["timestamp"] >= instant - LOOKBACK], instant + + +def _score_frame( + recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime +) -> list[ScoredSite]: + scoring_frame = build_scoring_frame(recent, site_id=site_id) + if scoring_frame.empty: + return [] + + booster = lgb.Booster(model_file=str(model_path)) + return score(booster, scoring_frame, instant=instant) + + +def run_scoring( + *, + model_path: Path, + csv_path: Path | None = None, + site_id: str | None = None, + now: datetime | None = None, +) -> list[ScoredSite]: + """Score le prochain pas horaire par site et l'ecrit dans `prediction`. + + En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future" + calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base + joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans + `build_scoring_frame` : le filtrer aussi ici serait redondant. + """ + if csv_path is not None: + recent, instant = _load_recent_from_csv(csv_path, now=now) + return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) + + # Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape. + engine = create_engine(config.database_url()) + try: + instant = now or datetime.now(UTC) + recent = load_recent_from_database(engine, since=instant - LOOKBACK) + resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) + + reference = model_reference(model_path) + with engine.begin() as connection: + write_predictions(connection, resultats, reference=reference) + + return resultats + finally: + engine.dispose() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Scoring du modele LightGBM EnerVision") + + parser.add_argument( + "--model", + type=Path, + default=Path("models/lightgbm-consumption.txt"), + help="Chemin du modele entraine. Defaut : models/lightgbm-consumption.txt.", + ) + parser.add_argument( + "--csv", + type=Path, + default=None, + help=( + "Instantane historique de demarrage/demo, rien n'est ecrit en base. Omis, lit " + "ML_DATABASE_URL, se connecte a PostgreSQL et ecrit dans `prediction`." + ), + ) + parser.add_argument( + "--site-id", + default=None, + help="Ne score que ce site. Omis, tous les sites presents dans la fenetre recente.", + ) + parser.add_argument( + "--now", + type=_parse_instant, + default=None, + help=( + "Instant de reference (ISO 8601), pour tester ou demontrer le scoring cote base sur " + "des donnees anciennes (ex. le jeu de donnees historique, qui s'arrete fin 2024). " + "Omis, horloge systeme reelle." + ), + ) + + return parser.parse_args() + + +def _parse_instant(valeur: str) -> datetime: + instant = datetime.fromisoformat(valeur) + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) + + +def main() -> None: + args = parse_args() + resultats = run_scoring( + model_path=args.model, csv_path=args.csv, site_id=args.site_id, now=args.now + ) + + if not resultats: + print("Aucun site a scorer (aucune lecture recente dans la fenetre).") + return + + for r in resultats: + if r.status == "available": + print(f"{r.site_id} @ {r.target_at} : {r.predicted_value:.2f} kWh") + else: + print(f"{r.site_id} @ {r.target_at} : {r.status} ({r.failure_reason})") + + if args.csv is not None: + print("\nMode --csv : instantane historique, rien ecrit en base.") + + +if __name__ == "__main__": + main() diff --git a/ml/pyproject.toml b/ml/pyproject.toml index b3c368b..1589c92 100644 --- a/ml/pyproject.toml +++ b/ml/pyproject.toml @@ -47,9 +47,9 @@ select = [ "S", "PT", ] -# N806 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable mal -# nommee. -ignore = ["B008", "N806"] +# N806/N803 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable +# ou un argument mal nomme. +ignore = ["B008", "N806", "N803"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["S101"] diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py new file mode 100644 index 0000000..0aa42a1 --- /dev/null +++ b/ml/tests/test_data.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pandas as pd + +from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv + +_CSV_HEADER = ( + "site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent," + "solar_irradiance_wm2,is_working_hours,site_type" +) + + +def write_csv(tmp_path: Path, *lignes: str) -> Path: + csv_path = tmp_path / "recent.csv" + csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n") + return csv_path + + +def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None: + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") + + frame = load_from_csv(csv_path) + + for colonne in NUMERIC_COLUMNS: + assert frame[colonne].dtype == "float64" + + +def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None: + # Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type + # alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de + # divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement + # `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui). + csv_path = write_csv( + tmp_path, + "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office", + "SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office", + ) + + frame = load_from_csv(csv_path) + + assert frame["consumption_kwh"].dtype == "float64" + assert frame["consumption_kwh"].iloc[0] == 10.5 + assert pd.isna(frame["consumption_kwh"].iloc[1]) + + +def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None: + # `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette + # affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test + # garde le contrat visible malgre tout, au cas ou l'implementation changerait. + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") + + frame = load_from_csv(csv_path) + + assert frame["capacity_kw"].dtype == "float64" + assert pd.isna(frame["capacity_kw"].iloc[0]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py new file mode 100644 index 0000000..fb3e015 --- /dev/null +++ b/ml/tests/test_score.py @@ -0,0 +1,277 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pandas as pd +import pytest + +from enervision_ml.features import TARGET_COLUMN +from enervision_ml.score import ( + LAG_168H_COLUMN, + MAX_STALENESS, + ScoredSite, + build_scoring_frame, + model_reference, + run_scoring, + score, + write_predictions, +) + + +def make_recent( + site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0 +) -> pd.DataFrame: + instants = [depart + timedelta(hours=h) for h in range(heures)] + return pd.DataFrame( + { + "site_id": site_id, + "timestamp": instants, + TARGET_COLUMN: [valeur + h for h in range(heures)], + "temperature_celsius": 15.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "is_working_hours": True, + "site_type": "office", + "capacity_kw": 100.0, + } + ) + + +class FakeBooster: + def __init__(self, valeur: float = 42.0) -> None: + self.valeur = valeur + self.appels: list[int] = [] + + def predict(self, X: Any) -> list[float]: + self.appels.append(len(X)) + return [self.valeur] * len(X) + + +class FakeConnection: + def __init__(self) -> None: + self.appels: list[tuple[Any, Any]] = [] + + def execute(self, statement: Any, parameters: Any = None) -> None: + self.appels.append((statement, parameters)) + + +def test_build_scoring_frame_adds_one_row_per_site_one_hour_after_the_last_reading() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = pd.concat( + [ + make_recent("site-a", heures=200, depart=depart), + make_recent("site-b", heures=200, depart=depart), + ], + ignore_index=True, + ) + + scoring_frame = build_scoring_frame(recent) + + assert set(scoring_frame["site_id"]) == {"site-a", "site-b"} + derniere_lecture = depart + timedelta(hours=199) + assert (scoring_frame["timestamp"] == derniere_lecture + timedelta(hours=1)).all() + + +def test_build_scoring_frame_computes_lags_from_real_history() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = make_recent("site-a", heures=200, depart=depart) + + scoring_frame = build_scoring_frame(recent) + + ligne = scoring_frame.iloc[0] + # La cible future n'existe pas : le lag d'1h doit valoir la toute derniere valeur reelle. + assert ligne[f"{TARGET_COLUMN}_lag_1h"] == recent[TARGET_COLUMN].iloc[-1] + + +def test_build_scoring_frame_flags_insufficient_history_under_168_hours() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = make_recent("site-a", heures=100, depart=depart) + + scoring_frame = build_scoring_frame(recent) + + assert pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN]) + + +def test_build_scoring_frame_accepts_a_full_week_of_history() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = make_recent("site-a", heures=169, depart=depart) + + scoring_frame = build_scoring_frame(recent) + + assert not pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN]) + + +def test_build_scoring_frame_filters_to_a_single_site() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = pd.concat( + [ + make_recent("site-a", heures=200, depart=depart), + make_recent("site-b", heures=200, depart=depart), + ], + ignore_index=True, + ) + + scoring_frame = build_scoring_frame(recent, site_id="site-a") + + assert scoring_frame["site_id"].tolist() == ["site-a"] + + +def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> None: + recent = make_recent("site-a", heures=0, depart=datetime(2026, 1, 1, tzinfo=UTC)) + + scoring_frame = build_scoring_frame(recent) + + assert scoring_frame.empty + + +def target_at_for(depart: datetime, heures: int) -> datetime: + """`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture + + 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption, + pour les tests qui ne visent pas ce filtre.""" + return depart + timedelta(hours=heures) + + +def test_score_marks_insufficient_history_without_calling_the_model() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart)) + booster = FakeBooster() + + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 100), + ) + + assert resultats == [ + ScoredSite( + site_id="site-a", + target_at=resultats[0].target_at, + status="insufficient_data", + predicted_value=None, + failure_reason=resultats[0].failure_reason, + ) + ] + assert booster.appels == [] + + +def test_score_predicts_when_history_is_sufficient() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + booster = FakeBooster(valeur=99.5) + + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 200), + ) + + assert len(resultats) == 1 + assert resultats[0].status == "available" + assert resultats[0].predicted_value == 99.5 + assert resultats[0].failure_reason is None + assert booster.appels == [1] + + +def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + # Historique largement suffisant (168h+), mais l'instant de reference est loin apres la + # derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique. + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1) + booster = FakeBooster() + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert len(resultats) == 1 + assert resultats[0].status == "insufficient_data" + assert resultats[0].predicted_value is None + assert "vieille" in (resultats[0].failure_reason or "") + assert booster.appels == [] + + +def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + # `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure + # pour retomber exactement sur le seuil, ni en dessous ni au dessus. + instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1) + booster = FakeBooster(valeur=12.0) + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert resultats[0].status == "available" + assert booster.appels == [1] + + +def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None: + connection = FakeConnection() + + write_predictions(connection, [], reference="lightgbm-test") # type: ignore[arg-type] + + assert connection.appels == [] + + +def test_write_predictions_sends_one_row_per_result() -> None: + connection = FakeConnection() + resultats = [ + ScoredSite("site-a", datetime(2026, 1, 1, tzinfo=UTC), "available", 42.0, None), + ScoredSite( + "site-b", + datetime(2026, 1, 1, tzinfo=UTC), + "insufficient_data", + None, + "pas assez d'historique", + ), + ] + + write_predictions(connection, resultats, reference="lightgbm-test") # type: ignore[arg-type] + + assert len(connection.appels) == 1 + _, lignes = connection.appels[0] + assert len(lignes) == 2 + assert lignes[0]["model_reference"] == "lightgbm-test" + assert lignes[0]["target_metric"] == "consumption_kwh" + assert lignes[0]["period_minutes"] == 60 + + +def test_model_reference_is_stable_for_the_same_file_content(tmp_path: Path) -> None: + model_path = tmp_path / "model.txt" + model_path.write_bytes(b"contenu-du-modele") + + assert model_reference(model_path) == model_reference(model_path) + + +def test_model_reference_changes_with_the_file_content(tmp_path: Path) -> None: + premier = tmp_path / "model-a.txt" + premier.write_bytes(b"version-1") + second = tmp_path / "model-b.txt" + second.write_bytes(b"version-2") + + assert model_reference(premier) != model_reference(second) + + +def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Path) -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + frame = pd.concat( + [ + make_recent("site-a", heures=400, depart=depart), + make_recent("site-b", heures=400, depart=depart), + ], + ignore_index=True, + ) + csv_path = tmp_path / "recent.csv" + frame.to_csv(csv_path, index=False) + + model_path = tmp_path / "model.txt" + model_path.write_bytes(b"peu importe le contenu pour ce test") + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0) + ) + + resultats = run_scoring(model_path=model_path, csv_path=csv_path) + + assert {r.site_id for r in resultats} == {"site-a", "site-b"} + assert all(r.status == "available" for r in resultats) + assert all(r.predicted_value == 7.0 for r in resultats) diff --git a/sonar-project.properties b/sonar-project.properties index 7155d42..49c6abe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,8 +2,16 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -sonar.sources=apps/frontend/src,apps/backend/app +# Dossier contenant le code source +sonar.sources=apps/frontend/src,apps/backend +# Dossier contenant les tests +sonar.tests=apps/frontend/src,apps/backend/tests +sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py -sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json +# Liste des fichiers et dossiers à exclure de l'analyse +sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts +# Chemin vers le rapport de couverture de code +# Fichier généré par Pytest +sonar.python.coverage.reportPaths=apps/backend/coverage.xml sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info