diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 98d5d53..aff8d71 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -2,19 +2,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/**" @@ -54,24 +41,28 @@ jobs: cache-dependency-path: apps/frontend/package-lock.json - run: npm ci working-directory: apps/frontend - - run: npm test -- --watch=false + - run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend/lcov.info + sonarqube: needs: [build, test] name: SonarQube runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v6 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + fetch-depth: 0 + - name: Download coverage + uses: actions/download-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - # deploy: - # runs-on: ubuntu-latest - # steps: - # - run: echo "DEPLOY job is running" diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index ae5df24..c544dae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -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)] diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index bc13d9c..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -1,10 +1,9 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, ReadingServiceDep, SiteServiceDep +from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.reading import ReadingResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -39,18 +38,15 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site @router.get( "/{site_id}/current", - response_model=ReadingResponse | None, - summary="Dernière mesure connue d'un site", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", responses=REPONSES_INTROUVABLE, ) -async def get_current( - site_id: str, _: LecteurDep, sites: SiteServiceDep, readings: ReadingServiceDep -) -> ReadingResponse | None: +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: try: - await sites.get_by_id(site_id) + actuel = await service.current(site_id) except SiteNotFoundError as erreur: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur - derniere = await readings.get_latest(site_id) - return ReadingResponse.model_validate(derniere) if derniere is not None else None + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index d2d06a1..d005d16 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -13,22 +13,26 @@ 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) ) - return (await self._session.scalars(requete)).first() + lecture: Reading | None = await self._session.scalar(requete) + return lecture async def list_history( self, diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -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"] diff --git a/apps/backend/app/services/data_quality.py b/apps/backend/app/services/data_quality.py new file mode 100644 index 0000000..ae8b43c --- /dev/null +++ b/apps/backend/app/services/data_quality.py @@ -0,0 +1,18 @@ +# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat +# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur +# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES` +# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli. + +from typing import Literal, get_args + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality)) + +_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)} + + +def qualite_ou_critique(valeur: str | None) -> DataQuality: + if valeur is None: + return "critical" + return _PAR_VALEUR.get(valeur, "critical") diff --git a/apps/backend/app/services/reading.py b/apps/backend/app/services/reading.py index 7d746c2..818c202 100644 --- a/apps/backend/app/services/reading.py +++ b/apps/backend/app/services/reading.py @@ -20,9 +20,6 @@ class ReadingService: def __init__(self, *, readings: ReadingRepository) -> None: self._readings = readings - async def get_latest(self, site_id: str) -> Reading | None: - return await self._readings.latest_for_site(site_id) - async def list_history( self, *, diff --git a/apps/backend/app/services/sensor.py b/apps/backend/app/services/sensor.py index 1d707e0..fa1a1ea 100644 --- a/apps/backend/app/services/sensor.py +++ b/apps/backend/app/services/sensor.py @@ -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": diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..a438a20 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -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), + ) diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py index c98eace..c2b15da 100644 --- a/apps/backend/app/services/stats.py +++ b/apps/backend/app/services/stats.py @@ -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 = ( diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index ab4ff74..114215b 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1120,7 +1120,7 @@ "tags": [ "sites" ], - "summary": "Dernière mesure connue d'un site", + "summary": "Dernière mesure d'un site", "operationId": "get_current_api_v1_sites__site_id__current_get", "security": [ { @@ -1144,15 +1144,7 @@ "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReadingResponse" - }, - { - "type": "null" - } - ], - "title": "Response Get Current Api V1 Sites Site Id Current Get" + "$ref": "#/components/schemas/SiteCurrentResponse" } } } @@ -2392,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": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index aa5546b..85432c4 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -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}"), diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 5980b0b..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -6,11 +6,13 @@ import pytest from fastapi import FastAPI from httpx import AsyncClient -from app.api.deps import get_current_principal, get_reading_service, get_site_service +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 Reading, Site -from app.services.site import SiteNotFoundError +from app.models.energy import Site +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -34,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] @@ -47,25 +67,10 @@ class FauxService: raise self._erreur return self.site - -def reading(site_id: str = "site-1") -> Reading: - return Reading( - reading_id=1, - site_id=site_id, - timestamp=datetime(2026, 9, 16, tzinfo=UTC), - source="csv", - consumption_kw=42.5, - data_quality="good", - raw_data={}, - ) - - -class FauxReadingService: - def __init__(self, derniere: Reading | None) -> None: - self._derniere = derniere - - async def get_latest(self, site_id: str) -> Reading | None: - return self._derniere + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel @pytest.fixture @@ -88,19 +93,6 @@ def servi( app.dependency_overrides.pop(get_site_service, None) -@pytest.fixture -def readings_servis( - app: FastAPI, lecteur_connecte: None -) -> Iterator[Callable[[Reading | None], FauxReadingService]]: - def installe(derniere: Reading | None) -> FauxReadingService: - service = FauxReadingService(derniere) - app.dependency_overrides[get_reading_service] = lambda: service - return service - - yield installe - app.dependency_overrides.pop(get_reading_service, None) - - async def test_list_sites_returns_the_sites( servi: Callable[..., FauxService], client: AsyncClient ) -> None: @@ -143,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: @@ -173,45 +189,3 @@ async def test_get_site_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/sites/inconnu") assert response.status_code == 404 - - -async def test_get_current_returns_the_latest_reading_regardless_of_its_age( - servi: Callable[..., FauxService], - readings_servis: Callable[[Reading | None], FauxReadingService], - client: AsyncClient, -) -> None: - servi() - readings_servis(reading()) - - response = await client.get("/api/v1/sites/site-1/current") - - assert response.status_code == 200 - corps = response.json() - assert corps["consumption_kw"] == 42.5 - - -async def test_get_current_returns_null_when_the_site_has_no_reading( - servi: Callable[..., FauxService], - readings_servis: Callable[[Reading | None], FauxReadingService], - client: AsyncClient, -) -> None: - servi() - readings_servis(None) - - response = await client.get("/api/v1/sites/site-1/current") - - assert response.status_code == 200 - assert response.json() is None - - -async def test_get_current_returns_404_for_an_unknown_site( - servi: Callable[..., FauxService], - readings_servis: Callable[[Reading | None], FauxReadingService], - client: AsyncClient, -) -> None: - servi(SiteNotFoundError("site-inconnu")) - readings_servis(None) - - response = await client.get("/api/v1/sites/site-inconnu/current") - - assert response.status_code == 404 diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 70af2d2..4f12df0 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,29 +88,71 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} -async def test_latest_for_site_ignores_how_old_the_reading_is(session: AsyncSession) -> None: - site = await creer_site(session) - depot = ReadingRepository(session) - await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2024, 1, 1, tzinfo=UTC)) - recente = await creer_lecture( - session, site_id=site.site_id, timestamp=datetime(2024, 12, 31, tzinfo=UTC) - ) - - resultat = await depot.latest_for_site(site.site_id) - await session.rollback() - - assert resultat is not None - assert resultat.reading_id == recente.reading_id - - -async def test_latest_for_site_returns_none_when_the_site_has_no_reading( +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 + ) - resultat = await depot.latest_for_site(identifiant_site()) + 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 resultat is None + 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( diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py index 1295aa9..a3f0826 100644 --- a/apps/backend/tests/services/test_reading.py +++ b/apps/backend/tests/services/test_reading.py @@ -28,11 +28,6 @@ class FakeRepository: def __init__(self, readings: list[Reading]) -> None: self._readings = readings self.appels: list[tuple[str | None, datetime, datetime, int, int]] = [] - self.site_interroge: str | None = None - - async def latest_for_site(self, site_id: str) -> Reading | None: - self.site_interroge = site_id - return self._readings[0] if self._readings else None async def list_history( self, @@ -47,25 +42,6 @@ class FakeRepository: return self._readings -async def test_get_latest_relays_the_repository_reading() -> None: - depot = FakeRepository([reading(1)]) - service = ReadingService(readings=depot) - - lecture = await service.get_latest("site-1") - - assert lecture is not None - assert lecture.reading_id == 1 - assert depot.site_interroge == "site-1" - - -async def test_get_latest_returns_none_when_the_site_has_no_reading() -> None: - service = ReadingService(readings=FakeRepository([])) - - lecture = await service.get_latest("site-1") - - assert lecture is None - - async def test_list_history_returns_the_repository_readings() -> None: service = ReadingService(readings=FakeRepository([reading(1), reading(2)])) diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -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" diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 6cb33fa..981779f 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -86,15 +86,6 @@ "text-summary", "lcov", "html" - ], - "reporters": [ - "default", - [ - "junit", - { - "outputFile": "test-results/junit.xml" - } - ] ] } } diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 0000000..e6ea512 --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + reportsDirectory: './coverage', + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: [ + '**/*.spec.*', + '**/*.test.*', + '**/node_modules/**', + '**/dist/**', + ], + }, + }, +}) \ No newline at end of file diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 553ac4e..ac14fb1 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -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, diff --git a/docs/dailies/point-avancement-2026-09-15.md b/docs/dailies/point-avancement-2026-09-15.md new file mode 100644 index 0000000..67c9787 --- /dev/null +++ b/docs/dailies/point-avancement-2026-09-15.md @@ -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) | diff --git a/docs/dailies/point-avancement-2026-09-18.md b/docs/dailies/point-avancement-2026-09-18.md new file mode 100644 index 0000000..3fc0c61 --- /dev/null +++ b/docs/dailies/point-avancement-2026-09-18.md @@ -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. diff --git a/sonar-project.properties b/sonar-project.properties index 3c1af86..7155d42 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,14 +1,9 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision +sonar.sourceEncoding=UTF-8 +sonar.sources=apps/frontend/src,apps/backend/app -# This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=ProjetPiscine_EnerVision -#sonar.projectVersion=1.0 +sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json - -# 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 \ No newline at end of file +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info