Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e504bee72 | ||
|
|
32155cbfc5 | ||
|
|
d16de84559 | ||
|
|
90639b5618 | ||
|
|
e147ea69bc | ||
|
|
83caa9006e | ||
|
|
91752a2b0b | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
62d81e901d | ||
|
|
31886944ab | ||
|
|
63cbeafe3b | ||
|
|
be44b97d7a | ||
|
|
d7457e9fd9 | ||
|
|
5545c166fd | ||
|
|
f0ad8e9990 | ||
|
|
3c01ab3ecc | ||
|
|
c3fd9327ea |
@@ -140,3 +140,29 @@ jobs:
|
||||
# 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
|
||||
|
||||
sast:
|
||||
name: Analyse statique de sécurité
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/backend
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
|
||||
|
||||
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
|
||||
- name: Rapport complet, tous niveaux
|
||||
continue-on-error: true
|
||||
run: uvx bandit==1.9.4 --recursive app
|
||||
|
||||
@@ -57,3 +57,26 @@ jobs:
|
||||
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
||||
- name: Tests
|
||||
run: uv run pytest
|
||||
|
||||
sast:
|
||||
name: Analyse statique de sécurité
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ml
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||
|
||||
- name: Rapport complet, tous niveaux
|
||||
continue-on-error: true
|
||||
run: uvx bandit==1.9.4 --recursive enervision_ml
|
||||
|
||||
@@ -5,11 +5,15 @@ on:
|
||||
paths:
|
||||
- "apps/frontend/**"
|
||||
- "apps/backend/**"
|
||||
- "ml/**"
|
||||
- "etl/airflow/**"
|
||||
- ".github/workflows/sonarqube.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/frontend/**"
|
||||
- "apps/backend/**"
|
||||
- "ml/**"
|
||||
- "etl/airflow/**"
|
||||
- ".github/workflows/sonarqube.yml"
|
||||
|
||||
|
||||
@@ -108,8 +112,36 @@ jobs:
|
||||
name: backend-coverage
|
||||
path: apps/backend/coverage.xml
|
||||
|
||||
test-ml:
|
||||
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: ml/uv.lock
|
||||
|
||||
- name: Installe l'interpréteur déclaré par .python-version
|
||||
run: uv python install
|
||||
working-directory: ml
|
||||
|
||||
- name: Synchronise les dépendances sans dévier du verrou
|
||||
run: uv sync --all-groups --frozen
|
||||
working-directory: ml
|
||||
|
||||
- name: Lancement des tests et génération du rapport de couverture (ML)
|
||||
run: uv run pytest --cov-report=xml
|
||||
working-directory: ml
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ml-coverage
|
||||
path: ml/coverage.xml
|
||||
|
||||
sonarqube:
|
||||
needs: [build-front, build-back, test-front, test-back]
|
||||
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -126,6 +158,11 @@ jobs:
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: apps/backend
|
||||
- name: Téléchargement du rapport de couverture (ML)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ml-coverage
|
||||
path: ml
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v8
|
||||
env:
|
||||
|
||||
@@ -11,13 +11,14 @@ WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
uv sync --locked --no-install-project --no-dev --no-build
|
||||
|
||||
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
|
||||
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
|
||||
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
|
||||
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
|
||||
COPY . /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
|
||||
@@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
|
||||
)
|
||||
await data_connection.execute(statement)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
@@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
|
||||
}
|
||||
values.update(changes)
|
||||
|
||||
statement = insert(Reading).values(**values)
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
await data_connection.execute(insert(Reading).values(**values))
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
async def test_prediction_requires_period_when_energy_is_predicted(
|
||||
@@ -164,8 +169,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
|
||||
model_reference="test-model/1",
|
||||
)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
@@ -212,10 +219,7 @@ async def test_alert_rejects_prediction_when_site_differs(
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
await data_connection.execute(
|
||||
insert(Alert).values(
|
||||
statement = insert(Alert).values(
|
||||
source_alert_id=str(uuid4()),
|
||||
site_id=other_site,
|
||||
source="enervision",
|
||||
@@ -226,7 +230,11 @@ async def test_alert_rejects_prediction_when_site_differs(
|
||||
prediction_id=prediction_id,
|
||||
raw_data={},
|
||||
)
|
||||
)
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||
@@ -256,6 +264,8 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||
)
|
||||
await data_connection.execute(statement)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
@@ -106,13 +106,15 @@ def test_validate_source_accepts_valid_dataset():
|
||||
def test_validate_source_rejects_missing_column():
|
||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Colonnes obligatoires absentes",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -124,13 +126,15 @@ def test_validate_source_rejects_duplicates():
|
||||
"timestamp",
|
||||
]
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="doublons",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -139,13 +143,15 @@ def test_validate_source_rejects_unknown_site():
|
||||
|
||||
frame.loc[1, "site_id"] = "SITE999"
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Sites incohérents",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
|
||||
) -> None:
|
||||
await une_ligne(session)
|
||||
|
||||
requete = text(instruction)
|
||||
|
||||
with pytest.raises(DBAPIError, match="ajout seul"):
|
||||
await session.execute(text(instruction))
|
||||
await session.execute(requete)
|
||||
await session.rollback()
|
||||
|
||||
|
||||
|
||||
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
empreinte = fingerprint_refresh(secret)
|
||||
expiration = datetime.now(UTC) + DUREE
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
token_hash=empreinte,
|
||||
expires_at=expiration,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
famille = uuid.uuid4()
|
||||
empreinte = fingerprint_refresh(secret)
|
||||
expiration = datetime.now(UTC) + DUREE
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
family_id=uuid.uuid4(),
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
family_id=famille,
|
||||
token_hash=empreinte,
|
||||
expires_at=expiration,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
|
||||
) -> None:
|
||||
saisie = adresse().upper()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.execute(
|
||||
text(
|
||||
"insert into app_user (email, password_hash, role) "
|
||||
"values (:e, '$argon2id$x', 'lecteur')"
|
||||
),
|
||||
{"e": saisie},
|
||||
requete = text(
|
||||
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.execute(requete, {"e": saisie})
|
||||
await session.rollback()
|
||||
|
||||
|
||||
|
||||
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
|
||||
async def test_list_history_raises_when_start_is_after_end() -> None:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
|
||||
debut = datetime(2026, 9, 2, tzinfo=UTC)
|
||||
fin = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
|
||||
with pytest.raises(FenetreInverseeError):
|
||||
await service.list_history(
|
||||
start=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
end=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||
|
||||
|
||||
async def test_list_history_raises_when_start_equals_end() -> None:
|
||||
|
||||
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
|
||||
if action == "set_active":
|
||||
arguments["is_active"] = False
|
||||
|
||||
methode = getattr(attirail.service, action)
|
||||
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await getattr(attirail.service, action)(**arguments)
|
||||
await methode(**arguments)
|
||||
|
||||
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
|
||||
|
||||
|
||||
def test_build_parser_requires_a_subcommand() -> None:
|
||||
parser = cli.build_parser()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args([])
|
||||
parser.parse_args([])
|
||||
|
||||
|
||||
def test_build_parser_requires_an_email() -> None:
|
||||
parser = cli.build_parser()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args(["create-admin"])
|
||||
parser.parse_args(["create-admin"])
|
||||
|
||||
|
||||
def test_read_password_generates_a_long_secret_when_asked(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Conventions de tests unitaires — Frontend
|
||||
# Conventions de tests unitaires : Frontend
|
||||
|
||||
## Outil
|
||||
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
||||
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
|
||||
## Lancer les tests
|
||||
- Développement (mode watch) : `npm test`
|
||||
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
||||
- Un fichier ou un dossier seulement :
|
||||
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
||||
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import {authGuard} from './core/guards/auth-guard';
|
||||
import { authGuard } from './core/guards/auth-guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
|
||||
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
|
||||
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
|
||||
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: 'change-password',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
|
||||
},
|
||||
{
|
||||
path: 'reset-password',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
|
||||
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||
},
|
||||
{
|
||||
path: 'sites',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
|
||||
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
|
||||
},
|
||||
{
|
||||
path: 'sites/:siteId',
|
||||
@@ -23,6 +38,12 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
||||
},
|
||||
{
|
||||
path: 'recommendations',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
|
||||
},
|
||||
{
|
||||
path: 'monitoring/sensors',
|
||||
canActivate: [authGuard],
|
||||
|
||||
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
export const ALERTS_FIXTURE: Alert[] = [
|
||||
{
|
||||
alert_id: 'ALR-SITE002-1718458320',
|
||||
timestamp: '2026-09-15T11:12:00',
|
||||
alert_id: 5,
|
||||
site_id: 'SITE002',
|
||||
timestamp: '2026-09-15T11:12:00Z',
|
||||
type: 'threshold',
|
||||
severity: 'critical',
|
||||
type: 'outage',
|
||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
||||
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
|
||||
value: 812.5,
|
||||
threshold: 720.0,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE003-1718458321',
|
||||
timestamp: '2026-09-15T11:05:00',
|
||||
alert_id: 4,
|
||||
site_id: 'SITE003',
|
||||
timestamp: '2026-09-15T11:05:00Z',
|
||||
type: 'outage',
|
||||
severity: 'critical',
|
||||
type: 'sensor',
|
||||
message: 'Perte réseau totale sur Data Center Marseille',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
|
||||
value: null,
|
||||
threshold: null,
|
||||
metric: null,
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE005-1718458322',
|
||||
timestamp: '2026-09-15T10:47:00',
|
||||
alert_id: 3,
|
||||
site_id: 'SITE005',
|
||||
timestamp: '2026-09-15T10:47:00Z',
|
||||
type: 'spike',
|
||||
severity: 'high',
|
||||
type: 'threshold',
|
||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
||||
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
|
||||
value: 410.0,
|
||||
threshold: 480.0,
|
||||
threshold: 260.0,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE006-1718458323',
|
||||
timestamp: '2026-09-15T10:30:00',
|
||||
alert_id: 2,
|
||||
site_id: 'SITE006',
|
||||
severity: 'medium',
|
||||
timestamp: '2026-09-15T10:30:00Z',
|
||||
type: 'sensor',
|
||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
severity: 'medium',
|
||||
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
|
||||
value: null,
|
||||
threshold: null,
|
||||
metric: null,
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE004-1718458324',
|
||||
timestamp: '2026-09-15T09:58:00',
|
||||
alert_id: 1,
|
||||
site_id: 'SITE004',
|
||||
severity: 'low',
|
||||
timestamp: '2026-09-15T09:58:00Z',
|
||||
type: 'anomaly',
|
||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
||||
severity: 'low',
|
||||
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
|
||||
value: 62.0,
|
||||
threshold: 55.0,
|
||||
metric: 'consumption_kwh',
|
||||
prediction_id: 42,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
const ALERT_API: Alert = {
|
||||
alert_id: 1,
|
||||
site_id: 'site-1',
|
||||
timestamp: '2026-09-16T00:00:00Z',
|
||||
type: 'threshold',
|
||||
severity: 'high',
|
||||
message: 'Dépassement du seuil configuré',
|
||||
value: 812.5,
|
||||
threshold: 720.0,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
};
|
||||
|
||||
describe('AlertsService', () => {
|
||||
let service: AlertsService;
|
||||
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
||||
let result: unknown;
|
||||
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
|
||||
let result: Alert[] = [];
|
||||
service.getAlerts().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
const req = httpMock.expectOne(
|
||||
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
|
||||
);
|
||||
expect(req.request.params.keys()).toEqual([]);
|
||||
req.flush([ALERT_API]);
|
||||
|
||||
req.flush([
|
||||
{
|
||||
alert_id: 'ALR-TEST-1',
|
||||
timestamp: '2026-09-15T12:00:00',
|
||||
site_id: 'SITE001',
|
||||
severity: 'high',
|
||||
type: 'threshold',
|
||||
message: 'Test',
|
||||
value: 100,
|
||||
threshold: 90,
|
||||
},
|
||||
]);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].alert_id).toBe(1);
|
||||
expect(result[0].prediction_id).toBeNull();
|
||||
});
|
||||
|
||||
expect((result as unknown[]).length).toBe(1);
|
||||
it('transmet les filtres site_id et severity en paramètres de requête', () => {
|
||||
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||
expect(req.request.params.get('severity')).toBe('high');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('ne pose pas de paramètre pour un filtre omis', () => {
|
||||
service.getAlerts({ site_id: 'SITE001' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||
expect(req.request.params.has('severity')).toBe(false);
|
||||
req.flush([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||
|
||||
export interface AlertFilters {
|
||||
site_id?: string;
|
||||
severity?: AlertSeverity;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AlertsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getAlerts() {
|
||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
||||
getAlerts(filters: AlertFilters = {}) {
|
||||
let params = new HttpParams();
|
||||
if (filters.site_id) {
|
||||
params = params.set('site_id', filters.site_id);
|
||||
}
|
||||
if (filters.severity) {
|
||||
params = params.set('severity', filters.severity);
|
||||
}
|
||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { RecommendationsService } from './recommendations.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Recommendation } from '../../shared/models/recommendation.model';
|
||||
|
||||
const RECOMMANDATION_API: Recommendation = {
|
||||
recommendation_id: 1,
|
||||
alert_id: 1,
|
||||
action: 'Vérifier la consommation',
|
||||
explanation: 'Pic détecté',
|
||||
rule_reference: 'spike-v1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('RecommendationsService', () => {
|
||||
let service: RecommendationsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(RecommendationsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('liste les recommandations depuis le bon endpoint', () => {
|
||||
let result: Recommendation[] = [];
|
||||
service.getRecommendations().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([RECOMMANDATION_API]);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].alert_id).toBe(1);
|
||||
});
|
||||
|
||||
it('décrit une recommandation par son identifiant', () => {
|
||||
service.getRecommendation(42).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
|
||||
});
|
||||
|
||||
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
|
||||
let result: unknown;
|
||||
service.generate('SITE001').subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||
);
|
||||
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||
expect(req.request.body).toBeNull();
|
||||
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||
|
||||
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||
});
|
||||
|
||||
it('génère pour tout le parc quand aucun site n’est donné', () => {
|
||||
service.generate().subscribe();
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||
);
|
||||
expect(req.request.params.has('site_id')).toBe(false);
|
||||
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
Recommendation,
|
||||
RecommendationGenerationReport,
|
||||
} from '../../shared/models/recommendation.model';
|
||||
|
||||
@Service()
|
||||
export class RecommendationsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRecommendations() {
|
||||
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
|
||||
}
|
||||
|
||||
getRecommendation(recommendationId: number) {
|
||||
return this.http.get<Recommendation>(
|
||||
`${environment.apiUrl}/recommendations/${recommendationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
generate(siteId?: string) {
|
||||
let params = new HttpParams();
|
||||
if (siteId) {
|
||||
params = params.set('site_id', siteId);
|
||||
}
|
||||
return this.http.post<RecommendationGenerationReport>(
|
||||
`${environment.apiUrl}/recommendations/generate`,
|
||||
null,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
||||
}
|
||||
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
|
||||
<ev-button
|
||||
class="logout-button"
|
||||
variant="secondary"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="recommendations">
|
||||
<nav class="ev-breadcrumb">
|
||||
<a routerLink="/dashboard">Tableau de bord</a>
|
||||
</nav>
|
||||
|
||||
<header class="recommendations__header">
|
||||
<a routerLink="/dashboard" class="ev-brand-link">
|
||||
<ev-brand class="recommendations__logo" />
|
||||
</a>
|
||||
<div>
|
||||
<h1>Recommandations</h1>
|
||||
<p class="recommendations__subtitle">
|
||||
Actions proposées par le moteur de règles à partir des alertes
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="recommendations__toolbar">
|
||||
<label class="recommendations__filter">
|
||||
<span class="form-label">Site</span>
|
||||
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||
<option value="" [selected]="!siteFilter()">Tous les sites</option>
|
||||
@for (site of sites(); track site.site_id) {
|
||||
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
|
||||
{{ site.site_name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (isAdmin()) {
|
||||
<ev-button
|
||||
variant="secondary"
|
||||
[fullWidth]="false"
|
||||
[disabled]="generating()"
|
||||
data-testid="generate"
|
||||
(click)="onGenerate()"
|
||||
>
|
||||
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
|
||||
</ev-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (generationReport(); as report) {
|
||||
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
|
||||
}
|
||||
@if (generationError(); as message) {
|
||||
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (alertId(); as id) {
|
||||
<p class="recommendations__focus">
|
||||
Alerte n° {{ id }} ·
|
||||
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommendations__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations__logo {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.recommendations__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.recommendations__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recommendations__filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 14rem;
|
||||
|
||||
.form-label {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations__banner {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.recommendations__focus {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
import { RecommendationsView, parseAlertId } from './recommendations';
|
||||
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||
import { SitesService } from '../../core/services/sites.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
const SITES = [
|
||||
{
|
||||
site_id: 'SITE001',
|
||||
site_name: 'Usine Nantes',
|
||||
site_type: 'industriel',
|
||||
location: 'Nantes',
|
||||
capacity_kw: 500,
|
||||
status: 'actif',
|
||||
},
|
||||
];
|
||||
|
||||
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
|
||||
|
||||
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
|
||||
const query = options.query ?? {};
|
||||
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
|
||||
const generate = vi.fn().mockReturnValue(of(BILAN));
|
||||
const getRecommendations = vi.fn().mockReturnValue(of([]));
|
||||
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RecommendationsView],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
|
||||
},
|
||||
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
|
||||
{ provide: AlertsService, useValue: { getAlerts } },
|
||||
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RecommendationsView);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
|
||||
}
|
||||
|
||||
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
|
||||
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
|
||||
}
|
||||
|
||||
describe('parseAlertId', () => {
|
||||
it("n'accepte qu'un entier strictement positif", () => {
|
||||
expect(parseAlertId('12')).toBe(12);
|
||||
expect(parseAlertId('0')).toBeNull();
|
||||
expect(parseAlertId('-3')).toBeNull();
|
||||
expect(parseAlertId('abc')).toBeNull();
|
||||
expect(parseAlertId('12abc')).toBeNull();
|
||||
expect(parseAlertId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecommendationsView', () => {
|
||||
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
|
||||
const { fixture } = setup({ query: { alert: '12' } });
|
||||
|
||||
expect(fixture.componentInstance.alertId()).toBe(12);
|
||||
expect(listeEnfant(fixture).alertId()).toBe(12);
|
||||
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('ignore un paramètre alert invalide', () => {
|
||||
const { fixture } = setup({ query: { alert: 'abc' } });
|
||||
|
||||
expect(fixture.componentInstance.alertId()).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
|
||||
});
|
||||
|
||||
it('applique le site donné par ?site= au filtre et à la liste', () => {
|
||||
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||
const option = fixture.nativeElement.querySelector(
|
||||
'option[value="SITE001"]',
|
||||
) as HTMLOptionElement;
|
||||
expect(option.selected).toBe(true);
|
||||
});
|
||||
|
||||
it('relance la liste sur le site choisi dans le filtre', () => {
|
||||
const { fixture, getAlerts } = setup();
|
||||
const select = fixture.nativeElement.querySelector(
|
||||
'[data-testid="site-filter"]',
|
||||
) as HTMLSelectElement;
|
||||
|
||||
select.value = 'SITE001';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
|
||||
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
|
||||
});
|
||||
|
||||
it('cache le bouton de génération aux lecteurs', () => {
|
||||
const { fixture } = setup({ role: 'lecteur' });
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
|
||||
const { fixture, generate, getRecommendations } = setup({
|
||||
role: 'admin',
|
||||
query: { site: 'SITE001' },
|
||||
});
|
||||
|
||||
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(generate).toHaveBeenCalledWith('SITE001');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
|
||||
);
|
||||
expect(getRecommendations).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.generating()).toBe(false);
|
||||
});
|
||||
|
||||
it('génère pour tout le parc quand aucun site n’est filtré', () => {
|
||||
const { fixture, generate } = setup({ role: 'admin' });
|
||||
|
||||
fixture.componentInstance.onGenerate();
|
||||
|
||||
expect(generate).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("signale l'échec de la génération sans casser la page", () => {
|
||||
const { fixture, generate } = setup({ role: 'admin' });
|
||||
generate.mockReturnValue(throwError(() => new Error('403')));
|
||||
|
||||
fixture.componentInstance.onGenerate();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.generationError()).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La génération des recommandations a échoué',
|
||||
);
|
||||
expect(fixture.componentInstance.generating()).toBe(false);
|
||||
});
|
||||
|
||||
it('accorde le bilan au singulier', () => {
|
||||
const { fixture } = setup();
|
||||
|
||||
expect(
|
||||
fixture.componentInstance.bilan({
|
||||
alerts_examined: 1,
|
||||
recommendations_created: 1,
|
||||
already_present: 0,
|
||||
}),
|
||||
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { catchError, map, of } from 'rxjs';
|
||||
import { SitesService } from '../../core/services/sites.service';
|
||||
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { Site } from '../../shared/models/site.model';
|
||||
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
|
||||
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||
import { Brand } from '../../shared/components/ui/brand/brand';
|
||||
import { Button } from '../../shared/components/ui/button/button';
|
||||
|
||||
const GENERATION_FAILED_MESSAGE =
|
||||
'La génération des recommandations a échoué, réessayez plus tard.';
|
||||
|
||||
export function parseAlertId(raw: string | null): number | null {
|
||||
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||
}
|
||||
|
||||
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
|
||||
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-recommendations',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
|
||||
templateUrl: './recommendations.html',
|
||||
styleUrl: './recommendations.scss',
|
||||
})
|
||||
export class RecommendationsView {
|
||||
private route = inject(ActivatedRoute);
|
||||
private sitesService = inject(SitesService);
|
||||
private recommendationsService = inject(RecommendationsService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
alertId = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
|
||||
{ initialValue: null },
|
||||
);
|
||||
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
|
||||
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||
initialValue: [] as Site[],
|
||||
});
|
||||
|
||||
list = viewChild.required(RecommendationList);
|
||||
|
||||
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
|
||||
generating = signal(false);
|
||||
generationReport = signal<RecommendationGenerationReport | null>(null);
|
||||
generationError = signal<string | null>(null);
|
||||
|
||||
onSiteChange(event: Event): void {
|
||||
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||
}
|
||||
|
||||
onGenerate(): void {
|
||||
if (this.generating()) {
|
||||
return;
|
||||
}
|
||||
this.generating.set(true);
|
||||
this.generationError.set(null);
|
||||
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
|
||||
next: (report) => {
|
||||
this.generating.set(false);
|
||||
this.generationReport.set(report);
|
||||
this.list().reload();
|
||||
},
|
||||
error: () => {
|
||||
this.generating.set(false);
|
||||
this.generationError.set(GENERATION_FAILED_MESSAGE);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
bilan(report: RecommendationGenerationReport): string {
|
||||
return [
|
||||
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
|
||||
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
|
||||
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
|
||||
].join(', ');
|
||||
}
|
||||
}
|
||||
@@ -81,5 +81,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
<section class="recommendations-section">
|
||||
<h2>Recommandations</h2>
|
||||
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
|
||||
<a
|
||||
routerLink="/recommendations"
|
||||
[queryParams]="{ site: siteId() }"
|
||||
class="ev-link recommendations-section__link"
|
||||
>Voir dans la vue recommandations</a
|
||||
>
|
||||
</section>
|
||||
|
||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||
</div>
|
||||
|
||||
@@ -117,3 +117,12 @@ h2 {
|
||||
.chart-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.recommendations-section {
|
||||
margin: 2.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.recommendations-section__link {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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';
|
||||
import { AlertsService } from '../../../core/services/alerts.service';
|
||||
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||
|
||||
const SITE = {
|
||||
site_id: 'SITE001',
|
||||
@@ -69,6 +71,7 @@ function setup(
|
||||
readingsMock: Partial<ReadingsService>,
|
||||
) {
|
||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
||||
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SiteDetail],
|
||||
providers: [
|
||||
@@ -76,9 +79,14 @@ function setup(
|
||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||
{ provide: SitesService, useValue: sitesMock },
|
||||
{ provide: ReadingsService, useValue: readingsMock },
|
||||
{ provide: AlertsService, useValue: { getAlerts } },
|
||||
{
|
||||
provide: RecommendationsService,
|
||||
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||
},
|
||||
],
|
||||
});
|
||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
|
||||
}
|
||||
|
||||
describe('SiteDetail', () => {
|
||||
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('demande les recommandations du site consulté à travers ses alertes', () => {
|
||||
const { fixture, getAlerts } = setup(
|
||||
'SITE001',
|
||||
{
|
||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||
},
|
||||
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Recommandations');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
||||
const { fixture } = setup(
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
|
||||
|
||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||
@@ -96,7 +97,16 @@ export interface MetricView {
|
||||
@Component({
|
||||
selector: 'app-site-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
||||
imports: [
|
||||
RouterLink,
|
||||
Card,
|
||||
Alert,
|
||||
Badge,
|
||||
Brand,
|
||||
ConsumptionGauge,
|
||||
ReadingHistoryChart,
|
||||
RecommendationList,
|
||||
],
|
||||
templateUrl: './site-detail.html',
|
||||
styleUrl: './site-detail.scss',
|
||||
})
|
||||
@@ -117,6 +127,11 @@ export class SiteDetail {
|
||||
|
||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||
|
||||
siteAsList = computed<Site[]>(() => {
|
||||
const site = this.site();
|
||||
return site ? [site] : [];
|
||||
});
|
||||
|
||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
||||
|
||||
consumptionLabel = computed(() => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
@if (error(); as message) {
|
||||
<ev-alert severity="danger" class="reco-list__banner">{{ message }}</ev-alert>
|
||||
} @else if (loading() && !hasData()) {
|
||||
<p class="reco-list__state" aria-live="polite">Chargement des recommandations…</p>
|
||||
} @else if (visibleGroups().length === 0) {
|
||||
<ev-alert severity="success" class="reco-list__banner">{{ emptyMessage() }}</ev-alert>
|
||||
}
|
||||
|
||||
<div class="reco-list" [attr.aria-busy]="loading()">
|
||||
@for (group of visibleGroups(); track group.alert.alert_id) {
|
||||
<ev-card
|
||||
class="reco-group"
|
||||
[class.reco-group--focus]="group.alert.alert_id === alertId()"
|
||||
[id]="'alerte-' + group.alert.alert_id"
|
||||
>
|
||||
<header class="reco-group__alert">
|
||||
<div class="reco-group__meta">
|
||||
<ev-badge [tone]="toneFor(group.alert.severity)">{{
|
||||
severityLabel(group.alert.severity)
|
||||
}}</ev-badge>
|
||||
<span class="reco-group__type">{{ typeLabel(group.alert.type) }}</span>
|
||||
@if (!siteId()) {
|
||||
<a [routerLink]="['/sites', group.alert.site_id]" class="ev-link">{{
|
||||
group.siteName
|
||||
}}</a>
|
||||
}
|
||||
<time [attr.datetime]="group.alert.timestamp">{{
|
||||
group.alert.timestamp | date: 'dd/MM/yyyy HH:mm'
|
||||
}}</time>
|
||||
</div>
|
||||
<p class="reco-group__message">{{ group.alert.message }}</p>
|
||||
</header>
|
||||
<ol class="reco-group__items">
|
||||
@for (reco of group.recommendations; track reco.recommendation_id) {
|
||||
<li class="reco">
|
||||
<div class="reco__head">
|
||||
<strong class="reco__action">{{ reco.action }}</strong>
|
||||
<ev-badge [tone]="ruleTone(reco.rule_reference)">{{
|
||||
ruleLabel(reco.rule_reference)
|
||||
}}</ev-badge>
|
||||
</div>
|
||||
<p class="reco__explanation">{{ reco.explanation }}</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</ev-card>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reco-list__banner {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.reco-list__state {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reco-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.reco-group {
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.reco-group--focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.reco-group__alert {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.reco-group__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reco-group__type {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.reco-group__message {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.reco-group__items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reco {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.reco__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reco__action {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.reco__explanation {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import { NEVER, of, throwError } from 'rxjs';
|
||||
import { RecommendationList, joinByAlert } from './recommendation-list';
|
||||
import { AlertsService } from '../../../core/services/alerts.service';
|
||||
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||
import { Alert } from '../../models/alert.model';
|
||||
import { Recommendation } from '../../models/recommendation.model';
|
||||
import { Site } from '../../models/site.model';
|
||||
|
||||
const SITES: Site[] = [
|
||||
{
|
||||
site_id: 'SITE001',
|
||||
site_name: 'Usine Nantes',
|
||||
site_type: 'industriel',
|
||||
location: 'Nantes',
|
||||
capacity_kw: 500,
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE002',
|
||||
site_name: 'Bureau Lille',
|
||||
site_type: 'bureau',
|
||||
location: 'Lille',
|
||||
capacity_kw: 80,
|
||||
status: 'actif',
|
||||
},
|
||||
];
|
||||
|
||||
function alerte(surcharges: Partial<Alert>): Alert {
|
||||
return {
|
||||
alert_id: 1,
|
||||
site_id: 'SITE001',
|
||||
timestamp: '2026-09-15T09:00:00Z',
|
||||
type: 'threshold',
|
||||
severity: 'high',
|
||||
message: 'Puissance appelée au-dessus de la capacité du site',
|
||||
value: 812.5,
|
||||
threshold: 720,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
...surcharges,
|
||||
};
|
||||
}
|
||||
|
||||
function reco(surcharges: Partial<Recommendation>): Recommendation {
|
||||
return {
|
||||
recommendation_id: 1,
|
||||
alert_id: 1,
|
||||
action: 'Ramener la puissance appelée sous le seuil contractuel',
|
||||
explanation: 'Seuil de consommation dépassé sur le site SITE001.',
|
||||
rule_reference: 'threshold-reduction-v1',
|
||||
created_at: '2026-09-15T09:05:00Z',
|
||||
...surcharges,
|
||||
};
|
||||
}
|
||||
|
||||
const ALERTES: Alert[] = [
|
||||
alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
|
||||
alerte({
|
||||
alert_id: 2,
|
||||
site_id: 'SITE002',
|
||||
timestamp: '2026-09-15T11:00:00Z',
|
||||
severity: 'critical',
|
||||
type: 'spike',
|
||||
message: 'Variation brutale entre deux lectures consécutives',
|
||||
}),
|
||||
alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
|
||||
];
|
||||
|
||||
const RECOMMANDATIONS: Recommendation[] = [
|
||||
reco({
|
||||
recommendation_id: 3,
|
||||
alert_id: 2,
|
||||
action: "Escalader à l'astreinte sous une heure",
|
||||
rule_reference: 'escalade-astreinte-v1',
|
||||
}),
|
||||
reco({ recommendation_id: 1, alert_id: 1 }),
|
||||
reco({
|
||||
recommendation_id: 2,
|
||||
alert_id: 2,
|
||||
action: 'Délester les équipements non prioritaires sur le créneau du pic',
|
||||
rule_reference: 'spike-delestage-v1',
|
||||
}),
|
||||
reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
|
||||
];
|
||||
|
||||
function setup(
|
||||
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||
recosMock: { getRecommendations: ReturnType<typeof vi.fn> },
|
||||
inputs: Record<string, unknown> = {},
|
||||
) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RecommendationList],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: RecommendationsService, useValue: recosMock },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RecommendationList);
|
||||
for (const [nom, valeur] of Object.entries(inputs)) {
|
||||
fixture.componentRef.setInput(nom, valeur);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function rendre(fixture: ComponentFixture<RecommendationList>) {
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function texte(fixture: ComponentFixture<RecommendationList>): string {
|
||||
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
}
|
||||
|
||||
const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
|
||||
|
||||
describe('joinByAlert', () => {
|
||||
it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
|
||||
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
|
||||
|
||||
expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
|
||||
expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
|
||||
expect(groupes[1].siteName).toBe('Usine Nantes');
|
||||
expect(groupes[0].siteName).toBe('SITE002');
|
||||
});
|
||||
|
||||
it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
|
||||
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
|
||||
|
||||
expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
|
||||
expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecommendationList', () => {
|
||||
it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||
const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledWith({});
|
||||
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
|
||||
const contenu = texte(fixture);
|
||||
expect(contenu).toContain('Usine Nantes');
|
||||
expect(contenu).toContain('Bureau Lille');
|
||||
expect(contenu).toContain('Critique');
|
||||
expect(contenu).toContain('Pic de consommation');
|
||||
expect(contenu).toContain('Escalade astreinte');
|
||||
expect(contenu).toContain('Délester les équipements');
|
||||
expect(contenu).toContain('15/09/2026');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
|
||||
expect(fixture.componentInstance.total()).toBe(3);
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('filtre les alertes du site côté API et masque le lien vers le site', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
|
||||
const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
|
||||
expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
|
||||
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||
alertId: 2,
|
||||
});
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
|
||||
expect(groupes.length).toBe(1);
|
||||
expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
|
||||
expect(groupes[0].id).toBe('alerte-2');
|
||||
});
|
||||
|
||||
it("annonce l'absence de recommandation pour une alerte inconnue", () => {
|
||||
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||
alertId: 123,
|
||||
});
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
|
||||
});
|
||||
|
||||
it("annonce l'absence de recommandation pour le site consulté", () => {
|
||||
const fixture = setup(
|
||||
{ getAlerts: vi.fn().mockReturnValue(of([])) },
|
||||
{ getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||
{ siteId: 'SITE001' },
|
||||
);
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
|
||||
});
|
||||
|
||||
it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
|
||||
const fixture = setup(
|
||||
{ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
|
||||
{ getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||
);
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.groups()).toEqual([]);
|
||||
expect(texte(fixture)).toContain('Recommandations indisponibles');
|
||||
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
|
||||
});
|
||||
|
||||
it('annonce le chargement tant que la réponse ne vient pas', () => {
|
||||
const fixture = setup(
|
||||
{ getAlerts: vi.fn().mockReturnValue(NEVER) },
|
||||
{ getRecommendations: vi.fn().mockReturnValue(NEVER) },
|
||||
);
|
||||
|
||||
rendre(fixture);
|
||||
|
||||
expect(fixture.componentInstance.loading()).toBe(true);
|
||||
expect(texte(fixture)).toContain('Chargement des recommandations');
|
||||
});
|
||||
|
||||
it('recharge les deux flux à la demande', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||
const recos = recosOk();
|
||||
const fixture = setup({ getAlerts }, recos);
|
||||
rendre(fixture);
|
||||
|
||||
fixture.componentInstance.reload();
|
||||
rendre(fixture);
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||
expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
|
||||
import { AlertsService } from '../../../core/services/alerts.service';
|
||||
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||
import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||
import { Recommendation } from '../../models/recommendation.model';
|
||||
import { Site } from '../../models/site.model';
|
||||
import {
|
||||
LIBELLE_PAR_SEVERITE,
|
||||
LIBELLE_PAR_TYPE,
|
||||
TON_PAR_SEVERITE,
|
||||
} from '../../models/alert-presentation';
|
||||
import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
|
||||
import { Card } from '../ui/card/card';
|
||||
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||
|
||||
const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
|
||||
|
||||
export interface RecommendedAlertView {
|
||||
alert: Alert;
|
||||
siteName: string;
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
interface Chargement {
|
||||
alerts: Alert[];
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
|
||||
// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
|
||||
export function joinByAlert(
|
||||
alerts: Alert[],
|
||||
recommendations: Recommendation[],
|
||||
siteNames: Map<string, string>,
|
||||
): RecommendedAlertView[] {
|
||||
const parAlerte = new Map<number, Recommendation[]>();
|
||||
for (const recommandation of recommendations) {
|
||||
const liste = parAlerte.get(recommandation.alert_id) ?? [];
|
||||
liste.push(recommandation);
|
||||
parAlerte.set(recommandation.alert_id, liste);
|
||||
}
|
||||
return alerts
|
||||
.filter((alert) => parAlerte.has(alert.alert_id))
|
||||
.map((alert) => ({
|
||||
alert,
|
||||
siteName: siteNames.get(alert.site_id) ?? alert.site_id,
|
||||
recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
|
||||
(a, b) => a.recommendation_id - b.recommendation_id,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-recommendation-list',
|
||||
standalone: true,
|
||||
imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
|
||||
templateUrl: './recommendation-list.html',
|
||||
styleUrl: './recommendation-list.scss',
|
||||
})
|
||||
export class RecommendationList {
|
||||
private alertsService = inject(AlertsService);
|
||||
private recommendationsService = inject(RecommendationsService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
siteId = input<string | null>(null);
|
||||
alertId = input<number | null>(null);
|
||||
sites = input<Site[]>([]);
|
||||
|
||||
private data = signal<Chargement | null>(null);
|
||||
private reloadTick = signal(0);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
|
||||
|
||||
private siteNameById = computed(
|
||||
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||
);
|
||||
|
||||
hasData = computed(() => this.data() !== null);
|
||||
|
||||
groups = computed<RecommendedAlertView[]>(() => {
|
||||
const data = this.data();
|
||||
return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
|
||||
});
|
||||
|
||||
visibleGroups = computed(() => {
|
||||
const alertId = this.alertId();
|
||||
const groups = this.groups();
|
||||
return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
|
||||
});
|
||||
|
||||
total = computed(() =>
|
||||
this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
|
||||
);
|
||||
|
||||
emptyMessage = computed(() => {
|
||||
if (this.alertId() !== null) {
|
||||
return 'Aucune recommandation pour cette alerte.';
|
||||
}
|
||||
return this.siteId()
|
||||
? 'Aucune recommandation pour ce site.'
|
||||
: 'Aucune recommandation pour le moment.';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
toObservable(this.trigger)
|
||||
.pipe(
|
||||
tap(() => this.loading.set(true)),
|
||||
switchMap(({ siteId }) =>
|
||||
forkJoin({
|
||||
alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
|
||||
recommendations: this.recommendationsService.getRecommendations(),
|
||||
}).pipe(catchError(() => this.reportUnavailable())),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((data) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(null);
|
||||
this.data.set(data);
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.reloadTick.update((tick) => tick + 1);
|
||||
}
|
||||
|
||||
toneFor(severity: AlertSeverity): BadgeTone {
|
||||
return TON_PAR_SEVERITE[severity];
|
||||
}
|
||||
|
||||
severityLabel(severity: AlertSeverity): string {
|
||||
return LIBELLE_PAR_SEVERITE[severity];
|
||||
}
|
||||
|
||||
typeLabel(type: AlertType): string {
|
||||
return LIBELLE_PAR_TYPE[type];
|
||||
}
|
||||
|
||||
ruleLabel(reference: string): string {
|
||||
return libelleRegle(reference);
|
||||
}
|
||||
|
||||
ruleTone(reference: string): BadgeTone {
|
||||
return tonRegle(reference);
|
||||
}
|
||||
|
||||
// Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
|
||||
// ou l'inverse) afficherait des groupes faux plutôt que rien.
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.loading.set(false);
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
this.data.set(null);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
LIBELLE_PAR_SEVERITE,
|
||||
LIBELLE_PAR_TYPE,
|
||||
SEVERITES,
|
||||
TON_PAR_SEVERITE,
|
||||
TYPES_ALERTE,
|
||||
UNITE_PAR_METRIQUE,
|
||||
} from './alert-presentation';
|
||||
|
||||
describe('alert-presentation', () => {
|
||||
it('distingue le ton des sévérités high et critical', () => {
|
||||
expect(TON_PAR_SEVERITE.high).toBe('danger');
|
||||
expect(TON_PAR_SEVERITE.critical).toBe('critical');
|
||||
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
|
||||
});
|
||||
|
||||
it("n'affiche pas une alerte faible avec le ton de succès", () => {
|
||||
expect(TON_PAR_SEVERITE.low).toBe('neutral');
|
||||
expect(TON_PAR_SEVERITE.medium).toBe('warning');
|
||||
});
|
||||
|
||||
it('donne un libellé français à chaque sévérité et à chaque type', () => {
|
||||
for (const severite of SEVERITES) {
|
||||
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
|
||||
}
|
||||
for (const type of TYPES_ALERTE) {
|
||||
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
|
||||
}
|
||||
expect(SEVERITES.length).toBe(4);
|
||||
expect(TYPES_ALERTE.length).toBe(5);
|
||||
});
|
||||
|
||||
it('associe une unité à chaque métrique du contrat', () => {
|
||||
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
|
||||
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BadgeTone } from '../components/ui/badge/badge';
|
||||
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
|
||||
|
||||
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
|
||||
// lisait comme « tout va bien » à côté des rouges.
|
||||
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||
low: 'neutral',
|
||||
medium: 'warning',
|
||||
high: 'danger',
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
|
||||
low: 'Faible',
|
||||
medium: 'Moyenne',
|
||||
high: 'Élevée',
|
||||
critical: 'Critique',
|
||||
};
|
||||
|
||||
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
|
||||
spike: 'Pic de consommation',
|
||||
threshold: 'Seuil dépassé',
|
||||
anomaly: 'Anomalie',
|
||||
outage: 'Coupure',
|
||||
sensor: 'Capteur',
|
||||
};
|
||||
|
||||
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
|
||||
consumption_kw: 'kW',
|
||||
consumption_kwh: 'kWh',
|
||||
};
|
||||
|
||||
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
|
||||
|
||||
export const TYPES_ALERTE: readonly AlertType[] = [
|
||||
'spike',
|
||||
'threshold',
|
||||
'anomaly',
|
||||
'outage',
|
||||
'sensor',
|
||||
];
|
||||
@@ -1,13 +1,16 @@
|
||||
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
|
||||
|
||||
export interface Alert {
|
||||
alert_id: string;
|
||||
timestamp: string;
|
||||
alert_id: number;
|
||||
site_id: string;
|
||||
severity: AlertSeverity;
|
||||
timestamp: string;
|
||||
type: AlertType;
|
||||
severity: AlertSeverity;
|
||||
message: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
value: number | null;
|
||||
threshold: number | null;
|
||||
metric: AlertMetric | null;
|
||||
prediction_id: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { libelleRegle, tonRegle } from './recommendation-presentation';
|
||||
|
||||
describe('recommendation-presentation', () => {
|
||||
it('traduit les sept règles connues du moteur', () => {
|
||||
expect(libelleRegle('spike-delestage-v1')).toBe('Délestage');
|
||||
expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance');
|
||||
expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours');
|
||||
expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur');
|
||||
expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification');
|
||||
expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte');
|
||||
expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance');
|
||||
});
|
||||
|
||||
it('affiche telle quelle une référence de règle inconnue', () => {
|
||||
expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2');
|
||||
});
|
||||
|
||||
it("réserve le ton critique à l'escalade vers l'astreinte", () => {
|
||||
expect(tonRegle('escalade-astreinte-v1')).toBe('critical');
|
||||
expect(tonRegle('spike-delestage-v1')).toBe('neutral');
|
||||
expect(tonRegle('inconnue-v9')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BadgeTone } from '../components/ui/badge/badge';
|
||||
|
||||
// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ;
|
||||
// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue.
|
||||
const LIBELLE_PAR_REGLE: Record<string, string> = {
|
||||
'spike-delestage-v1': 'Délestage',
|
||||
'threshold-reduction-v1': 'Réduction de puissance',
|
||||
'outage-secours-v1': 'Alimentation de secours',
|
||||
'sensor-maintenance-v1': 'Maintenance capteur',
|
||||
'anomaly-verification-v1': 'Vérification',
|
||||
'escalade-astreinte-v1': 'Escalade astreinte',
|
||||
'contrat-puissance-v1': 'Contrat de puissance',
|
||||
};
|
||||
|
||||
const REGLE_ESCALADE = 'escalade-astreinte-v1';
|
||||
|
||||
export function libelleRegle(reference: string): string {
|
||||
return LIBELLE_PAR_REGLE[reference] ?? reference;
|
||||
}
|
||||
|
||||
export function tonRegle(reference: string): BadgeTone {
|
||||
return reference === REGLE_ESCALADE ? 'critical' : 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface Recommendation {
|
||||
recommendation_id: number;
|
||||
alert_id: number;
|
||||
action: string;
|
||||
explanation: string;
|
||||
rule_reference: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RecommendationGenerationReport {
|
||||
alerts_examined: number;
|
||||
recommendations_created: number;
|
||||
already_present: number;
|
||||
}
|
||||
@@ -29,3 +29,18 @@
|
||||
color: var(--color-disabled);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
|
||||
// reprend en dur la valeur de --color-text-muted.
|
||||
.form-select {
|
||||
@extend .form-input;
|
||||
padding-right: 2.25rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-surface);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.6rem center;
|
||||
background-size: 1rem;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# ML-START : accès aux données, scoring, frontière API et ML
|
||||
|
||||
Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`,
|
||||
`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md)
|
||||
et par les vues d'architecture. Il répond à trois questions, et à elles seules :
|
||||
|
||||
1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ;
|
||||
2. **ce que fait un run de scoring**, étape par étape ;
|
||||
3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là.
|
||||
|
||||
Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md).
|
||||
Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mécanisme d'accès aux données
|
||||
|
||||
### Deux sources, un seul schéma de sortie
|
||||
|
||||
`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes**
|
||||
(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`,
|
||||
`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) :
|
||||
|
||||
| Fonction | Source | Usage |
|
||||
|---|---|---|
|
||||
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
|
||||
| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
|
||||
| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring |
|
||||
|
||||
L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
|
||||
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
|
||||
divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production
|
||||
sous forme de prédictions silencieusement fausses.
|
||||
|
||||
### Connexion directe à PostgreSQL, pas l'API
|
||||
|
||||
Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois
|
||||
raisons, à défendre telles quelles :
|
||||
|
||||
- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire
|
||||
passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer
|
||||
trois fois pour un `SELECT`.
|
||||
- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du
|
||||
schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la
|
||||
disponibilité du service, pour lire des données dont il connaît déjà la forme.
|
||||
- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte
|
||||
applicatif porteur d'un rôle métier.
|
||||
|
||||
### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL`
|
||||
|
||||
La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est
|
||||
pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du
|
||||
schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner
|
||||
l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()`
|
||||
lève donc plutôt que de retomber sur une valeur par défaut.
|
||||
|
||||
**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`,
|
||||
restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement,
|
||||
`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente
|
||||
avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md).
|
||||
|
||||
### Le seul endroit qui construit les features
|
||||
|
||||
`enervision_ml.features.build_features` est **l'unique** constructeur de features, à
|
||||
l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une
|
||||
fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à
|
||||
ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**.
|
||||
Ne jamais réécrire cette logique ailleurs : importer le module.
|
||||
|
||||
Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais
|
||||
un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des
|
||||
lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait
|
||||
un surapprentissage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Les étapes d'un run de scoring
|
||||
|
||||
`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la
|
||||
consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans
|
||||
`prediction`.
|
||||
|
||||
| # | Étape | Point de vigilance |
|
||||
|---|---|---|
|
||||
| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` |
|
||||
| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 |
|
||||
| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire |
|
||||
| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | |
|
||||
|
||||
### Ce que le run écrit, et ce qu'il n'écrase pas
|
||||
|
||||
La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
|
||||
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra
|
||||
possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui
|
||||
n'existe pas encore.
|
||||
|
||||
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
|
||||
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
|
||||
`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
|
||||
`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`.
|
||||
|
||||
### `model_reference` est un hachage, pas un nom de fichier
|
||||
|
||||
`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque
|
||||
entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction`
|
||||
porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une
|
||||
prédiction douteuse, de savoir quel modèle l'a produite.
|
||||
|
||||
### Mode CSV : rien n'est écrit en base
|
||||
|
||||
En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe
|
||||
dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode
|
||||
sert à valider le pipeline sans base joignable.
|
||||
|
||||
### Limite assumée
|
||||
|
||||
La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture
|
||||
réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans
|
||||
le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule,
|
||||
sur une feature parmi une dizaine, pour une prévision à un seul pas.
|
||||
|
||||
---
|
||||
|
||||
## 3. La frontière entre l'API et le ML
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph ml["ml/ · projet Python indépendant"]
|
||||
train["enervision_ml.train<br/>LightGBM + MLflow"]
|
||||
score["enervision_ml.score<br/>prévision à un pas"]
|
||||
end
|
||||
subgraph db["PostgreSQL + TimescaleDB"]
|
||||
reading[("reading, site")]
|
||||
prediction[("prediction")]
|
||||
end
|
||||
subgraph api["apps/backend · FastAPI"]
|
||||
route["GET /api/v1/predictions"]
|
||||
end
|
||||
|
||||
reading -- "SQL direct, ML_DATABASE_URL" --> train
|
||||
reading -- "fenêtre récente" --> score
|
||||
train -- "models/*.txt + run MLflow" --> score
|
||||
score -- "INSERT" --> prediction
|
||||
prediction -- "lecture seule" --> route
|
||||
```
|
||||
|
||||
**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
|
||||
`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul
|
||||
à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury :
|
||||
|
||||
- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée
|
||||
(`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde
|
||||
ou une minute.
|
||||
- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python
|
||||
séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni
|
||||
leur surface de vulnérabilités, pour un script lancé hors du chemin de requête.
|
||||
- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API
|
||||
continue de servir la dernière prévision connue, avec son `created_at` et son
|
||||
`model_reference`, au lieu de rendre une erreur.
|
||||
- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de
|
||||
`prediction` et leurs contraintes de cohérence, vérifiables en SQL.
|
||||
|
||||
Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
|
||||
celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
|
||||
(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
|
||||
l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la
|
||||
surveillance de dérive, portée par les issues #44 et #45.
|
||||
|
||||
---
|
||||
|
||||
## Voir aussi
|
||||
|
||||
- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests
|
||||
- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés
|
||||
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
|
||||
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
|
||||
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
|
||||
@@ -85,17 +85,18 @@ collecteur ne vient le lire.
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
|
||||
|
||||
## Flux bout en bout
|
||||
|
||||
Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les
|
||||
deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas
|
||||
l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous.
|
||||
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
|
||||
d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`,
|
||||
issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
|
||||
l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -168,7 +169,9 @@ consolidée.
|
||||
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
||||
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
||||
est livré et documenté mais pas exercé.
|
||||
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
||||
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
|
||||
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
|
||||
[50-cicd.md](50-cicd.md).
|
||||
|
||||
## Décisions structurantes
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend
|
||||
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
|
||||
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
|
||||
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
|
||||
[ML-START.md](../../ML-START.md) section 3.
|
||||
[ML-START.md](../ML-START.md) section 3.
|
||||
|
||||
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
|
||||
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
|
||||
|
||||
@@ -146,6 +146,29 @@ Compose.
|
||||
|
||||
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
|
||||
|
||||
## Recommandations
|
||||
|
||||
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
|
||||
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
|
||||
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
|
||||
son explication et la règle qui l'a produite.
|
||||
|
||||
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
|
||||
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
|
||||
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
|
||||
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
|
||||
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
|
||||
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
|
||||
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
|
||||
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
|
||||
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
|
||||
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
|
||||
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
|
||||
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
||||
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
|
||||
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
|
||||
complète préfiltrée).
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
||||
|
||||
@@ -24,12 +24,13 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
|
||||
| `--shadow-card` | Ombre portée des cartes |
|
||||
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
||||
|
||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
|
||||
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
|
||||
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
|
||||
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
|
||||
`<ev-alert severity="danger">`, pas une classe dédiée.
|
||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
|
||||
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
|
||||
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
|
||||
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
|
||||
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
|
||||
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
|
||||
|
||||
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
||||
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Intégration et livraison continues
|
||||
|
||||
Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est
|
||||
vérifié, ce qui bloque, et ce qui ne l'est pas.
|
||||
|
||||
| Étage | Sert à | Statut |
|
||||
|---|---|---|
|
||||
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
|
||||
| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` |
|
||||
|
||||
Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image
|
||||
publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet
|
||||
étage, et elle est nommée ici plutôt que découverte en soutenance.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
push["push ou pull_request"]
|
||||
|
||||
subgraph back["Backend · .github/workflows/backend.yml"]
|
||||
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"]
|
||||
bi["integration<br/>TimescaleDB réel + alembic upgrade head"]
|
||||
bd["security-audit<br/>uv export | pip-audit"]
|
||||
bs["sast<br/>bandit"]
|
||||
end
|
||||
|
||||
subgraph front["Frontend · frontend.yml"]
|
||||
fb["build<br/>npm ci, npm run build"]
|
||||
ft["test<br/>couverture lcov"]
|
||||
fd["security-audit<br/>npm audit --audit-level=high"]
|
||||
end
|
||||
|
||||
subgraph mlw["ML · ml.yml"]
|
||||
mv["verification<br/>ruff, mypy, pytest"]
|
||||
ms["sast<br/>bandit"]
|
||||
end
|
||||
|
||||
subgraph afw["Airflow · airflow.yml"]
|
||||
av["verification<br/>ruff, intégrité des DAGs"]
|
||||
ab["image<br/>construction de l'image"]
|
||||
end
|
||||
|
||||
subgraph sq["SonarQube · sonarqube.yml"]
|
||||
sb1["build-front / test-front"]
|
||||
sb2["build-back / test-back"]
|
||||
sb3["test-ml"]
|
||||
sscan["sonarqube<br/>quality gate SonarCloud"]
|
||||
end
|
||||
|
||||
push --> bv & bi & bd & bs
|
||||
push --> fb --> ft
|
||||
push --> fd
|
||||
push --> mv & ms
|
||||
push --> av & ab
|
||||
push --> sb1 & sb2 --> sscan
|
||||
sscan -.-> cd["deploy<br/>issue #21"]
|
||||
```
|
||||
|
||||
## Déclenchement
|
||||
|
||||
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** :
|
||||
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`,
|
||||
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun
|
||||
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline
|
||||
déclenche le pipeline.
|
||||
|
||||
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
|
||||
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
|
||||
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
|
||||
modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes`
|
||||
y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
||||
Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre
|
||||
le voit.
|
||||
|
||||
**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la
|
||||
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
|
||||
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
|
||||
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
|
||||
|
||||
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
|
||||
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
|
||||
|
||||
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10
|
||||
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
|
||||
Docker et son propre environnement.
|
||||
|
||||
## Ce qui bloque un merge
|
||||
|
||||
| Gate | Où | Seuil | Effet d'un échec |
|
||||
|---|---|---|---|
|
||||
| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque |
|
||||
| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque |
|
||||
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
|
||||
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
|
||||
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
|
||||
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
|
||||
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
|
||||
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
|
||||
| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque |
|
||||
| Build `npm run build` | frontend | compilation | Bloque |
|
||||
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
|
||||
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
|
||||
|
||||
Deux seuils portent une décision qu'il faut savoir défendre :
|
||||
|
||||
- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une
|
||||
dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les
|
||||
`moderate` sont invisibles en CI, et qu'elles se regardent à la main.
|
||||
- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW
|
||||
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
|
||||
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
|
||||
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
|
||||
21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes
|
||||
analysées.
|
||||
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
|
||||
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
|
||||
le rejeu à l'identique promis plus bas n'existerait pas.
|
||||
|
||||
## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres`
|
||||
|
||||
`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que
|
||||
`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête
|
||||
volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste
|
||||
rendrait ce job vert sur une base qui n'est pas la nôtre.
|
||||
|
||||
Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas
|
||||
monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb`
|
||||
avant `alembic upgrade head`.
|
||||
|
||||
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
|
||||
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
||||
|
||||
## SonarCloud, et l'incident qui a immobilisé trois PR
|
||||
|
||||
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
|
||||
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
|
||||
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
|
||||
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
|
||||
|
||||
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
|
||||
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
|
||||
Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil
|
||||
propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture**
|
||||
(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien.
|
||||
Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est
|
||||
compté comme code de production non couvert (cf. l'incident ci-dessous).
|
||||
|
||||
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
|
||||
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
|
||||
la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** :
|
||||
`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que
|
||||
`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient
|
||||
comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par
|
||||
ailleurs que la racine.
|
||||
|
||||
Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test,
|
||||
`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur
|
||||
toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas
|
||||
contournée** en désactivant la gate ou en excluant les fichiers gênants.
|
||||
|
||||
## Dependabot
|
||||
|
||||
`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** :
|
||||
`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les
|
||||
deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc
|
||||
elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
|
||||
les tests ne se merge pas.
|
||||
|
||||
## Stratégie de branche et conventions
|
||||
|
||||
| Règle | Détail |
|
||||
|---|---|
|
||||
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
|
||||
| Messages de commit | Conventional Commits |
|
||||
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
|
||||
| Revue | Toute PR passe par une revue écrite avant merge |
|
||||
| ADR | Toute décision structurante porte son ADR dans la même PR |
|
||||
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
|
||||
|
||||
## Secrets
|
||||
|
||||
Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions.
|
||||
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
|
||||
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
|
||||
run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de
|
||||
déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
|
||||
l'issue #22.
|
||||
|
||||
## Ce qui manque, et pourquoi
|
||||
|
||||
| Manque | Issue | Conséquence assumée |
|
||||
|---|---|---|
|
||||
| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine |
|
||||
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
||||
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
|
||||
| Tests de charge | #47 | Aucun garde-fou de performance |
|
||||
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
|
||||
|
||||
## Reproduire la CI en local
|
||||
|
||||
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
|
||||
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration
|
||||
demandent une base : `make db-up` puis `uv run pytest -m integration`.
|
||||
|
||||
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
|
||||
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
|
||||
`ml`.
|
||||
@@ -15,10 +15,12 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
||||
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
|
||||
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
|
||||
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
||||
| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque |
|
||||
|
||||
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
|
||||
ci-dessus, tant que `monitoring/` ne contient que des `.gitkeep`. Elles en sortiront le jour où
|
||||
elles auront de la matière. Un fichier vide de plus n'aide personne.
|
||||
La CI/CD a désormais son document : cinq workflows et seize jobs, c'est assez de matière pour
|
||||
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
|
||||
toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura
|
||||
de la matière. Un fichier vide de plus n'aide personne.
|
||||
|
||||
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
|
||||
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
|
||||
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
|
||||
[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees).
|
||||
[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees).
|
||||
|
||||
| Element | Choix |
|
||||
|--------------|-----------------------------------------------|
|
||||
@@ -103,7 +103,7 @@ prevision (utile plus tard pour comparer prevision et realise, surveillance de d
|
||||
uv run ruff check . # lint
|
||||
uv run ruff format . # format
|
||||
uv run mypy enervision_ml tests # typage strict
|
||||
uv run pytest # tests
|
||||
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
|
||||
```
|
||||
|
||||
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
||||
|
||||
+11
-1
@@ -17,6 +17,7 @@ dev = [
|
||||
"ruff>=0.16.7",
|
||||
"mypy>=2.3.1",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-cov>=7.1.0",
|
||||
"pandas-stubs>=3.0.5.260914",
|
||||
]
|
||||
|
||||
@@ -75,5 +76,14 @@ ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --strict-markers -m 'not integration'"
|
||||
addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing"
|
||||
markers = ["integration: requiert une base PostgreSQL joignable"]
|
||||
|
||||
# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que
|
||||
# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau.
|
||||
[tool.coverage.run]
|
||||
source = ["enervision_ml"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
|
||||
Generated
+55
@@ -388,6 +388,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
@@ -494,6 +533,7 @@ dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -512,6 +552,7 @@ dev = [
|
||||
{ name = "mypy", specifier = ">=2.3.1" },
|
||||
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.16.7" },
|
||||
]
|
||||
|
||||
@@ -1591,6 +1632,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
||||
@@ -3,15 +3,17 @@ sonar.organization=groupe3-ener-vision
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Dossier contenant le code source
|
||||
sonar.sources=apps/frontend/src,apps/backend
|
||||
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
|
||||
# Dossier contenant les tests
|
||||
sonar.tests=apps/frontend/src,apps/backend/tests
|
||||
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
|
||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||
|
||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
||||
sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
||||
|
||||
# Chemin vers le rapport de couverture de code
|
||||
# Fichier généré par Pytest
|
||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
|
||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
|
||||
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag)
|
||||
sonar.coverage.exclusions=etl/airflow/**
|
||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||
|
||||
Reference in New Issue
Block a user