Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d535959fbe | ||
|
|
33807e3038 | ||
|
|
c914d41f33 | ||
|
|
d3047f53e8 | ||
|
|
ec63798807 | ||
|
|
1e168ef03e | ||
|
|
ddf7e17788 | ||
|
|
56e8f95729 | ||
|
|
91d435748c | ||
|
|
0b41580310 | ||
|
|
e3d436ea53 | ||
|
|
c453700b13 | ||
|
|
f427f8a8f3 | ||
|
|
7be8a44e89 | ||
|
|
2095ad6bd3 | ||
|
|
fb06bf0062 | ||
|
|
e27142c7db | ||
|
|
77feabcbad | ||
|
|
18a4be6e38 | ||
|
|
12fb8860d1 | ||
|
|
7c2936f2ef | ||
|
|
9d3e402ca4 | ||
|
|
7db57f162c | ||
|
|
9ccc603d88 | ||
|
|
297d85a0ca | ||
|
|
af58172742 | ||
|
|
b433e01fa8 | ||
|
|
5eb74aa64a | ||
|
|
cc0a58ac4c | ||
|
|
2400b6f05e | ||
|
|
9e33c276d6 | ||
|
|
6cb9ac00cb | ||
|
|
c1f63889c1 | ||
|
|
5875e8c239 | ||
|
|
c8383014a8 | ||
|
|
3cf9194d4c | ||
|
|
8def1e23af | ||
|
|
e26948932e | ||
|
|
41acdb54b6 | ||
|
|
5d921a9b1e | ||
|
|
56c134beb0 | ||
|
|
19cfac1cff | ||
|
|
1fce577a78 | ||
|
|
1afaee069f | ||
|
|
7bc9a09489 | ||
|
|
4ee2109628 | ||
|
|
334ca5982b | ||
|
|
2465021d61 | ||
|
|
278299c2b1 | ||
|
|
1cca4f130c | ||
|
|
ed7311d4ef | ||
|
|
ff68a51424 | ||
|
|
11f9b1bcd5 | ||
|
|
41c18a3bb1 | ||
|
|
00ef725249 | ||
|
|
34f35f3ca0 | ||
|
|
f5cac1c2a8 | ||
|
|
8e07168a5e | ||
|
|
d167b64188 | ||
|
|
2f97e4d434 | ||
|
|
07ea8d21dc |
@@ -56,3 +56,27 @@ jobs:
|
||||
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
|
||||
- name: Tests et couverture
|
||||
run: uv run pytest --cov-fail-under=85
|
||||
|
||||
security-audit:
|
||||
name: Audit des dépendances
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/backend
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
|
||||
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
|
||||
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
|
||||
- name: Audite les dépendances livrées
|
||||
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
||||
shell: bash
|
||||
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
name: Frontend
|
||||
# Pipeline à choix multiple
|
||||
|
||||
on:
|
||||
# workflow_dispatch -> lancement manuel des jobs
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
job_choice:
|
||||
required: true
|
||||
description: "Choix du job"
|
||||
type: choice
|
||||
default: all
|
||||
options:
|
||||
- build
|
||||
- sonarqube
|
||||
- test
|
||||
- all # lancer tous les jobs
|
||||
push:
|
||||
paths:
|
||||
- "apps/frontend/**"
|
||||
@@ -23,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:
|
||||
@@ -36,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:
|
||||
@@ -52,26 +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
|
||||
- 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
|
||||
|
||||
sonarqube:
|
||||
needs: [build, test]
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
|
||||
# deploy:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - run: echo "DEPLOY job is running"
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend/lcov.info
|
||||
|
||||
@@ -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 }}
|
||||
@@ -171,7 +171,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
def get_site_service(session: SessionDep) -> SiteService:
|
||||
return SiteService(sites=SiteRepository(session))
|
||||
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
|
||||
|
||||
|
||||
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
|
||||
from app.api.deps import LecteurDep, SiteServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.site import SiteResponse
|
||||
from app.schemas.site import SiteCurrentResponse, SiteResponse
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
@@ -34,3 +34,19 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
|
||||
) from erreur
|
||||
return SiteResponse.model_validate(site)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}/current",
|
||||
response_model=SiteCurrentResponse,
|
||||
summary="Dernière mesure d'un site",
|
||||
responses=REPONSES_INTROUVABLE,
|
||||
)
|
||||
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
|
||||
try:
|
||||
actuel = await service.current(site_id)
|
||||
except SiteNotFoundError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
|
||||
) from erreur
|
||||
return SiteCurrentResponse.model_validate(actuel)
|
||||
|
||||
@@ -13,14 +13,27 @@ class ReadingRepository:
|
||||
|
||||
async def latest_by_site(self) -> Sequence[Reading]:
|
||||
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
|
||||
# ligne par site, la plus récente grâce à l'ordre composite qui suit.
|
||||
# ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
|
||||
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
|
||||
requete = (
|
||||
select(Reading)
|
||||
.distinct(Reading.site_id)
|
||||
.order_by(Reading.site_id, Reading.timestamp.desc())
|
||||
.order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
|
||||
)
|
||||
return (await self._session.execute(requete)).scalars().all()
|
||||
|
||||
async def latest_for_site(self, site_id: str) -> Reading | None:
|
||||
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
|
||||
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
|
||||
requete = (
|
||||
select(Reading)
|
||||
.where(Reading.site_id == site_id)
|
||||
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
lecture: Reading | None = await self._session.scalar(requete)
|
||||
return lecture
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
@@ -10,3 +13,20 @@ class SiteResponse(BaseModel):
|
||||
location: str | None
|
||||
capacity_kw: float | None
|
||||
status: str | None
|
||||
|
||||
|
||||
class SiteCurrentResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
timestamp: datetime | None
|
||||
site_id: str
|
||||
site_type: str
|
||||
consumption_kw: float | None
|
||||
consumption_kwh: float | None
|
||||
voltage_v: float | None
|
||||
current_a: float | None
|
||||
power_factor: float | None
|
||||
temperature_celsius: float | None
|
||||
humidity_percent: float | None
|
||||
null_reasons: list[str]
|
||||
data_quality: Literal["good", "partial", "degraded", "critical"]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat
|
||||
# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur
|
||||
# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES`
|
||||
# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli.
|
||||
|
||||
from typing import Literal, get_args
|
||||
|
||||
DataQuality = Literal["good", "partial", "degraded", "critical"]
|
||||
|
||||
QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality))
|
||||
|
||||
_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)}
|
||||
|
||||
|
||||
def qualite_ou_critique(valeur: str | None) -> DataQuality:
|
||||
if valeur is None:
|
||||
return "critical"
|
||||
return _PAR_VALEUR.get(valeur, "critical")
|
||||
@@ -5,12 +5,11 @@ from typing import Literal
|
||||
from app.models.energy import Reading, Site
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
from app.services.data_quality import qualite_ou_critique
|
||||
|
||||
CapteurStatus = Literal["ok", "failing"]
|
||||
OverallStatus = Literal["ok", "degraded", "critical"]
|
||||
|
||||
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
|
||||
|
||||
RAISON_VERS_CAPTEUR: dict[str, str] = {
|
||||
"consumption_sensor_failure": "consumption",
|
||||
"electrical_sensor_failure": "electrical",
|
||||
@@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
|
||||
overall="critical",
|
||||
)
|
||||
|
||||
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
|
||||
qualite = qualite_ou_critique(derniere.data_quality)
|
||||
overall = _overall_depuis_qualite(qualite)
|
||||
|
||||
if overall == "critical":
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
from app.services.data_quality import DataQuality, qualite_ou_critique
|
||||
|
||||
|
||||
class SiteError(Exception):
|
||||
@@ -12,9 +16,26 @@ class SiteNotFoundError(SiteError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SiteCurrentReading:
|
||||
timestamp: datetime | None
|
||||
site_id: str
|
||||
site_type: str
|
||||
consumption_kw: float | None
|
||||
consumption_kwh: float | None
|
||||
voltage_v: float | None
|
||||
current_a: float | None
|
||||
power_factor: float | None
|
||||
temperature_celsius: float | None
|
||||
humidity_percent: float | None
|
||||
null_reasons: list[str]
|
||||
data_quality: DataQuality
|
||||
|
||||
|
||||
class SiteService:
|
||||
def __init__(self, *, sites: SiteRepository) -> None:
|
||||
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
|
||||
self._sites = sites
|
||||
self._readings = readings
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
return await self._sites.list_all()
|
||||
@@ -24,3 +45,38 @@ class SiteService:
|
||||
if site is None:
|
||||
raise SiteNotFoundError(site_id)
|
||||
return site
|
||||
|
||||
async def current(self, site_id: str) -> SiteCurrentReading:
|
||||
site = await self.get_by_id(site_id)
|
||||
derniere = await self._readings.latest_for_site(site_id)
|
||||
|
||||
if derniere is None:
|
||||
return SiteCurrentReading(
|
||||
timestamp=None,
|
||||
site_id=site.site_id,
|
||||
site_type=site.site_type,
|
||||
consumption_kw=None,
|
||||
consumption_kwh=None,
|
||||
voltage_v=None,
|
||||
current_a=None,
|
||||
power_factor=None,
|
||||
temperature_celsius=None,
|
||||
humidity_percent=None,
|
||||
null_reasons=[],
|
||||
data_quality="critical",
|
||||
)
|
||||
|
||||
return SiteCurrentReading(
|
||||
timestamp=derniere.timestamp,
|
||||
site_id=site.site_id,
|
||||
site_type=site.site_type,
|
||||
consumption_kw=derniere.consumption_kw,
|
||||
consumption_kwh=derniere.consumption_kwh,
|
||||
voltage_v=derniere.voltage_v,
|
||||
current_a=derniere.current_a,
|
||||
power_factor=derniere.power_factor,
|
||||
temperature_celsius=derniere.temperature_celsius,
|
||||
humidity_percent=derniere.humidity_percent,
|
||||
null_reasons=derniere.null_reasons or [],
|
||||
data_quality=qualite_ou_critique(derniere.data_quality),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from app.models.energy import Reading, Site
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
DataQuality = Literal["good", "partial", "degraded", "critical"]
|
||||
|
||||
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
|
||||
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -58,13 +54,10 @@ class StatsService:
|
||||
@staticmethod
|
||||
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
|
||||
capacite = site.capacity_kw or 0
|
||||
# Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le
|
||||
# producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas,
|
||||
# `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas.
|
||||
qualite: DataQuality = "critical"
|
||||
consommation = None
|
||||
if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
|
||||
qualite = derniere.data_quality # type: ignore[assignment]
|
||||
qualite = qualite_ou_critique(derniere.data_quality)
|
||||
consommation = derniere.consumption_kw
|
||||
|
||||
charge = (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1115,6 +1115,93 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sites/{site_id}/current": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"sites"
|
||||
],
|
||||
"summary": "Dernière mesure d'un site",
|
||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "site_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Site Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SiteCurrentResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Aucun site ne porte cet identifiant.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/alerts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2297,6 +2384,140 @@
|
||||
],
|
||||
"title": "SensorStatusResponse"
|
||||
},
|
||||
"SiteCurrentResponse": {
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Timestamp"
|
||||
},
|
||||
"site_id": {
|
||||
"type": "string",
|
||||
"title": "Site Id"
|
||||
},
|
||||
"site_type": {
|
||||
"type": "string",
|
||||
"title": "Site Type"
|
||||
},
|
||||
"consumption_kw": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Consumption Kw"
|
||||
},
|
||||
"consumption_kwh": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Consumption Kwh"
|
||||
},
|
||||
"voltage_v": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Voltage V"
|
||||
},
|
||||
"current_a": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Current A"
|
||||
},
|
||||
"power_factor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Power Factor"
|
||||
},
|
||||
"temperature_celsius": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Temperature Celsius"
|
||||
},
|
||||
"humidity_percent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Humidity Percent"
|
||||
},
|
||||
"null_reasons": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Null Reasons"
|
||||
},
|
||||
"data_quality": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"good",
|
||||
"partial",
|
||||
"degraded",
|
||||
"critical"
|
||||
],
|
||||
"title": "Data Quality"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"timestamp",
|
||||
"site_id",
|
||||
"site_type",
|
||||
"consumption_kw",
|
||||
"consumption_kwh",
|
||||
"voltage_v",
|
||||
"current_a",
|
||||
"power_factor",
|
||||
"temperature_celsius",
|
||||
"humidity_percent",
|
||||
"null_reasons",
|
||||
"data_quality"
|
||||
],
|
||||
"title": "SiteCurrentResponse"
|
||||
},
|
||||
"SiteResponse": {
|
||||
"properties": {
|
||||
"site_id": {
|
||||
|
||||
@@ -31,6 +31,7 @@ ROUTES_A_ROLE = {
|
||||
("POST", "/api/v1/users/{id}/password-reset"),
|
||||
("GET", "/api/v1/sites"),
|
||||
("GET", "/api/v1/sites/{site_id}"),
|
||||
("GET", "/api/v1/sites/{site_id}/current"),
|
||||
("GET", "/api/v1/alerts"),
|
||||
("GET", "/api/v1/recommendations"),
|
||||
("GET", "/api/v1/recommendations/{recommendation_id}"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import Site
|
||||
from app.services.site import SiteNotFoundError
|
||||
from app.services.site import SiteCurrentReading, SiteNotFoundError
|
||||
|
||||
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def principal(role: Role = Role.LECTEUR) -> Principal:
|
||||
@@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site:
|
||||
)
|
||||
|
||||
|
||||
def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading:
|
||||
return SiteCurrentReading(
|
||||
timestamp=TIMESTAMP,
|
||||
site_id=site_id,
|
||||
site_type="industriel",
|
||||
consumption_kw=87.34,
|
||||
consumption_kwh=87.34,
|
||||
voltage_v=401.2,
|
||||
current_a=132.5,
|
||||
power_factor=0.923,
|
||||
temperature_celsius=22.1,
|
||||
humidity_percent=58.4,
|
||||
null_reasons=[],
|
||||
data_quality="good",
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self, erreur: Exception | None = None) -> None:
|
||||
self._erreur = erreur
|
||||
self.site = site()
|
||||
self.actuel = lecture_actuelle()
|
||||
|
||||
async def list_all(self) -> list[Site]:
|
||||
return [self.site]
|
||||
@@ -46,6 +67,11 @@ class FauxService:
|
||||
raise self._erreur
|
||||
return self.site
|
||||
|
||||
async def current(self, site_id: str) -> SiteCurrentReading:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
return self.actuel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||
@@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_get_current_returns_the_latest_reading(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/sites/site-1/current")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps["site_id"] == "site-1"
|
||||
assert corps["data_quality"] == "good"
|
||||
assert corps["consumption_kw"] == 87.34
|
||||
|
||||
|
||||
async def test_get_current_returns_404_for_an_unknown_site(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi(SiteNotFoundError("site-inconnu"))
|
||||
|
||||
response = await client.get("/api/v1/sites/site-inconnu/current")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_list_sites_reaches_the_repository_through_the_session(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
|
||||
@@ -88,6 +88,73 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
|
||||
assert identifiants == {premier, second}
|
||||
|
||||
|
||||
async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
|
||||
await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
|
||||
)
|
||||
derniere = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
|
||||
)
|
||||
|
||||
resultats = await depot.latest_by_site()
|
||||
retenues = [r.reading_id for r in resultats if r.site_id == site.site_id]
|
||||
await session.rollback()
|
||||
|
||||
assert retenues == [derniere.reading_id]
|
||||
|
||||
|
||||
async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
|
||||
recente = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
||||
)
|
||||
|
||||
trouvee = await depot.latest_for_site(site.site_id)
|
||||
reading_id = trouvee.reading_id if trouvee else None
|
||||
await session.rollback()
|
||||
|
||||
assert reading_id == recente.reading_id
|
||||
|
||||
|
||||
async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history")
|
||||
derniere = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=horodatage, source="api_current"
|
||||
)
|
||||
|
||||
trouvee = await depot.latest_for_site(site.site_id)
|
||||
reading_id = trouvee.reading_id if trouvee else None
|
||||
await session.rollback()
|
||||
|
||||
assert reading_id == derniere.reading_id
|
||||
|
||||
|
||||
async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
sans_lecture = await creer_site(session)
|
||||
autre = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
await creer_lecture(session, site_id=autre.site_id)
|
||||
|
||||
trouvee = await depot.latest_for_site(sans_lecture.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert trouvee is None
|
||||
|
||||
|
||||
async def test_list_history_orders_the_readings_by_timestamp_descending(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.services.site import SiteNotFoundError, SiteService
|
||||
|
||||
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def site(site_id: str = "site-1") -> Site:
|
||||
return Site(
|
||||
@@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxLecture:
|
||||
site_id: str
|
||||
timestamp: datetime = TIMESTAMP
|
||||
consumption_kw: float | None = 87.34
|
||||
consumption_kwh: float | None = 87.34
|
||||
voltage_v: float | None = 401.2
|
||||
current_a: float | None = 132.5
|
||||
power_factor: float | None = 0.923
|
||||
temperature_celsius: float | None = 22.1
|
||||
humidity_percent: float | None = 58.4
|
||||
null_reasons: list[str] | None = field(default_factory=list)
|
||||
data_quality: str | None = "good"
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, sites: list[Site]) -> None:
|
||||
self._sites = sites
|
||||
@@ -26,24 +46,77 @@ class FakeRepository:
|
||||
return next((s for s in self._sites if s.site_id == site_id), None)
|
||||
|
||||
|
||||
async def test_list_all_returns_the_repository_sites() -> None:
|
||||
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
|
||||
class FauxDepotLectures:
|
||||
def __init__(self, lectures: dict[str, FauxLecture]) -> None:
|
||||
self._lectures = lectures
|
||||
|
||||
sites = await service.list_all()
|
||||
async def latest_for_site(self, site_id: str) -> FauxLecture | None:
|
||||
return self._lectures.get(site_id)
|
||||
|
||||
|
||||
def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService:
|
||||
return SiteService(
|
||||
sites=FakeRepository(sites), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
async def test_list_all_returns_the_repository_sites() -> None:
|
||||
svc = service([site("a"), site("b")])
|
||||
|
||||
sites = await svc.list_all()
|
||||
|
||||
assert [s.site_id for s in sites] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_site() -> None:
|
||||
service = SiteService(sites=FakeRepository([site("a")]))
|
||||
svc = service([site("a")])
|
||||
|
||||
trouve = await service.get_by_id("a")
|
||||
trouve = await svc.get_by_id("a")
|
||||
|
||||
assert trouve.site_id == "a"
|
||||
|
||||
|
||||
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
|
||||
service = SiteService(sites=FakeRepository([]))
|
||||
svc = service([])
|
||||
|
||||
with pytest.raises(SiteNotFoundError):
|
||||
await service.get_by_id("inconnu")
|
||||
await svc.get_by_id("inconnu")
|
||||
|
||||
|
||||
async def test_current_raises_when_the_site_is_unknown() -> None:
|
||||
svc = service([])
|
||||
|
||||
with pytest.raises(SiteNotFoundError):
|
||||
await svc.current("inconnu")
|
||||
|
||||
|
||||
async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None:
|
||||
svc = service([site("a")])
|
||||
|
||||
actuel = await svc.current("a")
|
||||
|
||||
assert actuel.timestamp is None
|
||||
assert actuel.consumption_kw is None
|
||||
assert actuel.data_quality == "critical"
|
||||
assert actuel.null_reasons == []
|
||||
|
||||
|
||||
async def test_current_copies_every_field_from_the_latest_reading() -> None:
|
||||
svc = service([site("a")], {"a": FauxLecture(site_id="a")})
|
||||
|
||||
actuel = await svc.current("a")
|
||||
|
||||
assert actuel.timestamp == TIMESTAMP
|
||||
assert actuel.site_type == "industriel"
|
||||
assert actuel.consumption_kw == 87.34
|
||||
assert actuel.voltage_v == 401.2
|
||||
assert actuel.data_quality == "good"
|
||||
|
||||
|
||||
async def test_current_treats_an_unknown_data_quality_as_critical() -> None:
|
||||
svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)})
|
||||
|
||||
actuel = await svc.current("a")
|
||||
|
||||
assert actuel.data_quality == "critical"
|
||||
|
||||
@@ -86,15 +86,6 @@
|
||||
"text-summary",
|
||||
"lcov",
|
||||
"html"
|
||||
],
|
||||
"reporters": [
|
||||
"default",
|
||||
[
|
||||
"junit",
|
||||
{
|
||||
"outputFile": "test-results/junit.xml"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -21,8 +21,6 @@ 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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<Reading[]>(`${environment.apiUrl}/readings`, { params });
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Site[]>(`${environment.apiUrl}/sites`);
|
||||
}
|
||||
|
||||
getSite(siteId: string) {
|
||||
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
|
||||
}
|
||||
|
||||
getCurrent(siteId: string) {
|
||||
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<div class="site-detail-placeholder">
|
||||
<nav class="ev-breadcrumb">
|
||||
<a routerLink="/dashboard">Tableau de bord</a>
|
||||
<span>/</span>
|
||||
<a routerLink="/sites">Sites</a>
|
||||
</nav>
|
||||
|
||||
<header class="site-detail-placeholder__header">
|
||||
<a routerLink="/dashboard" class="ev-brand-link">
|
||||
<ev-brand class="site-detail-placeholder__logo" />
|
||||
</a>
|
||||
<h1>Site {{ siteId() }}</h1>
|
||||
</header>
|
||||
|
||||
<ev-card>
|
||||
<p>Le détail de ce site est à venir (voir issue #51).</p>
|
||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||
</ev-card>
|
||||
</div>
|
||||
-28
@@ -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;
|
||||
}
|
||||
-42
@@ -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');
|
||||
});
|
||||
});
|
||||
-19
@@ -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'))));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<div class="site-detail">
|
||||
<nav class="ev-breadcrumb">
|
||||
<a routerLink="/dashboard">Tableau de bord</a>
|
||||
<span>/</span>
|
||||
<a routerLink="/sites">Sites</a>
|
||||
</nav>
|
||||
|
||||
<header class="site-detail__header">
|
||||
<a routerLink="/dashboard" class="ev-brand-link">
|
||||
<ev-brand class="site-detail__logo" />
|
||||
</a>
|
||||
<div>
|
||||
<h1>{{ site()?.site_name ?? siteId() }}</h1>
|
||||
@if (site(); as s) {
|
||||
<p class="site-detail__subtitle">
|
||||
{{ s.site_type }} · {{ s.location || 'Localisation inconnue' }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="site-detail__badges">
|
||||
@if (site(); as s) {
|
||||
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
|
||||
}
|
||||
@if (hasMeasurement() && qualityLabel(); as label) {
|
||||
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (site(); as s) {
|
||||
@if (hasMeasurement()) {
|
||||
<section class="overview">
|
||||
<ev-card class="card card--gauge">
|
||||
<span class="card__label">Consommation vs capacité</span>
|
||||
@let consumption = consumptionKw();
|
||||
@if (consumption !== null) {
|
||||
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
|
||||
<span class="card__value">
|
||||
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
|
||||
</span>
|
||||
} @else {
|
||||
<p class="card__unavailable">
|
||||
Consommation indisponible
|
||||
<span class="metric__reason">({{ consumptionReason() }})</span>
|
||||
</p>
|
||||
}
|
||||
</ev-card>
|
||||
|
||||
<ev-card class="metrics-card">
|
||||
<span class="card__label">Mesure instantanée</span>
|
||||
<dl class="metrics-grid">
|
||||
@for (metric of metrics(); track metric.key) {
|
||||
<div class="metric">
|
||||
<dt>{{ metric.label }}</dt>
|
||||
@if (metric.value !== null) {
|
||||
<dd>{{ metric.value }}</dd>
|
||||
} @else {
|
||||
<dd class="metric__unavailable">
|
||||
Indisponible
|
||||
<span class="metric__reason">({{ metric.reason }})</span>
|
||||
</dd>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</ev-card>
|
||||
</section>
|
||||
|
||||
@if (history().length > 0) {
|
||||
<section class="chart-section">
|
||||
<h2>Historique de consommation</h2>
|
||||
<app-reading-history-chart [readings]="history()" />
|
||||
</section>
|
||||
}
|
||||
} @else {
|
||||
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
|
||||
}
|
||||
}
|
||||
|
||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SitesService>,
|
||||
readingsMock: Partial<ReadingsService>,
|
||||
) {
|
||||
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.');
|
||||
});
|
||||
});
|
||||
@@ -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<string, BadgeTone> = {
|
||||
actif: 'success',
|
||||
maintenance: 'warning',
|
||||
hors_service: 'danger',
|
||||
};
|
||||
|
||||
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
|
||||
good: 'success',
|
||||
partial: 'warning',
|
||||
degraded: 'danger',
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
|
||||
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<MetricKey, string[]> = {
|
||||
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<string, string> = {
|
||||
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<Site | null>(null);
|
||||
current = signal<SiteCurrent | null>(null);
|
||||
history = signal<Reading[]>([]);
|
||||
error = signal<string | null>(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<BadgeTone>(() => {
|
||||
const quality = this.current()?.data_quality;
|
||||
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
|
||||
});
|
||||
|
||||
metrics = computed<MetricView[]>(() => {
|
||||
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<Reading[]> {
|
||||
// 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<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
this.site.set(null);
|
||||
this.current.set(null);
|
||||
this.history.set([]);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<canvas #canvas></canvas>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 260px;
|
||||
}
|
||||
+101
@@ -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<string, unknown>[] } = { 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<typeof vi.fn>;
|
||||
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+95
@@ -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<ReadingDataQuality, string> = {
|
||||
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<Reading[]>();
|
||||
|
||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | null;
|
||||
imputation_method: string | null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.{ts,tsx,js,jsx}'],
|
||||
exclude: [
|
||||
'**/*.spec.*',
|
||||
'**/*.test.*',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
|
||||
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
|
||||
| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 |
|
||||
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
|
||||
@@ -171,7 +172,11 @@ et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la
|
||||
elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET
|
||||
/stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`,
|
||||
`ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc
|
||||
pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans
|
||||
pas dans ce gabarit route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`,
|
||||
mais `SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la
|
||||
dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de mesure
|
||||
à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. Le contrat
|
||||
détaillé pour le frontend est dans
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
|
||||
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Revue 1 - Cadrage (Jalon 2 - 15/09)
|
||||
|
||||
Présents :
|
||||
- Dorian
|
||||
- Johan
|
||||
- Inès
|
||||
- Meryem
|
||||
- Valentin
|
||||
|
||||
**Objectif:** Valider le périmètre retenu et les choix technologiques initiaux.
|
||||
|
||||
## KPIs
|
||||
|
||||
| Indicateur | Valeur |
|
||||
| --- | --- |
|
||||
| Issues fermées / Issues totales | 4/4 |
|
||||
| Must fermés / Must total | 4/41 |
|
||||
| Jours écoulés / jours restants | 2/9 |
|
||||
|
||||
## Issues du jalon
|
||||
|
||||
| Issue | Titre | Labels | Fermée par |
|
||||
| --- | --- | --- | --- |
|
||||
| #58 | Préparer les tests unitaires du frontend pour permettre leur implémentation continue | frontend, test | Dorian |
|
||||
| #57 | Préparer les tests unitaires du backend pour permettre leur implémentation continue | backend, test | Dorian |
|
||||
| #18 | Provisionner un cluster K8s single-node (k3s) via Terraform | infra | Dorian |
|
||||
| #67 | docs: fonder la documentation d'architecture du monorepo | documentation | Johan |
|
||||
|
||||
## Décisions prises
|
||||
|
||||
| Décision | Porteur | Justification |
|
||||
| --- | --- | --- |
|
||||
| Vitest retenu comme framework de tests unitaires frontend (PR #62) | Valentin | Standard de l'écosystème Vite ; permet scripts npm et mesure de couverture dès maintenant. |
|
||||
| pytest / pytest-asyncio + coverage.py côté backend, seuil de couverture fixé à 85 % (PR #65) | Johan | Le code applicatif est encore un squelette : c'est le bon moment pour poser les conventions de test avant l'arrivée de la logique métier. |
|
||||
| Cluster K8s single-node (k3s) provisionné via Terraform, version k3s figée en v1.31.5+k3s1, option `--write-kubeconfig-mode 644` retirée (PR #66) | Dorian | Reproductibilité de l'infra on-premise (infra as code) et durcissement sécurité du kubeconfig. |
|
||||
| Documentation d'architecture en 5 vues Mermaid + index dans `docs/architecture/`, avec statuts Done/In Progress/Target (PR #68) | Johan | Rendu natif GitHub sans assets binaires ; corrige des affirmations obsolètes (frontend, port 4200, arborescence). |
|
||||
|
||||
## Actions à mener
|
||||
|
||||
| Action | Responsable | Échéance |
|
||||
| --- | --- | --- |
|
||||
| Alimenter les tests unitaires frontend au fil des features, sur le socle Vitest posé en #58/#62 | Valentin | J4 (22/09) |
|
||||
| Implémenter la logique métier backend en respectant la structure de tests (`tests/api`, `tests/db`, `tests/core`, `tests/services`, `tests/repositories`) | Johan | J3 (18/09) |
|
||||
| Maintenir les vues d'architecture (`docs/architecture/`) à jour à chaque PR modifiant un composant, comme prévu par la règle posée en #67/#68 | Équipe | Continu |
|
||||
| Brancher `make test` / `make dev` / `make check` dans la CI | Dorian | J3 (18/09) |
|
||||
@@ -0,0 +1,112 @@
|
||||
# Point d'avancement · ProjetPiscine_EnerVision · 2026-09-18
|
||||
|
||||
Rendu final : **vendredi 25 septembre (J10)**, 9h00 pour le ZIP des livrables et le support EC02.
|
||||
Source : issues et PR GitHub du repo, branche `dev`. Relevé du 18/09 au matin.
|
||||
|
||||
## 1. Ce qui a bougé depuis le point d'hier
|
||||
|
||||
Grosse journée : **10 issues fermées**, **8 PR mergées sur `dev`**, **41 commits**.
|
||||
|
||||
| Auteur | Commits sur `dev` le 17/09 | Ce qui est arrivé |
|
||||
|---|---|---|
|
||||
| **Johan** | 27 | Design système front (#91), politique de mot de passe + mot de passe oublié (#87), vue liste des sites (#49), `GET /sensors/status` (#32), pipeline CI lint+tests (#20, avec Ines) |
|
||||
| **Meryem** | 6 | Pipeline d'import des données historiques, **#14 fermée** (elle bloquait #15 et #16) |
|
||||
| **Dorian (phyri0s)** | 3 | `GET /readings` avec fenêtre bornée et pagination, **pipeline d'entraînement LightGBM** posé (#92, ADR 0005) |
|
||||
| **Valentin** | 4 | Auth frontend mergée (#7), Dependabot (#40), audit de sécu des dépendances en CI |
|
||||
| **Ines** | 1 | Merge du pipeline CI, config SonarQube |
|
||||
|
||||
Le choix ML (#89) a été acté et fermé par tout le monde : **LightGBM, un modèle global**, documenté
|
||||
dans `docs/adr/0005-modele-prediction-lightgbm.md`. Le module `ml/` existe sur `dev` avec
|
||||
`data.py`, `features.py`, `train.py`, `baseline.py`, `metrics.py` et 4 fichiers de tests.
|
||||
|
||||
Décompte global : **33 issues fermées sur 65** (hier : 25/59). 6 issues créées dans la journée,
|
||||
32 encore ouvertes, dont **22 sans personne dessus** (hier : 27).
|
||||
|
||||
## 2. Où en est chacun
|
||||
|
||||
| Membre | Issues ouvertes assignées | Détail |
|
||||
|---|---|---|
|
||||
| **Johan** | #19, #29, #51, #61 | Reverse proxy Nginx+TLS · endpoint `current` (PR #84 ouverte depuis le 16/09) · vue détail d'un site (PR #103, CI verte) · tests d'accès rôles |
|
||||
| **ValentinDeFaria** | #53, #96, #97 | Vue supervision des capteurs (prise hier) · audit dépendances CI (PR #100) · couverture tests front CI (PR #101) |
|
||||
| **phyri0s (Dorian)** | #5, #30 | Jeu de données d'entraînement simulé · #30 `/readings` **est livrée (PR #94 mergée) mais l'issue n'a pas été fermée** |
|
||||
| **ineszang** | #21 | Pipeline CD (déploiement SSH) · PR #99 SonarQube approuvée mais check en échec |
|
||||
| **Meryemel-gham** | **aucune** | #14 fermée hier à 10h11, rien de repris depuis |
|
||||
| **Non assigné** | **22 issues** | Voir sections 4 et 5 |
|
||||
|
||||
## 3. Avancement par jalon
|
||||
|
||||
| Jalon | Échéance | Fermées / total | Hier | Statut |
|
||||
|---|---|---|---|---|
|
||||
| J1 - Environnement & repo | 14/09 | 5/5 | 5/5 | ✅ |
|
||||
| J2 - Périmètre & choix techos | 15/09 | 4/4 | 4/4 | ✅ |
|
||||
| **J3 - Ingestion & backend** | **18/09 (aujourd'hui)** | 9/15 | 6/12 | 🔴 60 %, **échoit ce soir avec 6 ouvertes** |
|
||||
| **J4 - Architecture, sécurité, frontend** | **21/09 (J+3)** | 10/30 | 6/26 | 🔴 33 %, 20 ouvertes |
|
||||
| **J5 - Robustesse & livrables** | **23/09 (J+5)** | 1/5 | 0/5 | 🔴 20 %, 4 ouvertes, **0 assignée** |
|
||||
|
||||
La journée d'hier a fait gagner 10 points sur J3 et 10 sur J4, mais le périmètre a aussi grossi
|
||||
(59 → 65 issues). **Le retard identifié hier n'est pas résorbé, il est stabilisé.**
|
||||
|
||||
## 4. J3 échoit aujourd'hui : les 6 issues restantes
|
||||
|
||||
| Issue | Qui | État réel |
|
||||
|---|---|---|
|
||||
| #5 Jeu de données d'entraînement simulé | Dorian | En cours, socle posé hier |
|
||||
| **#6 Entraîner le modèle** | **personne** | Le pipeline existe (#92), il manque le porteur de l'entraînement effectif |
|
||||
| **#15 DAG Airflow - normalisation** | **personne** | **Débloquée hier** par la fermeture de #14 |
|
||||
| **#16 DAG Airflow - chargement micro-batch** | **personne** | **Débloquée hier** par la fermeture de #14 |
|
||||
| #44 Tests unitaires modèle/recommandations | **personne** | Dépend de #6 |
|
||||
| #61 Tests d'accès API sécurisée (rôles) | Johan | **Débloquée** : les endpoints métier qui manquaient (#28, #32, #33, #59, #60) sont livrés |
|
||||
|
||||
Quatre de ces six n'ont personne dessus, dont les deux DAG Airflow qui viennent tout juste d'être
|
||||
débloqués. **Meryem, qui a écrit l'ingestion (#14), n'a plus d'issue assignée** : c'est le
|
||||
rapprochement le plus évident à faire ce matin.
|
||||
|
||||
## 5. À décider ensemble ce matin
|
||||
|
||||
1. **`main` est figée au 14/09.** `dev` a **161 commits d'avance** et rien n'a jamais été remonté.
|
||||
Le rendu du 25/09 se fait sur le Git : il faut décider maintenant qui merge `dev` → `main`,
|
||||
quand, et si on le fait en continu ou en une fois à la fin. Une remontée de 161 commits la
|
||||
veille du rendu est le risque le plus concret du projet aujourd'hui.
|
||||
|
||||
2. **Affecter les 4 issues J3 orphelines aujourd'hui** : #6 (entraînement du modèle), #15 et #16
|
||||
(DAG Airflow, débloqués), #44 (tests ML). Proposition : #15/#16 à Meryem (continuité de #14),
|
||||
#6 à Dorian en suite de #5.
|
||||
|
||||
3. **Purger la file de PR : 5 ouvertes, dont une depuis le 16/09.**
|
||||
|
||||
| PR | Auteur | CI | Ce qui bloque |
|
||||
|---|---|---|---|
|
||||
| #84 `sites/{id}/current` | Johan | verte | **ouverte depuis le 16/09**, rien ne la retient |
|
||||
| #103 vue détail d'un site | Johan | verte sauf SonarCloud | vérification visuelle |
|
||||
| #101 couverture tests front | Valentin | **entièrement verte** | personne n'a reviewé |
|
||||
| #100 audit dépendances CI | Valentin | verte sauf SonarCloud | personne n'a reviewé |
|
||||
| #99 config SonarQube | Ines | **SonarQube en échec** | approuvée, mais le check rouge |
|
||||
|
||||
SonarCloud est en échec sur #103 et #100, SonarQube sur #99. On avait acté hier que
|
||||
« SonarCloud non-bloquant = go de merge ». **Soit on applique cette règle pour de bon et on
|
||||
merge, soit Ines finit #99 et on redevient strict.** Rester entre les deux fait que rien ne part.
|
||||
|
||||
4. **J5 n'a aucun assigné à 5 jours de l'échéance** : #41 (DAST OWASP ZAP), #45 (tests
|
||||
d'intégration API↔DB↔ML), #46 (E2E Playwright), #47 (tests de charge k6). Ce sont exactement
|
||||
les livrables qui servent de preuve à EC03 et EC04. À nommer aujourd'hui, même sans démarrer.
|
||||
|
||||
5. **Hygiène de suivi** : #30 est livrée (PR #94 mergée hier) mais son issue est ouverte, et #29
|
||||
attend juste le merge de #84. Deux issues qui font croire à du reste à faire. À fermer.
|
||||
|
||||
6. **J4 : 20 ouvertes pour lundi.** Le front concentre le volume (#9 widget alerte de pic, #10 vue
|
||||
recommandations, #11 responsive, #54 comparateur de scénarios), plus le ML applicatif (#37
|
||||
service de scoring, #38 moteur de règles) et l'infra (#22 secrets, #24 MinIO, #26 monitoring,
|
||||
#36 rétention, #42 chiffrement au repos, #43 accessibilité). Hier il a été acté de **ne rien
|
||||
couper**. À 3 jours de l'échéance et 5 personnes, ce choix se reconfirme ou se révise ce matin,
|
||||
avec les chiffres sous les yeux.
|
||||
|
||||
## 6. Ce qui va bien, et qu'il faut garder
|
||||
|
||||
- Le rythme du 17/09 (10 issues fermées, 8 PR mergées) est le bon rythme. Tenu 5 jours, il vide
|
||||
la file.
|
||||
- La chaîne de revue fonctionne : Dorian a reviewé #93 et #95, les remarques ont été traitées
|
||||
avant merge.
|
||||
- Le backend est essentiellement là : auth + RBAC + audit, `/sites`, `/readings`, `/stats/summary`,
|
||||
`/alerts`, `/recommendations`, `/sensors/status`, contrat OpenAPI versionné, import historique.
|
||||
Le reste du projet s'appuie dessus, et ce socle ne bougera plus.
|
||||
- 5 ADR écrits et à jour : c'est de la matière directement réutilisable pour EC01 et EC02.
|
||||
@@ -1,14 +1,17 @@
|
||||
sonar.projectKey=ProjetPiscine_EnerVision
|
||||
sonar.organization=groupe3-ener-vision
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Dossier contenant le code source
|
||||
sonar.sources=apps/frontend/src,apps/backend
|
||||
# Dossier contenant les tests
|
||||
sonar.tests=apps/frontend/src,apps/backend/tests
|
||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||
|
||||
# This is the name and version displayed in the SonarCloud UI.
|
||||
#sonar.projectName=ProjetPiscine_EnerVision
|
||||
#sonar.projectVersion=1.0
|
||||
# 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
|
||||
|
||||
|
||||
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
|
||||
#sonar.sources=.
|
||||
|
||||
# Encoding of the source code. Default is default system encoding
|
||||
#sonar.sourceEncoding=UTF-8
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user