From b032f084fcc03078c13eb0f5a8d1629af00712c7 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:15:21 +0200 Subject: [PATCH 1/6] fix(apps): gere les caracteres encodes dans l'URL Alembic --- apps/backend/alembic/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index 7b09cae..a1a4adc 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -19,7 +19,7 @@ config = context.config if config.config_file_name is not None: fileConfig(config.config_file_name) -config.set_main_option("sqlalchemy.url", get_settings().database_url) +config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%")) target_metadata = Base.metadata From 128133761f7259dc19feb29c2823ceb641023d17 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:16:18 +0200 Subject: [PATCH 2/6] feat(apps): cree les six tables data et l'hypertable readings --- .../e6d2026091501_create_data_schema.py | 216 ++++++++++++++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/energy.py | 209 +++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 apps/backend/alembic/versions/e6d2026091501_create_data_schema.py create mode 100644 apps/backend/app/models/energy.py diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py new file mode 100644 index 0000000..87146d6 --- /dev/null +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -0,0 +1,216 @@ +"""Création des six tables Data et de l'hypertable readings. + +Revision ID: e6d2026091501 +Revises: 5353c0e4f094 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "e6d2026091501" +down_revision = "5353c0e4f094" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "datasets", + sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("dataset_name", sa.Text(), nullable=False), + sa.Column("archive_sha256", sa.String(length=64), nullable=False), + sa.Column("storage_uri", sa.Text(), nullable=False), + sa.Column("source_timezone", sa.Text(), nullable=True), + sa.Column( + "metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + sa.PrimaryKeyConstraint("dataset_id"), + sa.UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + ) + op.create_table( + "sites", + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("site_name", sa.Text(), nullable=False), + sa.Column("site_type", sa.Text(), nullable=False), + sa.Column("location", sa.Text(), nullable=True), + sa.Column("capacity_kw", sa.Double(), nullable=True), + sa.Column("status", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("site_id"), + ) + op.create_table( + "predictions", + sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("target_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("target_metric", sa.Text(), nullable=False), + sa.Column("period_minutes", sa.Integer(), nullable=True), + sa.Column("predicted_value", sa.Double(), nullable=True), + sa.Column("model_reference", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("failure_reason", sa.Text(), nullable=True), + sa.CheckConstraint( + "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)", + name="ck_predictions_status", + ), + sa.CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_predictions_energy_period", + ), + sa.CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_predictions_metric" + ), + sa.CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_predictions_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("prediction_id"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + ) + op.create_index( + "ix_predictions_site_target", "predictions", ["site_id", "target_at"], unique=False + ) + op.create_table( + "readings", + sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("dataset_id", sa.BigInteger(), nullable=True), + sa.Column("consumption_kw", sa.Double(), nullable=True), + sa.Column("consumption_kwh", sa.Double(), nullable=True), + sa.Column("consumption_euros", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("voltage_v", sa.Double(), nullable=True), + sa.Column("current_a", sa.Double(), nullable=True), + sa.Column("power_factor", sa.Double(), nullable=True), + sa.Column("temperature_celsius", sa.Double(), nullable=True), + sa.Column("humidity_percent", sa.Double(), nullable=True), + sa.Column("solar_irradiance_wm2", sa.Double(), nullable=True), + sa.Column("is_working_hours", sa.Boolean(), nullable=True), + sa.Column("data_quality", sa.Text(), nullable=True), + sa.Column("null_reasons", postgresql.ARRAY(sa.Text()), nullable=True), + sa.Column( + "imputed_values", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True + ), + sa.Column("imputation_method", sa.Text(), nullable=True), + sa.Column( + "ingested_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint( + "(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)", + name="ck_readings_dataset_source", + ), + sa.CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_readings_quality", + ), + sa.CheckConstraint( + "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + ), + sa.CheckConstraint( + "(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", + name="ck_readings_imputation", + ), + sa.ForeignKeyConstraint( + ["dataset_id"], ["datasets.dataset_id"], name="fk_readings_dataset", ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_readings_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("reading_id", "timestamp"), + ) + op.create_index("ix_readings_dataset_id", "readings", ["dataset_id"], unique=False) + op.create_index( + "ix_readings_site_timestamp", "readings", ["site_id", "timestamp"], unique=False + ) + op.create_index( + "uq_readings_source", + "readings", + ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], + unique=True, + ) + op.execute( + "SELECT create_hypertable('readings', by_range('timestamp'), create_default_indexes => FALSE)" + ) + op.create_table( + "alerts", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("alert_id", sa.Text(), nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("type", sa.Text(), nullable=False), + sa.Column("severity", sa.Text(), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("value", sa.Double(), nullable=True), + sa.Column("threshold", sa.Double(), nullable=True), + sa.Column("metric", sa.Text(), nullable=True), + sa.Column("prediction_id", sa.BigInteger(), nullable=True), + sa.Column( + "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + ), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + sa.CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + ), + sa.ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["predictions.prediction_id", "predictions.site_id"], + name="fk_alerts_prediction_site", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_alerts_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), + ) + op.create_index("ix_alerts_site_timestamp", "alerts", ["site_id", "timestamp"], unique=False) + op.create_table( + "recommendations", + sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("alert_id", sa.BigInteger(), nullable=False), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("explanation", sa.Text(), nullable=False), + sa.Column("rule_reference", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["alert_id"], ["alerts.id"], name="fk_recommendations_alert", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("recommendation_id"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + op.drop_table("recommendations") + op.drop_table("alerts") + op.drop_table("readings") + op.drop_table("predictions") + op.drop_table("sites") + op.drop_table("datasets") diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 6d71227..0f48e79 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,6 @@ # Piege : tout modele absent de ce module reste invisible de `alembic revision # --autogenerate`, qui genererait alors un drop de sa table. + +from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site + +__all__ = ["Alert", "Dataset", "Prediction", "Reading", "Recommendation", "Site"] diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py new file mode 100644 index 0000000..de27c7c --- /dev/null +++ b/apps/backend/app/models/energy.py @@ -0,0 +1,209 @@ +"""Tables du modèle de données EnerVision (CSV, API Mock et résultats ML).""" + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + Double, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Dataset(Base): + __tablename__ = "datasets" + __table_args__ = ( + CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + ) + + dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dataset_name: Mapped[str] = mapped_column(Text) + archive_sha256: Mapped[str] = mapped_column(String(64)) + storage_uri: Mapped[str] = mapped_column(Text) + source_timezone: Mapped[str | None] = mapped_column(Text) + # "metadata" est réservé par SQLAlchemy ; le nom SQL reste inchangé. + dataset_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB(none_as_null=True)) + + +class Site(Base): + __tablename__ = "sites" + + site_id: Mapped[str] = mapped_column(Text, primary_key=True) + site_name: Mapped[str] = mapped_column(Text) + site_type: Mapped[str] = mapped_column(Text) + location: Mapped[str | None] = mapped_column(Text) + capacity_kw: Mapped[float | None] = mapped_column(Double) + status: Mapped[str | None] = mapped_column(Text) + + +class Reading(Base): + __tablename__ = "readings" + __table_args__ = ( + CheckConstraint( + "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + ), + CheckConstraint( + "(source = 'csv' AND dataset_id IS NOT NULL) OR " + "(source IN ('api_current', 'api_history') AND dataset_id IS NULL)", + name="ck_readings_dataset_source", + ), + CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_readings_quality", + ), + CheckConstraint( + "(imputed_values IS NULL AND imputation_method IS NULL) OR " + "(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", + name="ck_readings_imputation", + ), + Index("ix_readings_site_timestamp", "site_id", "timestamp"), + Index("ix_readings_dataset_id", "dataset_id"), + ) + + reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_readings_site", ondelete="RESTRICT") + ) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True) + source: Mapped[str] = mapped_column(Text) + dataset_id: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("datasets.dataset_id", name="fk_readings_dataset", ondelete="RESTRICT"), + ) + consumption_kw: Mapped[float | None] = mapped_column(Double) + consumption_kwh: Mapped[float | None] = mapped_column(Double) + consumption_euros: Mapped[Decimal | None] = mapped_column(Numeric(14, 2)) + voltage_v: Mapped[float | None] = mapped_column(Double) + current_a: Mapped[float | None] = mapped_column(Double) + power_factor: Mapped[float | None] = mapped_column(Double) + temperature_celsius: Mapped[float | None] = mapped_column(Double) + humidity_percent: Mapped[float | None] = mapped_column(Double) + solar_irradiance_wm2: Mapped[float | None] = mapped_column(Double) + is_working_hours: Mapped[bool | None] = mapped_column(Boolean) + data_quality: Mapped[str | None] = mapped_column(Text) + null_reasons: Mapped[list[str] | None] = mapped_column(ARRAY(Text)) + imputed_values: Mapped[dict[str, Any] | None] = mapped_column(JSONB(none_as_null=True)) + imputation_method: Mapped[str | None] = mapped_column(Text) + ingested_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True)) + + +Index( + "uq_readings_source", + Reading.site_id, + Reading.timestamp, + Reading.source, + func.coalesce(Reading.dataset_id, text("0")), + unique=True, +) + + +class Prediction(Base): + __tablename__ = "predictions" + __table_args__ = ( + UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + Index("ix_predictions_site_target", "site_id", "target_at"), + CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", + name="ck_predictions_metric", + ), + CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + ), + CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_predictions_energy_period", + ), + CheckConstraint( + "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR " + "(status IN ('insufficient_data', 'error') AND predicted_value IS NULL " + "AND failure_reason IS NOT NULL)", + name="ck_predictions_status", + ), + ) + + prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_predictions_site", ondelete="RESTRICT") + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + target_metric: Mapped[str] = mapped_column(Text) + period_minutes: Mapped[int | None] = mapped_column(Integer) + predicted_value: Mapped[float | None] = mapped_column(Double) + model_reference: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + failure_reason: Mapped[str | None] = mapped_column(Text) + + +class Alert(Base): + __tablename__ = "alerts" + __table_args__ = ( + UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), + Index("ix_alerts_site_timestamp", "site_id", "timestamp"), + ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["predictions.prediction_id", "predictions.site_id"], + name="fk_alerts_prediction_site", + ondelete="RESTRICT", + ), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + ), + CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + alert_id: Mapped[str] = mapped_column(Text) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_alerts_site", ondelete="RESTRICT") + ) + source: Mapped[str] = mapped_column(Text) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + type: Mapped[str] = mapped_column(Text) + severity: Mapped[str] = mapped_column(Text) + message: Mapped[str] = mapped_column(Text) + value: Mapped[float | None] = mapped_column(Double) + threshold: Mapped[float | None] = mapped_column(Double) + metric: Mapped[str | None] = mapped_column(Text) + prediction_id: Mapped[int | None] = mapped_column(BigInteger) + raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True)) + + +class Recommendation(Base): + __tablename__ = "recommendations" + __table_args__ = ( + UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + ) + + recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + alert_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("alerts.id", name="fk_recommendations_alert", ondelete="RESTRICT") + ) + action: Mapped[str] = mapped_column(Text) + explanation: Mapped[str] = mapped_column(Text) + rule_reference: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) From c04ce9a9aecb19fbb67cace3bcd285cf939a22bc Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:16:40 +0200 Subject: [PATCH 3/6] test(apps): verifie les contraintes du schema data --- apps/backend/tests/db/test_data_schema.py | 261 ++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 apps/backend/tests/db/test_data_schema.py diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py new file mode 100644 index 0000000..aefc9fa --- /dev/null +++ b/apps/backend/tests/db/test_data_schema.py @@ -0,0 +1,261 @@ +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import insert, select, text +from sqlalchemy.engine import make_url +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings +from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site + +pytestmark = pytest.mark.integration +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +@pytest.fixture +async def data_connection() -> AsyncIterator[AsyncConnection]: + url = make_url(get_settings().database_url) + if url.database != "enervision_test": + pytest.fail("Ces tests exigent DATABASE_URL vers enervision_test.") + engine = create_async_engine(url) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + try: + yield connection + finally: + await transaction.rollback() + finally: + await engine.dispose() + + +@pytest.fixture +async def data_site(data_connection: AsyncConnection) -> str: + site_id = f"TEST-{uuid4()}" + await data_connection.execute( + insert(Site).values(site_id=site_id, site_name="Site de test", site_type="office") + ) + return site_id + + +async def test_readings_is_a_time_hypertable_when_migrated( + data_connection: AsyncConnection, +) -> None: + query = text( + "SELECT column_name FROM timescaledb_information.dimensions " + "WHERE hypertable_schema = 'public' AND hypertable_name = 'readings'" + ) + + result = await data_connection.execute(query) + + assert result.scalars().all() == ["timestamp"] + + +async def test_reading_preserves_null_and_zero_when_inserted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = insert(Reading).values( + site_id=data_site, + timestamp=MOMENT, + source="api_current", + consumption_kw=None, + consumption_kwh=0, + data_quality="partial", + null_reasons=["sensor_failure"], + raw_data={"consumption_kw": None}, + imputed_values=None, + imputation_method=None, + ) + + await data_connection.execute(statement) + result = ( + await data_connection.execute( + select( + Reading.consumption_kw, + Reading.consumption_kwh, + Reading.raw_data, + Reading.imputed_values, + ).where(Reading.site_id == data_site) + ) + ).one() + + assert tuple(result) == (None, 0, {"consumption_kw": None}, None) + + +@pytest.mark.parametrize("source", ["csv", "api_current", "api_history"]) +async def test_duplicate_reading_is_rejected_when_key_matches( + data_connection: AsyncConnection, data_site: str, source: str +) -> None: + dataset_id = None + if source == "csv": + dataset_id = ( + await data_connection.execute( + insert(Dataset.__table__) + .values( + dataset_name="Archive de test", + archive_sha256=uuid4().hex + uuid4().hex, + storage_uri="test://archive", + metadata={}, + ) + .returning(Dataset.dataset_id) + ) + ).scalar_one() + statement = insert(Reading).values( + site_id=data_site, + timestamp=MOMENT, + source=source, + dataset_id=dataset_id, + raw_data={}, + ) + await data_connection.execute(statement) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) + + +@pytest.mark.parametrize( + "changes", + [ + {"source": "csv"}, + {"source": "unknown"}, + {"site_id": "UNKNOWN-SITE"}, + {"data_quality": "unknown"}, + {"imputed_values": {"consumption_kw": 12}}, + {"imputation_method": "mean-v1"}, + ], + ids=[ + "csv_sans_dataset", + "source_inconnue", + "site_absent", + "qualite_inconnue", + "imputation_sans_methode", + "methode_sans_imputation", + ], +) +async def test_invalid_reading_is_rejected_when_constraints_fail( + data_connection: AsyncConnection, data_site: str, changes: dict[str, object] +) -> None: + values: dict[str, object] = { + "site_id": data_site, + "timestamp": MOMENT, + "source": "api_current", + "raw_data": {}, + } + values.update(changes) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(insert(Reading).values(**values)) + + +async def test_prediction_requires_period_when_energy_is_predicted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = insert(Prediction).values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kwh", + predicted_value=12, + status="available", + model_reference="test-model/1", + ) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) + + +async def test_unavailable_prediction_preserves_null_when_inserted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = ( + insert(Prediction) + .values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kw", + status="insufficient_data", + failure_reason="Historique trop court", + model_reference="test-model/1", + ) + .returning(Prediction.predicted_value) + ) + + value = (await data_connection.execute(statement)).scalar_one() + + assert value is None + + +async def test_alert_rejects_prediction_when_site_differs( + data_connection: AsyncConnection, data_site: str +) -> None: + other_site = f"TEST-{uuid4()}" + await data_connection.execute( + insert(Site).values(site_id=other_site, site_name="Autre site", site_type="office") + ) + prediction_id = ( + await data_connection.execute( + insert(Prediction) + .values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kw", + predicted_value=12, + status="available", + model_reference="test-model/1", + ) + .returning(Prediction.prediction_id) + ) + ).scalar_one() + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute( + insert(Alert).values( + alert_id=str(uuid4()), + site_id=other_site, + source="enervision", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + prediction_id=prediction_id, + raw_data={}, + ) + ) + + +async def test_recommendation_is_unique_when_alert_and_rule_match( + data_connection: AsyncConnection, data_site: str +) -> None: + alert_id = ( + await data_connection.execute( + insert(Alert) + .values( + alert_id=str(uuid4()), + site_id=data_site, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + .returning(Alert.id) + ) + ).scalar_one() + statement = insert(Recommendation).values( + alert_id=alert_id, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + ) + await data_connection.execute(statement) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) From e3e0e843d086c7b783763e4f9af135e9b2d0191f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 16:46:37 +0200 Subject: [PATCH 4/6] fix(backend): rebranche la revision data sur la tete d'authentification Le merge de dev apporte trois revisions d'authentification qui partent de la meme racine 5353c0e4f094 que la revision data. Git ne signale rien, mais alembic upgrade head refuse de choisir entre deux tetes. La revision data se greffe desormais sur 821f71be74c0, ce qui rend la chaine lineaire. --- .../alembic/versions/e6d2026091501_create_data_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py index 87146d6..50cc41f 100644 --- a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -1,7 +1,7 @@ """Création des six tables Data et de l'hypertable readings. Revision ID: e6d2026091501 -Revises: 5353c0e4f094 +Revises: 821f71be74c0 """ from alembic import op @@ -9,7 +9,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql revision = "e6d2026091501" -down_revision = "5353c0e4f094" +down_revision = "821f71be74c0" branch_labels = None depends_on = None From c733ccfc62d9c6e1d1f357fcd41615d3397d46a3 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:41:48 +0200 Subject: [PATCH 5/6] refactor(backend): passe les tables data au singulier et clarifie alert_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La convention de docs/architecture/40-data.md impose des noms de tables au singulier, que les quatre tables d'authentification respectent déjà. Les six tables data passent donc au singulier, avec leurs contraintes et leurs index. La révision n'étant appliquée que sur des bases locales, elle est modifiée sur place plutôt que doublée d'une migration de renommage. alert_id désignait deux colonnes différentes : la clé métier text de l'API Mock et la clé étrangère bigint de recommendation. La première devient source_alert_id, la seconde pointe désormais vers alert.alert_id. --- .../e6d2026091501_create_data_schema.py | 94 ++++++++++--------- apps/backend/app/models/energy.py | 75 ++++++++------- apps/backend/tests/db/test_data_schema.py | 10 +- 3 files changed, 92 insertions(+), 87 deletions(-) diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py index 50cc41f..8fb3694 100644 --- a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -1,4 +1,4 @@ -"""Création des six tables Data et de l'hypertable readings. +"""Création des six tables Data et de l'hypertable reading. Revision ID: e6d2026091501 Revises: 821f71be74c0 @@ -17,7 +17,7 @@ depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "datasets", + "dataset", sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("dataset_name", sa.Text(), nullable=False), sa.Column("archive_sha256", sa.String(length=64), nullable=False), @@ -26,12 +26,12 @@ def upgrade() -> None: sa.Column( "metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False ), - sa.CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + sa.CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"), sa.PrimaryKeyConstraint("dataset_id"), - sa.UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + sa.UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), ) op.create_table( - "sites", + "site", sa.Column("site_id", sa.Text(), nullable=False), sa.Column("site_name", sa.Text(), nullable=False), sa.Column("site_type", sa.Text(), nullable=False), @@ -41,7 +41,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("site_id"), ) op.create_table( - "predictions", + "prediction", sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column( @@ -59,29 +59,29 @@ def upgrade() -> None: sa.Column("failure_reason", sa.Text(), nullable=True), sa.CheckConstraint( "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)", - name="ck_predictions_status", + name="ck_prediction_status", ), sa.CheckConstraint( "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", - name="ck_predictions_energy_period", + name="ck_prediction_energy_period", ), sa.CheckConstraint( - "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_predictions_metric" + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_prediction_metric" ), sa.CheckConstraint( - "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_predictions_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_prediction_site", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("prediction_id"), - sa.UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), ) op.create_index( - "ix_predictions_site_target", "predictions", ["site_id", "target_at"], unique=False + "ix_prediction_site_target", "prediction", ["site_id", "target_at"], unique=False ) op.create_table( - "readings", + "reading", sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), @@ -114,44 +114,44 @@ def upgrade() -> None: ), sa.CheckConstraint( "(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)", - name="ck_readings_dataset_source", + name="ck_reading_dataset_source", ), sa.CheckConstraint( "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", - name="ck_readings_quality", + name="ck_reading_quality", ), sa.CheckConstraint( - "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + "source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source" ), sa.CheckConstraint( "(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", - name="ck_readings_imputation", + name="ck_reading_imputation", ), sa.ForeignKeyConstraint( - ["dataset_id"], ["datasets.dataset_id"], name="fk_readings_dataset", ondelete="RESTRICT" + ["dataset_id"], ["dataset.dataset_id"], name="fk_reading_dataset", ondelete="RESTRICT" ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_readings_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_reading_site", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("reading_id", "timestamp"), ) - op.create_index("ix_readings_dataset_id", "readings", ["dataset_id"], unique=False) + op.create_index("ix_reading_dataset_id", "reading", ["dataset_id"], unique=False) op.create_index( - "ix_readings_site_timestamp", "readings", ["site_id", "timestamp"], unique=False + "ix_reading_site_timestamp", "reading", ["site_id", "timestamp"], unique=False ) op.create_index( - "uq_readings_source", - "readings", + "uq_reading_source", + "reading", ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], unique=True, ) op.execute( - "SELECT create_hypertable('readings', by_range('timestamp'), create_default_indexes => FALSE)" + "SELECT create_hypertable('reading', by_range('timestamp'), create_default_indexes => FALSE)" ) op.create_table( - "alerts", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("alert_id", sa.Text(), nullable=False), + "alert", + sa.Column("alert_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("source_alert_id", sa.Text(), nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column("source", sa.Text(), nullable=False), sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), @@ -166,27 +166,29 @@ def upgrade() -> None: "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False ), sa.CheckConstraint( - "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" ), - sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), sa.CheckConstraint( - "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" ), sa.ForeignKeyConstraint( ["prediction_id", "site_id"], - ["predictions.prediction_id", "predictions.site_id"], - name="fk_alerts_prediction_site", + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_alerts_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_alert_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("alert_id"), + sa.UniqueConstraint( + "source", "site_id", "source_alert_id", name="uq_alert_source_reference" ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), ) - op.create_index("ix_alerts_site_timestamp", "alerts", ["site_id", "timestamp"], unique=False) + op.create_index("ix_alert_site_timestamp", "alert", ["site_id", "timestamp"], unique=False) op.create_table( - "recommendations", + "recommendation", sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("alert_id", sa.BigInteger(), nullable=False), sa.Column("action", sa.Text(), nullable=False), @@ -199,18 +201,18 @@ def upgrade() -> None: nullable=False, ), sa.ForeignKeyConstraint( - ["alert_id"], ["alerts.id"], name="fk_recommendations_alert", ondelete="RESTRICT" + ["alert_id"], ["alert.alert_id"], name="fk_recommendation_alert", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("recommendation_id"), - sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), ) # ### end Alembic commands ### def downgrade() -> None: - op.drop_table("recommendations") - op.drop_table("alerts") - op.drop_table("readings") - op.drop_table("predictions") - op.drop_table("sites") - op.drop_table("datasets") + op.drop_table("recommendation") + op.drop_table("alert") + op.drop_table("reading") + op.drop_table("prediction") + op.drop_table("site") + op.drop_table("dataset") diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index de27c7c..578ca50 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -28,10 +28,10 @@ from app.db.base import Base class Dataset(Base): - __tablename__ = "datasets" + __tablename__ = "dataset" __table_args__ = ( - CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), - UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"), + UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), ) dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) @@ -44,7 +44,7 @@ class Dataset(Base): class Site(Base): - __tablename__ = "sites" + __tablename__ = "site" site_id: Mapped[str] = mapped_column(Text, primary_key=True) site_name: Mapped[str] = mapped_column(Text) @@ -55,38 +55,38 @@ class Site(Base): class Reading(Base): - __tablename__ = "readings" + __tablename__ = "reading" __table_args__ = ( CheckConstraint( - "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + "source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source" ), CheckConstraint( "(source = 'csv' AND dataset_id IS NOT NULL) OR " "(source IN ('api_current', 'api_history') AND dataset_id IS NULL)", - name="ck_readings_dataset_source", + name="ck_reading_dataset_source", ), CheckConstraint( "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", - name="ck_readings_quality", + name="ck_reading_quality", ), CheckConstraint( "(imputed_values IS NULL AND imputation_method IS NULL) OR " "(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", - name="ck_readings_imputation", + name="ck_reading_imputation", ), - Index("ix_readings_site_timestamp", "site_id", "timestamp"), - Index("ix_readings_dataset_id", "dataset_id"), + Index("ix_reading_site_timestamp", "site_id", "timestamp"), + Index("ix_reading_dataset_id", "dataset_id"), ) reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_readings_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_reading_site", ondelete="RESTRICT") ) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True) source: Mapped[str] = mapped_column(Text) dataset_id: Mapped[int | None] = mapped_column( BigInteger, - ForeignKey("datasets.dataset_id", name="fk_readings_dataset", ondelete="RESTRICT"), + ForeignKey("dataset.dataset_id", name="fk_reading_dataset", ondelete="RESTRICT"), ) consumption_kw: Mapped[float | None] = mapped_column(Double) consumption_kwh: Mapped[float | None] = mapped_column(Double) @@ -109,7 +109,7 @@ class Reading(Base): Index( - "uq_readings_source", + "uq_reading_source", Reading.site_id, Reading.timestamp, Reading.source, @@ -119,32 +119,32 @@ Index( class Prediction(Base): - __tablename__ = "predictions" + __tablename__ = "prediction" __table_args__ = ( - UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), - Index("ix_predictions_site_target", "site_id", "target_at"), + UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), + Index("ix_prediction_site_target", "site_id", "target_at"), CheckConstraint( "target_metric IN ('consumption_kwh', 'consumption_kw')", - name="ck_predictions_metric", + name="ck_prediction_metric", ), CheckConstraint( - "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" ), CheckConstraint( "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", - name="ck_predictions_energy_period", + name="ck_prediction_energy_period", ), CheckConstraint( "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR " "(status IN ('insufficient_data', 'error') AND predicted_value IS NULL " "AND failure_reason IS NOT NULL)", - name="ck_predictions_status", + name="ck_prediction_status", ), ) prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_predictions_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_prediction_site", ondelete="RESTRICT") ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -157,29 +157,31 @@ class Prediction(Base): class Alert(Base): - __tablename__ = "alerts" + __tablename__ = "alert" __table_args__ = ( - UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), - Index("ix_alerts_site_timestamp", "site_id", "timestamp"), + UniqueConstraint( + "source", "site_id", "source_alert_id", name="uq_alert_source_reference" + ), + Index("ix_alert_site_timestamp", "site_id", "timestamp"), ForeignKeyConstraint( ["prediction_id", "site_id"], - ["predictions.prediction_id", "predictions.site_id"], - name="fk_alerts_prediction_site", + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", ondelete="RESTRICT", ), - CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), CheckConstraint( - "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" ), CheckConstraint( - "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" ), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - alert_id: Mapped[str] = mapped_column(Text) + alert_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + source_alert_id: Mapped[str] = mapped_column(Text) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_alerts_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_alert_site", ondelete="RESTRICT") ) source: Mapped[str] = mapped_column(Text) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -194,14 +196,15 @@ class Alert(Base): class Recommendation(Base): - __tablename__ = "recommendations" + __tablename__ = "recommendation" __table_args__ = ( - UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), ) recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) alert_id: Mapped[int] = mapped_column( - BigInteger, ForeignKey("alerts.id", name="fk_recommendations_alert", ondelete="RESTRICT") + BigInteger, + ForeignKey("alert.alert_id", name="fk_recommendation_alert", ondelete="RESTRICT"), ) action: Mapped[str] = mapped_column(Text) explanation: Mapped[str] = mapped_column(Text) diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py index aefc9fa..c564042 100644 --- a/apps/backend/tests/db/test_data_schema.py +++ b/apps/backend/tests/db/test_data_schema.py @@ -41,12 +41,12 @@ async def data_site(data_connection: AsyncConnection) -> str: return site_id -async def test_readings_is_a_time_hypertable_when_migrated( +async def test_reading_is_a_time_hypertable_when_migrated( data_connection: AsyncConnection, ) -> None: query = text( "SELECT column_name FROM timescaledb_information.dimensions " - "WHERE hypertable_schema = 'public' AND hypertable_name = 'readings'" + "WHERE hypertable_schema = 'public' AND hypertable_name = 'reading'" ) result = await data_connection.execute(query) @@ -216,7 +216,7 @@ async def test_alert_rejects_prediction_when_site_differs( async with data_connection.begin_nested(): await data_connection.execute( insert(Alert).values( - alert_id=str(uuid4()), + source_alert_id=str(uuid4()), site_id=other_site, source="enervision", timestamp=MOMENT, @@ -236,7 +236,7 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( await data_connection.execute( insert(Alert) .values( - alert_id=str(uuid4()), + source_alert_id=str(uuid4()), site_id=data_site, source="api_mock", timestamp=MOMENT, @@ -245,7 +245,7 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( message="Test", raw_data={}, ) - .returning(Alert.id) + .returning(Alert.alert_id) ) ).scalar_one() statement = insert(Recommendation).values( From 3eb5a0e8dc08a9b77d07c29aa93bc65f1409a9fb Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:43:12 +0200 Subject: [PATCH 6/6] style(backend): applique ruff format au modele data La cible make check ne lance que ruff check ; la CI lance en plus ruff format --check, qui refusait la contrainte unique repliee. --- apps/backend/app/models/energy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index 578ca50..285ad26 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -159,9 +159,7 @@ class Prediction(Base): class Alert(Base): __tablename__ = "alert" __table_args__ = ( - UniqueConstraint( - "source", "site_id", "source_alert_id", name="uq_alert_source_reference" - ), + UniqueConstraint("source", "site_id", "source_alert_id", name="uq_alert_source_reference"), Index("ix_alert_site_timestamp", "site_id", "timestamp"), ForeignKeyConstraint( ["prediction_id", "site_id"],