M7-T01: add meter table + model + energy_cost_period.meter_id + backfill migration
This commit is contained in:
@@ -0,0 +1,238 @@
|
|||||||
|
"""add meter table and energy_cost_period.meter_id
|
||||||
|
|
||||||
|
Introduces the ``meter`` table (one row per physical meter installation epoch)
|
||||||
|
and a nullable FK column ``energy_cost_period.meter_id`` that attributes each
|
||||||
|
billing period to a specific physical meter.
|
||||||
|
|
||||||
|
**Backfill logic (§3.7 of the M7 design doc)**:
|
||||||
|
|
||||||
|
If the database already contains any ``dsmr_reading`` or
|
||||||
|
``energy_cost_period`` rows, one initial ``meter`` row is created:
|
||||||
|
|
||||||
|
label = "Initial meter"
|
||||||
|
commodity = "electricity"
|
||||||
|
started_at = earliest dsmr_reading.recorded_at
|
||||||
|
(or, if none, earliest energy_cost_period.period_start,
|
||||||
|
or, if still none, the migration timestamp)
|
||||||
|
ended_at = NULL (still active)
|
||||||
|
reason = "initial"
|
||||||
|
|
||||||
|
All existing ``energy_cost_period`` rows are then back-filled with that
|
||||||
|
initial meter's id.
|
||||||
|
|
||||||
|
**Idempotency**: the backfill is guarded with a check for any existing
|
||||||
|
``meter`` row whose ``reason = 'initial'`` and ``commodity = 'electricity'``
|
||||||
|
and ``ended_at IS NULL``, so repeating the upgrade does not create duplicate
|
||||||
|
meters or overwrite already-filled meter_id values.
|
||||||
|
|
||||||
|
**Audit (on-non-degraded periods only)**: after backfilling, the number of
|
||||||
|
non-degraded ``energy_cost_period`` rows with ``meter_id IS NULL`` must be
|
||||||
|
zero; if it is not, the migration raises a ``RuntimeError`` and rolls back.
|
||||||
|
|
||||||
|
**Data safety**: this migration is additive only — no existing rows are
|
||||||
|
deleted or overwritten; it only creates a new table, adds a nullable column,
|
||||||
|
and back-fills that column.
|
||||||
|
|
||||||
|
Revision ID: 20260625_13_meter_table
|
||||||
|
Revises: 20260624_12_dsmr_decouple_telegram_id
|
||||||
|
Create Date: 2026-06-25 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260625_13_meter_table"
|
||||||
|
down_revision: Union[str, None] = "20260624_12_dsmr_decouple_telegram_id"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 1. Create the meter table. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"meter",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("commodity", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("reason", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("note", sa.String(length=1024), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 2. Add meter_id column to energy_cost_period (nullable FK). #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("meter_id", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
"fk_energy_cost_period_meter_id",
|
||||||
|
"meter",
|
||||||
|
["meter_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 3. Backfill initial meter (idempotent). #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Check if there is already an initial meter (idempotency guard).
|
||||||
|
existing_initial = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id FROM meter "
|
||||||
|
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
|
||||||
|
"LIMIT 1"
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing_initial is not None:
|
||||||
|
# Already backfilled — nothing to do.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine whether there is any historical data to create a meter for.
|
||||||
|
has_readings = conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM dsmr_reading LIMIT 1")
|
||||||
|
).fetchone()
|
||||||
|
has_periods = conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM energy_cost_period LIMIT 1")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not has_readings and not has_periods:
|
||||||
|
# Empty database: no historical data, so no initial meter is needed.
|
||||||
|
# meter_id will remain NULL on any future rows until T02 service layer
|
||||||
|
# starts populating it.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine started_at: earliest dsmr_reading.recorded_at, falling back to
|
||||||
|
# earliest energy_cost_period.period_start, and finally to now().
|
||||||
|
earliest_reading_row = conn.execute(
|
||||||
|
sa.text("SELECT MIN(recorded_at) AS ts FROM dsmr_reading")
|
||||||
|
).fetchone()
|
||||||
|
earliest_period_row = conn.execute(
|
||||||
|
sa.text("SELECT MIN(period_start) AS ts FROM energy_cost_period")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
started_at_value: datetime | None = None
|
||||||
|
if earliest_reading_row and earliest_reading_row[0] is not None:
|
||||||
|
# SQLite returns ISO strings for datetime columns; parse to datetime.
|
||||||
|
raw = earliest_reading_row[0]
|
||||||
|
started_at_value = _parse_sqlite_datetime(raw)
|
||||||
|
if started_at_value is None and earliest_period_row and earliest_period_row[0] is not None:
|
||||||
|
raw = earliest_period_row[0]
|
||||||
|
started_at_value = _parse_sqlite_datetime(raw)
|
||||||
|
if started_at_value is None:
|
||||||
|
started_at_value = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
now_utc = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
# Insert the initial meter row.
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO meter (label, commodity, started_at, ended_at, reason, note, created_at) "
|
||||||
|
"VALUES (:label, :commodity, :started_at, NULL, :reason, NULL, :created_at)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"label": "Initial meter",
|
||||||
|
"commodity": "electricity",
|
||||||
|
"started_at": _iso(started_at_value),
|
||||||
|
"reason": "initial",
|
||||||
|
"created_at": _iso(now_utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve the newly created meter id.
|
||||||
|
meter_row = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id FROM meter "
|
||||||
|
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
|
||||||
|
"LIMIT 1"
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
|
assert meter_row is not None, "Initial meter row not found after insert"
|
||||||
|
meter_id: int = meter_row[0]
|
||||||
|
|
||||||
|
# Back-fill all existing energy_cost_period rows that have meter_id IS NULL.
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE energy_cost_period SET meter_id = :mid WHERE meter_id IS NULL"
|
||||||
|
),
|
||||||
|
{"mid": meter_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 4. Audit: verify all non-degraded periods have a meter_id. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
unmatched_row = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT COUNT(*) FROM energy_cost_period "
|
||||||
|
"WHERE meter_id IS NULL AND degraded = 0"
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
|
unmatched_count: int = unmatched_row[0] if unmatched_row else 0
|
||||||
|
|
||||||
|
if unmatched_count != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Meter backfill audit failed: {unmatched_count} non-degraded "
|
||||||
|
"energy_cost_period row(s) still have meter_id IS NULL after backfill. "
|
||||||
|
"Migration aborted to protect data integrity."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove the FK column from energy_cost_period first (references meter).
|
||||||
|
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("fk_energy_cost_period_meter_id", type_="foreignkey")
|
||||||
|
batch_op.drop_column("meter_id")
|
||||||
|
|
||||||
|
# Drop the meter table.
|
||||||
|
op.drop_table("meter")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sqlite_datetime(value: str | datetime) -> datetime:
|
||||||
|
"""Parse a SQLite datetime value into an aware UTC datetime.
|
||||||
|
|
||||||
|
SQLite stores datetimes as ISO 8601 strings. SQLAlchemy may return them
|
||||||
|
as plain strings or as naive datetimes (no tzinfo) depending on the driver
|
||||||
|
and column declaration. This helper normalises both forms to an aware UTC
|
||||||
|
``datetime``.
|
||||||
|
"""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
# String form — strip trailing Z or +00:00 variants, then attach UTC.
|
||||||
|
s = str(value).strip()
|
||||||
|
for suffix in ("+00:00", "Z", " UTC"):
|
||||||
|
if s.endswith(suffix):
|
||||||
|
s = s[: -len(suffix)]
|
||||||
|
# SQLite uses space as the T separator in some formats.
|
||||||
|
s = s.replace(" ", "T")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
# Fallback: strip subseconds if present to handle unusual formats.
|
||||||
|
dt = datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(dt: datetime) -> str:
|
||||||
|
"""Serialise a datetime to an ISO 8601 string for SQLite storage."""
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
+69
-1
@@ -1,6 +1,7 @@
|
|||||||
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
|
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
|
||||||
|
|
||||||
Five tables:
|
Six tables:
|
||||||
|
- meter: physical electricity meter lifecycle epoch.
|
||||||
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
|
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
|
||||||
- energy_contract: contract head (manual or tibber, one active at a time).
|
- energy_contract: contract head (manual or tibber, one active at a time).
|
||||||
- energy_contract_version: versioned pricing values; append-only for auditability.
|
- energy_contract_version: versioned pricing values; append-only for auditability.
|
||||||
@@ -19,6 +20,62 @@ from sqlalchemy.types import JSON
|
|||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Meter(Base):
|
||||||
|
"""One physical electricity meter's installation epoch.
|
||||||
|
|
||||||
|
A ``meter`` record represents the period ``[started_at, ended_at)`` during
|
||||||
|
which a particular physical meter was installed and active. Replacing a meter
|
||||||
|
(swap, home move, etc.) is modelled by closing the current record
|
||||||
|
(``ended_at = swap_timestamp``) and opening a new one
|
||||||
|
(``started_at = swap_timestamp``).
|
||||||
|
|
||||||
|
**Invariant**: for each ``commodity`` there is at most one active meter
|
||||||
|
(``ended_at IS NULL``) at any point in time. The service layer enforces
|
||||||
|
this — no DB-level constraint is added to keep the migration simple and to
|
||||||
|
allow the application to return a meaningful error message.
|
||||||
|
|
||||||
|
``commodity`` defaults to ``"electricity"``; the field is a free-form string
|
||||||
|
(no CHECK constraint) so future commodities (``gas``, ``heating``) can be
|
||||||
|
added without a schema change.
|
||||||
|
|
||||||
|
``reason`` captures why this epoch started — one of ``initial``,
|
||||||
|
``meter_swap``, ``home_move``, or ``other`` — stored as a plain string so
|
||||||
|
the application layer controls the allowed set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "meter"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Energy commodity this meter measures. Defaults to "electricity".
|
||||||
|
commodity: Mapped[str] = mapped_column(String(32), nullable=False, default="electricity")
|
||||||
|
|
||||||
|
# UTC timestamp when this meter epoch starts (inclusive). May be in the past
|
||||||
|
# (retroactive declaration); effective billing start = max(started_at, data start).
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# UTC timestamp when this meter epoch ends (exclusive). NULL = currently active.
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Why this epoch was created. Application-layer validation enforces the
|
||||||
|
# allowed set; no CHECK constraint to keep migrations simple.
|
||||||
|
reason: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|
||||||
|
# Free-form note (e.g. location, physical meter id, reason details).
|
||||||
|
note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|
||||||
|
# UTC timestamp of when this row was created.
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# Relationship to cost periods attributed to this meter epoch (not loaded eagerly).
|
||||||
|
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
|
||||||
|
back_populates="meter", cascade="save-update, merge"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DsmrReading(Base):
|
class DsmrReading(Base):
|
||||||
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
||||||
|
|
||||||
@@ -217,6 +274,14 @@ class EnergyCostPeriod(Base):
|
|||||||
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# FK to the meter epoch this period belongs to. RESTRICT prevents deletion of
|
||||||
|
# a meter that still has attributed cost periods. Nullable for backwards
|
||||||
|
# compatibility (pre-M7 rows) and degraded periods where the meter was not
|
||||||
|
# determinable.
|
||||||
|
meter_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# True when the period was computed with incomplete data (missing readings or
|
# True when the period was computed with incomplete data (missing readings or
|
||||||
# missing price); serves as a flag for later recomputation.
|
# missing price); serves as a flag for later recomputation.
|
||||||
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
@@ -229,6 +294,9 @@ class EnergyCostPeriod(Base):
|
|||||||
back_populates="cost_periods"
|
back_populates="cost_periods"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Relationship back to the meter epoch.
|
||||||
|
meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods")
|
||||||
|
|
||||||
|
|
||||||
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
# Index on recorded_at for efficient time-range queries on DSMR readings.
|
||||||
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260624_12_dsmr_decouple_telegram_id"
|
APP_BASELINE_REVISION = "20260625_13_meter_table"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
+443
-9
@@ -1,13 +1,15 @@
|
|||||||
"""Tests for M6-T01: energy pricing and DSMR metering tables and ORM models.
|
"""Tests for energy pricing, DSMR metering, and meter epoch tables and ORM models.
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
1. Migration shape: upgrade to head creates all five tables with correct columns,
|
1. Migration shape: upgrade to head creates all six tables with correct columns,
|
||||||
constraints, and indexes; downgrade -1 cleanly removes them.
|
constraints, and indexes; downgrade -1 cleanly removes them.
|
||||||
2. ORM metadata: Base.metadata.tables contains all five tables; FKs are RESTRICT;
|
2. ORM metadata: Base.metadata.tables contains all six tables; FKs are RESTRICT;
|
||||||
JSON columns are present; unique and index constraints are correct.
|
JSON columns are present; unique and index constraints are correct.
|
||||||
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
|
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
|
||||||
4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip.
|
4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip.
|
||||||
5. FK RESTRICT: deleting a referenced parent row must fail when children exist.
|
5. FK RESTRICT: deleting a referenced parent row must fail when children exist.
|
||||||
|
6. Meter model: fields, nullability, FK from energy_cost_period.
|
||||||
|
7. Migration backfill: initial meter creation, idempotency, audit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,6 +29,7 @@ from app.models.energy import (
|
|||||||
DsmrReading,
|
DsmrReading,
|
||||||
EnergyContract,
|
EnergyContract,
|
||||||
EnergyContractVersion,
|
EnergyContractVersion,
|
||||||
|
Meter,
|
||||||
TibberPrice,
|
TibberPrice,
|
||||||
EnergyCostPeriod,
|
EnergyCostPeriod,
|
||||||
)
|
)
|
||||||
@@ -92,10 +95,11 @@ def energy_db_with_fk(tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_energy_tables_exist_after_upgrade(energy_db):
|
def test_energy_tables_exist_after_upgrade(energy_db):
|
||||||
"""All five energy tables must exist after upgrade to head."""
|
"""All six energy tables must exist after upgrade to head."""
|
||||||
inspector = inspect(energy_db)
|
inspector = inspect(energy_db)
|
||||||
table_names = inspector.get_table_names()
|
table_names = inspector.get_table_names()
|
||||||
expected_tables = {
|
expected_tables = {
|
||||||
|
"meter",
|
||||||
"dsmr_reading",
|
"dsmr_reading",
|
||||||
"energy_contract",
|
"energy_contract",
|
||||||
"energy_contract_version",
|
"energy_contract_version",
|
||||||
@@ -200,7 +204,7 @@ def test_energy_cost_period_columns(energy_db):
|
|||||||
"import_cost", "export_revenue", "net_cost", "currency",
|
"import_cost", "export_revenue", "net_cost", "currency",
|
||||||
"pricing", "degraded", "computed_at",
|
"pricing", "degraded", "computed_at",
|
||||||
}
|
}
|
||||||
nullable = {"contract_version_id"}
|
nullable = {"contract_version_id", "meter_id"}
|
||||||
|
|
||||||
for col_name in non_nullable:
|
for col_name in non_nullable:
|
||||||
assert col_name in columns, f"Missing column: {col_name}"
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
@@ -234,10 +238,35 @@ def test_energy_cost_period_fk_to_contract_version(energy_db):
|
|||||||
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
|
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
|
||||||
inspector = inspect(energy_db)
|
inspector = inspect(energy_db)
|
||||||
fks = inspector.get_foreign_keys("energy_cost_period")
|
fks = inspector.get_foreign_keys("energy_cost_period")
|
||||||
assert len(fks) == 1, f"Expected 1 FK on energy_cost_period, got {len(fks)}"
|
# Two FKs: contract_version_id → energy_contract_version.id
|
||||||
fk = fks[0]
|
# meter_id → meter.id
|
||||||
|
fk_by_col = {
|
||||||
|
col: fk
|
||||||
|
for fk in fks
|
||||||
|
for col in fk["constrained_columns"]
|
||||||
|
}
|
||||||
|
assert "contract_version_id" in fk_by_col, (
|
||||||
|
"energy_cost_period must have a FK on contract_version_id"
|
||||||
|
)
|
||||||
|
fk = fk_by_col["contract_version_id"]
|
||||||
assert fk["referred_table"] == "energy_contract_version"
|
assert fk["referred_table"] == "energy_contract_version"
|
||||||
assert "contract_version_id" in fk["constrained_columns"]
|
assert "id" in fk["referred_columns"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_fk_to_meter(energy_db):
|
||||||
|
"""energy_cost_period.meter_id must have a FK referencing meter.id."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
fks = inspector.get_foreign_keys("energy_cost_period")
|
||||||
|
fk_by_col = {
|
||||||
|
col: fk
|
||||||
|
for fk in fks
|
||||||
|
for col in fk["constrained_columns"]
|
||||||
|
}
|
||||||
|
assert "meter_id" in fk_by_col, (
|
||||||
|
"energy_cost_period must have a FK on meter_id"
|
||||||
|
)
|
||||||
|
fk = fk_by_col["meter_id"]
|
||||||
|
assert fk["referred_table"] == "meter"
|
||||||
assert "id" in fk["referred_columns"]
|
assert "id" in fk["referred_columns"]
|
||||||
|
|
||||||
|
|
||||||
@@ -308,8 +337,9 @@ def test_dsmr_decouple_migration_downgrade_restores_source_id_unique(tmp_path: P
|
|||||||
|
|
||||||
|
|
||||||
def test_base_metadata_contains_energy_tables():
|
def test_base_metadata_contains_energy_tables():
|
||||||
"""Base.metadata.tables must include all five new energy tables."""
|
"""Base.metadata.tables must include all six energy tables."""
|
||||||
expected = {
|
expected = {
|
||||||
|
"meter",
|
||||||
"dsmr_reading",
|
"dsmr_reading",
|
||||||
"energy_contract",
|
"energy_contract",
|
||||||
"energy_contract_version",
|
"energy_contract_version",
|
||||||
@@ -366,6 +396,17 @@ def test_energy_cost_period_fk_ondelete_restrict():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_meter_id_fk_ondelete_restrict():
|
||||||
|
"""The FK from energy_cost_period.meter_id must be ON DELETE RESTRICT."""
|
||||||
|
table = Base.metadata.tables["energy_cost_period"]
|
||||||
|
col = table.columns["meter_id"]
|
||||||
|
assert col.foreign_keys, "meter_id must have a foreign key"
|
||||||
|
fk = next(iter(col.foreign_keys))
|
||||||
|
assert fk.ondelete == "RESTRICT", (
|
||||||
|
f"meter_id FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_dsmr_reading_recorded_at_unique_in_metadata():
|
def test_dsmr_reading_recorded_at_unique_in_metadata():
|
||||||
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
|
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
|
||||||
and source_id must NOT be unique (decoupled from the telegram id)."""
|
and source_id must NOT be unique (decoupled from the telegram id)."""
|
||||||
@@ -739,3 +780,396 @@ def test_cost_period_restrict_prevents_version_deletion(tmp_path: Path):
|
|||||||
session.commit()
|
session.commit()
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Meter model tests (M7-T01)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_table_exists_after_upgrade(energy_db):
|
||||||
|
"""meter table must exist after upgrade to head."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
assert "meter" in inspector.get_table_names(), "meter table missing after upgrade to head"
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_columns(energy_db):
|
||||||
|
"""meter must have all required columns with correct nullability."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("meter")}
|
||||||
|
|
||||||
|
non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"}
|
||||||
|
nullable = {"ended_at", "note"}
|
||||||
|
|
||||||
|
for col_name in non_nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
for col_name in nullable:
|
||||||
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
|
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_orm_metadata():
|
||||||
|
"""Meter must be registered in Base.metadata with correct field types."""
|
||||||
|
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
|
||||||
|
table = Base.metadata.tables["meter"]
|
||||||
|
assert "id" in table.columns
|
||||||
|
assert "label" in table.columns
|
||||||
|
assert "commodity" in table.columns
|
||||||
|
assert "started_at" in table.columns
|
||||||
|
assert "ended_at" in table.columns
|
||||||
|
assert "reason" in table.columns
|
||||||
|
assert "note" in table.columns
|
||||||
|
assert "created_at" in table.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_insert_and_retrieve(energy_db):
|
||||||
|
"""A Meter row can be inserted and retrieved with all fields intact."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
m = Meter(
|
||||||
|
label="Test meter @ Dorpsstraat 1",
|
||||||
|
commodity="electricity",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=None,
|
||||||
|
reason="initial",
|
||||||
|
note="2G smart meter",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
session.commit()
|
||||||
|
meter_id = m.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(Meter, meter_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.label == "Test meter @ Dorpsstraat 1"
|
||||||
|
assert fetched.commodity == "electricity"
|
||||||
|
assert fetched.reason == "initial"
|
||||||
|
assert fetched.note == "2G smart meter"
|
||||||
|
assert fetched.ended_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_ended_at_nullable(energy_db):
|
||||||
|
"""Meter.ended_at and Meter.note can both be None (active/ongoing meter)."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
m = Meter(
|
||||||
|
label="Active meter",
|
||||||
|
commodity="electricity",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=None,
|
||||||
|
reason="home_move",
|
||||||
|
note=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
session.commit()
|
||||||
|
meter_id = m.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(Meter, meter_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.ended_at is None
|
||||||
|
assert fetched.note is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_meter_id_nullable(energy_db):
|
||||||
|
"""EnergyCostPeriod.meter_id must be nullable (no meter FK required)."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=now,
|
||||||
|
d1_kwh=0.5,
|
||||||
|
d2_kwh=1.2,
|
||||||
|
r1_kwh=0.0,
|
||||||
|
r2_kwh=0.0,
|
||||||
|
import_cost=0.225,
|
||||||
|
export_revenue=0.0,
|
||||||
|
net_cost=0.225,
|
||||||
|
currency="EUR",
|
||||||
|
pricing={"kind": "manual"},
|
||||||
|
contract_version_id=None,
|
||||||
|
meter_id=None,
|
||||||
|
degraded=False,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
session.commit()
|
||||||
|
period_id = period.id
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
fetched = session.get(EnergyCostPeriod, period_id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.meter_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_cost_period_meter_id_fk_enforced(tmp_path: Path):
|
||||||
|
"""Inserting an EnergyCostPeriod with a non-existent meter_id must fail (RESTRICT FK)."""
|
||||||
|
db_path = tmp_path / "meter_fk_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(engine) as session:
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=now,
|
||||||
|
d1_kwh=0.0,
|
||||||
|
d2_kwh=0.0,
|
||||||
|
r1_kwh=0.0,
|
||||||
|
r2_kwh=0.0,
|
||||||
|
import_cost=0.0,
|
||||||
|
export_revenue=0.0,
|
||||||
|
net_cost=0.0,
|
||||||
|
currency="EUR",
|
||||||
|
pricing={},
|
||||||
|
contract_version_id=None,
|
||||||
|
meter_id=9999, # non-existent meter
|
||||||
|
degraded=False,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_restrict_prevents_deletion_with_cost_periods(tmp_path: Path):
|
||||||
|
"""Deleting a Meter that has cost periods attributed to it must fail (ON DELETE RESTRICT)."""
|
||||||
|
db_path = tmp_path / "meter_restrict_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
m = Meter(
|
||||||
|
label="RESTRICT Test Meter",
|
||||||
|
commodity="electricity",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=None,
|
||||||
|
reason="initial",
|
||||||
|
note=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
period = EnergyCostPeriod(
|
||||||
|
period_start=now,
|
||||||
|
d1_kwh=0.1,
|
||||||
|
d2_kwh=0.2,
|
||||||
|
r1_kwh=0.0,
|
||||||
|
r2_kwh=0.0,
|
||||||
|
import_cost=0.05,
|
||||||
|
export_revenue=0.0,
|
||||||
|
net_cost=0.05,
|
||||||
|
currency="EUR",
|
||||||
|
pricing={"kind": "manual"},
|
||||||
|
contract_version_id=None,
|
||||||
|
meter_id=m.id,
|
||||||
|
degraded=False,
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
session.add(period)
|
||||||
|
session.commit()
|
||||||
|
meter_id = m.id
|
||||||
|
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(
|
||||||
|
text("DELETE FROM meter WHERE id = :mid"),
|
||||||
|
{"mid": meter_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Migration backfill tests (M7-T01)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_empty_db_no_initial_meter(tmp_path: Path):
|
||||||
|
"""Upgrading an empty database must not create any meter rows."""
|
||||||
|
db_path = tmp_path / "empty_backfill_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
with Session(engine) as session:
|
||||||
|
count = session.execute(text("SELECT COUNT(*) FROM meter")).scalar()
|
||||||
|
assert count == 0, f"Expected 0 meter rows in empty DB after upgrade, got {count}"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_backfill_creates_initial_meter_from_readings(tmp_path: Path):
|
||||||
|
"""Upgrading with existing dsmr_reading rows must create exactly one initial meter
|
||||||
|
and back-fill all energy_cost_period rows with its id."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
db_path = tmp_path / "backfill_readings_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
|
||||||
|
# Upgrade only to the revision just before M7-T01 (i.e. pre-meter).
|
||||||
|
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
t0 = now - timedelta(hours=2)
|
||||||
|
t1 = now - timedelta(hours=1)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Insert two dsmr_reading rows.
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
|
||||||
|
"VALUES (:ts, NULL, '{}')"
|
||||||
|
),
|
||||||
|
{"ts": t0.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
|
||||||
|
"VALUES (:ts, NULL, '{}')"
|
||||||
|
),
|
||||||
|
{"ts": t1.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||||
|
)
|
||||||
|
# Insert two energy_cost_period rows (no meter_id column yet at this revision).
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_cost_period "
|
||||||
|
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, "
|
||||||
|
"import_cost, export_revenue, net_cost, currency, pricing, "
|
||||||
|
"contract_version_id, degraded, computed_at) "
|
||||||
|
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
|
||||||
|
"NULL, 0, :now)"
|
||||||
|
),
|
||||||
|
{"ps": t0.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_cost_period "
|
||||||
|
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, "
|
||||||
|
"import_cost, export_revenue, net_cost, currency, pricing, "
|
||||||
|
"contract_version_id, degraded, computed_at) "
|
||||||
|
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
|
||||||
|
"NULL, 0, :now)"
|
||||||
|
),
|
||||||
|
{"ps": t1.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# Now upgrade to head (applies M7-T01 migration with backfill).
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Exactly one initial meter must exist.
|
||||||
|
meter_count = session.execute(
|
||||||
|
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
|
||||||
|
).scalar()
|
||||||
|
assert meter_count == 1, f"Expected 1 initial meter, got {meter_count}"
|
||||||
|
|
||||||
|
# All non-degraded energy_cost_period rows must have meter_id set.
|
||||||
|
null_count = session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM energy_cost_period "
|
||||||
|
"WHERE meter_id IS NULL AND degraded = 0"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"Expected 0 non-degraded periods with NULL meter_id, got {null_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The initial meter started_at must match the earliest dsmr_reading.recorded_at.
|
||||||
|
meter_row = session.execute(
|
||||||
|
text("SELECT started_at FROM meter WHERE reason = 'initial' LIMIT 1")
|
||||||
|
).fetchone()
|
||||||
|
assert meter_row is not None
|
||||||
|
meter_started = meter_row[0]
|
||||||
|
# Both meter_started and t0 are now UTC strings / datetimes — just verify
|
||||||
|
# the date portion matches (second-level precision is sufficient).
|
||||||
|
assert t0.strftime("%Y-%m-%dT%H:%M:%S") in str(meter_started), (
|
||||||
|
f"initial meter started_at {meter_started!r} should match earliest reading {t0}"
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_backfill_idempotent(tmp_path: Path):
|
||||||
|
"""Running upgrade head twice must not create duplicate initial meters or corrupt data."""
|
||||||
|
db_path = tmp_path / "idempotent_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
|
||||||
|
# Upgrade to pre-meter revision and insert a reading.
|
||||||
|
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
|
||||||
|
"VALUES (:ts, NULL, '{}')"
|
||||||
|
),
|
||||||
|
{"ts": now.strftime("%Y-%m-%dT%H:%M:%S")},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# First upgrade to head.
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
# Downgrade and re-upgrade to simulate idempotency (tests the guard condition).
|
||||||
|
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
with Session(engine) as session:
|
||||||
|
meter_count = session.execute(
|
||||||
|
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
|
||||||
|
).scalar()
|
||||||
|
assert meter_count == 1, (
|
||||||
|
f"Expected exactly 1 initial meter after repeated upgrade, got {meter_count}"
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_downgrade_removes_meter_table(tmp_path: Path):
|
||||||
|
"""Downgrading from M7-T01 must remove the meter table and the meter_id column."""
|
||||||
|
db_path = tmp_path / "downgrade_meter_test.db"
|
||||||
|
db_url = f"sqlite:///{db_path}"
|
||||||
|
alembic_cfg = _make_app_alembic_config(db_url)
|
||||||
|
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
assert "meter" in inspector.get_table_names(), "meter must exist before downgrade"
|
||||||
|
ecp_cols = {col["name"] for col in inspector.get_columns("energy_cost_period")}
|
||||||
|
assert "meter_id" in ecp_cols, "meter_id must exist in energy_cost_period before downgrade"
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
# Downgrade one step (removes the meter table + meter_id column).
|
||||||
|
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
|
||||||
|
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
inspector = inspect(engine)
|
||||||
|
assert "meter" not in inspector.get_table_names(), "meter must be gone after downgrade"
|
||||||
|
ecp_cols_after = {col["name"] for col in inspector.get_columns("energy_cost_period")}
|
||||||
|
assert "meter_id" not in ecp_cols_after, (
|
||||||
|
"meter_id must be removed from energy_cost_period after downgrade"
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|||||||
Reference in New Issue
Block a user