M8-T12: scope energy contracts
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
"""add a billing scope to energy contracts
|
||||||
|
|
||||||
|
Revision ID: 20260822_18_contract_scopes
|
||||||
|
Revises: 20260822_17_warmtelink_readings
|
||||||
|
Create Date: 2026-08-22 00:00:00.000000
|
||||||
|
|
||||||
|
The upgrade preserves every existing contract, version and cost row. Existing
|
||||||
|
contracts predate scopes and therefore deterministically belong to electricity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260822_18_contract_scopes"
|
||||||
|
down_revision: Union[str, None] = "20260822_17_warmtelink_readings"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _count(connection: sa.Connection, table: str) -> int:
|
||||||
|
return int(connection.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _orphan_count(connection: sa.Connection) -> int:
|
||||||
|
version_orphans = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT COUNT(*) FROM energy_contract_version v "
|
||||||
|
"LEFT JOIN energy_contract c ON c.id = v.contract_id WHERE c.id IS NULL"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
cost_orphans = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT COUNT(*) FROM energy_cost_period p "
|
||||||
|
"LEFT JOIN energy_contract_version v ON v.id = p.contract_version_id "
|
||||||
|
"WHERE p.contract_version_id IS NOT NULL AND v.id IS NULL"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
return int(version_orphans) + int(cost_orphans)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_scope_upgrade(connection: sa.Connection, before: dict[str, int], orphan_before: int) -> None:
|
||||||
|
after = {table: _count(connection, table) for table in before}
|
||||||
|
if after != before:
|
||||||
|
raise RuntimeError("contract scope migration row-count audit failed")
|
||||||
|
if _orphan_count(connection) != orphan_before:
|
||||||
|
raise RuntimeError("contract scope migration FK audit failed")
|
||||||
|
invalid_scope_count = connection.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM energy_contract WHERE scope IS NULL OR scope != 'electricity'")
|
||||||
|
).scalar_one()
|
||||||
|
if invalid_scope_count:
|
||||||
|
raise RuntimeError("contract scope migration backfill audit failed")
|
||||||
|
|
||||||
|
# Kept on Alembic's Config attributes rather than an environment switch so
|
||||||
|
# isolated migration tests can deterministically exercise the rollback
|
||||||
|
# boundary without changing production behavior.
|
||||||
|
failure_injector = op.get_context().config.attributes.get("m8_t12_post_ddl_audit_failure")
|
||||||
|
if callable(failure_injector):
|
||||||
|
failure_injector()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_scope_schema() -> None:
|
||||||
|
# SQLite batch mode reconstructs the table. The server default gives every
|
||||||
|
# historical row its deterministic value during reconstruction.
|
||||||
|
with op.batch_alter_table("energy_contract", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("scope", sa.String(length=32), nullable=False, server_default="electricity")
|
||||||
|
)
|
||||||
|
batch_op.create_index("ix_energy_contract_scope", ["scope"])
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
before = {
|
||||||
|
table: _count(connection, table)
|
||||||
|
for table in ("energy_contract", "energy_contract_version", "energy_cost_period")
|
||||||
|
}
|
||||||
|
orphan_before = _orphan_count(connection)
|
||||||
|
|
||||||
|
if connection.dialect.name != "sqlite":
|
||||||
|
_apply_scope_schema()
|
||||||
|
_audit_scope_upgrade(connection, before, orphan_before)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Alembic marks SQLite batch DDL as non-transactional. SQLite itself can
|
||||||
|
# nevertheless atomically roll back CREATE/COPY/DROP/RENAME when an
|
||||||
|
# explicit transaction owns the complete batch operation. Keep the audit
|
||||||
|
# inside that boundary so a failed audit cannot strand a revision-17 DB
|
||||||
|
# with a revision-18 table shape.
|
||||||
|
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
_apply_scope_schema()
|
||||||
|
_audit_scope_upgrade(connection, before, orphan_before)
|
||||||
|
except BaseException:
|
||||||
|
connection.exec_driver_sql("ROLLBACK")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
connection.exec_driver_sql("COMMIT")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Schema reversibility is only exercised against isolated temporary test DBs.
|
||||||
|
with op.batch_alter_table("energy_contract", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index("ix_energy_contract_scope")
|
||||||
|
batch_op.drop_column("scope")
|
||||||
@@ -46,7 +46,9 @@ from app.schemas.energy_contract import (
|
|||||||
from app.services.auth import AuthenticatedSession
|
from app.services.auth import AuthenticatedSession
|
||||||
from app.services import timezone as _tz_mod
|
from app.services import timezone as _tz_mod
|
||||||
from app.services.contracts import (
|
from app.services.contracts import (
|
||||||
|
CONTRACT_KIND_SCOPES,
|
||||||
ContractVersionError,
|
ContractVersionError,
|
||||||
|
ContractScopeError,
|
||||||
activate_contract,
|
activate_contract,
|
||||||
add_version,
|
add_version,
|
||||||
create_contract,
|
create_contract,
|
||||||
@@ -98,6 +100,7 @@ def _contract_detail(db: Session, contract) -> ContractDetailResponse:
|
|||||||
id=contract.id,
|
id=contract.id,
|
||||||
name=contract.name,
|
name=contract.name,
|
||||||
kind=contract.kind,
|
kind=contract.kind,
|
||||||
|
scope=contract.scope,
|
||||||
active=contract.active,
|
active=contract.active,
|
||||||
currency=contract.currency,
|
currency=contract.currency,
|
||||||
created_at=contract.created_at,
|
created_at=contract.created_at,
|
||||||
@@ -163,16 +166,22 @@ def get_profiles(
|
|||||||
|
|
||||||
@router.get("/contracts", response_model=ContractListResponse)
|
@router.get("/contracts", response_model=ContractListResponse)
|
||||||
def list_energy_contracts(
|
def list_energy_contracts(
|
||||||
|
scope: str = "electricity",
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_auth: AuthenticatedSession = Depends(require_session),
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
) -> ContractListResponse:
|
) -> ContractListResponse:
|
||||||
"""List all energy contracts with their active status.
|
"""List all energy contracts with their active status.
|
||||||
|
|
||||||
Returns a flat list (no embedded version history); use
|
Scope defaults to ``electricity`` for old clients. Returns a flat list (no embedded version history); use
|
||||||
GET /api/energy/contracts/{id} to fetch the full version history for a
|
GET /api/energy/contracts/{id} to fetch the full version history for a
|
||||||
specific contract.
|
specific contract.
|
||||||
"""
|
"""
|
||||||
contracts = list_contracts(db)
|
if scope not in set(CONTRACT_KIND_SCOPES.values()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Unknown energy contract scope: {scope!r}",
|
||||||
|
)
|
||||||
|
contracts = list_contracts(db, scope=scope)
|
||||||
items = [ContractResponse.model_validate(c) for c in contracts]
|
items = [ContractResponse.model_validate(c) for c in contracts]
|
||||||
return ContractListResponse(items=items, total=len(items))
|
return ContractListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
@@ -208,10 +217,11 @@ def create_energy_contract(
|
|||||||
name=body.name,
|
name=body.name,
|
||||||
kind=body.kind,
|
kind=body.kind,
|
||||||
currency=body.currency,
|
currency=body.currency,
|
||||||
|
scope=body.scope,
|
||||||
values=body.values,
|
values=body.values,
|
||||||
effective_from=effective_from,
|
effective_from=effective_from,
|
||||||
)
|
)
|
||||||
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
except (ProfileNotFoundError, ProfileValidationError, ContractScopeError) as exc:
|
||||||
_raise_422_for_profile_error(exc)
|
_raise_422_for_profile_error(exc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -256,11 +266,11 @@ def patch_energy_contract(
|
|||||||
"""Partially update a contract: rename or change activation status.
|
"""Partially update a contract: rename or change activation status.
|
||||||
|
|
||||||
- ``name``: updates the human-readable label.
|
- ``name``: updates the human-readable label.
|
||||||
- ``active=true``: activates this contract (all others are deactivated).
|
- ``active=true``: activates this contract (same-scope contracts are deactivated).
|
||||||
- ``active=false``: deactivates this contract (no effect on others).
|
- ``active=false``: deactivates this contract (no effect on others).
|
||||||
|
|
||||||
At most one contract may be active at any time; the service layer enforces
|
At most one contract may be active per scope; the service layer enforces
|
||||||
mutual exclusion.
|
scope-local mutual exclusion.
|
||||||
"""
|
"""
|
||||||
contract = _get_contract_or_404(db, contract_id)
|
contract = _get_contract_or_404(db, contract_id)
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -163,8 +163,10 @@ class EnergyContract(Base):
|
|||||||
|
|
||||||
``kind`` determines which price strategy is used (``manual`` for fixed
|
``kind`` determines which price strategy is used (``manual`` for fixed
|
||||||
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
|
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
|
||||||
Only one contract may be ``active`` at a time; the service layer enforces
|
A contract belongs to an energy ``scope`` (currently electricity; thermal
|
||||||
mutual exclusion. Specific pricing values live in ``EnergyContractVersion``
|
profiles are reserved for the next milestone). Only one contract may be
|
||||||
|
``active`` per scope; the service layer enforces mutual exclusion. Specific
|
||||||
|
pricing values live in ``EnergyContractVersion``
|
||||||
so that price changes can be tracked without modifying historical records.
|
so that price changes can be tracked without modifying historical records.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -180,6 +182,12 @@ class EnergyContract(Base):
|
|||||||
# migration simple and the strategy registry extensible.
|
# migration simple and the strategy registry extensible.
|
||||||
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
# Billing domain. The service registry derives this from ``kind`` so API
|
||||||
|
# callers cannot move a pricing strategy into an incompatible domain.
|
||||||
|
scope: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="electricity", index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Whether this is the currently active contract (at most one should be True).
|
# Whether this is the currently active contract (at most one should be True).
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class ContractResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
kind: str
|
kind: str
|
||||||
|
scope: str
|
||||||
active: bool
|
active: bool
|
||||||
currency: str
|
currency: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -69,6 +70,7 @@ class ContractDetailResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
kind: str
|
kind: str
|
||||||
|
scope: str
|
||||||
active: bool
|
active: bool
|
||||||
currency: str
|
currency: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -101,6 +103,7 @@ class ContractCreate(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
kind: str = Field(..., min_length=1, max_length=32)
|
kind: str = Field(..., min_length=1, max_length=32)
|
||||||
|
scope: str | None = Field(default=None, min_length=1, max_length=32)
|
||||||
currency: str = Field(default="EUR", min_length=1, max_length=8)
|
currency: str = Field(default="EUR", min_length=1, max_length=8)
|
||||||
values: dict[str, Any]
|
values: dict[str, Any]
|
||||||
effective_from: datetime | None = Field(
|
effective_from: datetime | None = Field(
|
||||||
|
|||||||
+58
-16
@@ -11,8 +11,8 @@ Design decisions
|
|||||||
setting its ``effective_to`` to the new version's ``effective_from``; raises
|
setting its ``effective_to`` to the new version's ``effective_from``; raises
|
||||||
``ContractVersionError`` if the new date is strictly earlier than the previous
|
``ContractVersionError`` if the new date is strictly earlier than the previous
|
||||||
version's ``effective_from``.
|
version's ``effective_from``.
|
||||||
- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active``
|
- ``activate_contract``: scope-local mutual exclusion; sets other contracts in
|
||||||
to False, then sets the given contract's ``active`` to True.
|
the target scope inactive, then sets the given contract active.
|
||||||
- ``active_contract_version_at``: returns the single version of the currently
|
- ``active_contract_version_at``: returns the single version of the currently
|
||||||
active contract that covers *ts* (``effective_from ≤ ts < effective_to``,
|
active contract that covers *ts* (``effective_from ≤ ts < effective_to``,
|
||||||
or open-ended when ``effective_to`` is None).
|
or open-ended when ``effective_to`` is None).
|
||||||
@@ -32,7 +32,7 @@ import logging
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.integrations.pricing.profiles import validate_values
|
from app.integrations.pricing.profiles import validate_values
|
||||||
@@ -41,6 +41,32 @@ from app.models.energy import EnergyContract, EnergyContractVersion
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# This is deliberately separate from the pricing-profile loader. T12 needs to
|
||||||
|
# reserve the thermal domain before T13 supplies its actual profile.
|
||||||
|
CONTRACT_KIND_SCOPES: dict[str, str] = {
|
||||||
|
"manual": "electricity",
|
||||||
|
"tibber": "electricity",
|
||||||
|
"district_heating": "thermal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ContractScopeError(ValueError):
|
||||||
|
"""Raised when a contract kind is unknown or its supplied scope disagrees."""
|
||||||
|
|
||||||
|
|
||||||
|
def contract_scope_for_kind(kind: str, requested_scope: str | None = None) -> str:
|
||||||
|
"""Return the registry-owned scope for *kind*, rejecting client mismatches."""
|
||||||
|
try:
|
||||||
|
scope = CONTRACT_KIND_SCOPES[kind]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ContractScopeError(f"Unknown energy contract kind: {kind!r}") from exc
|
||||||
|
if requested_scope is not None and requested_scope != scope:
|
||||||
|
raise ContractScopeError(
|
||||||
|
f"Contract kind {kind!r} belongs to scope {scope!r}, not {requested_scope!r}."
|
||||||
|
)
|
||||||
|
return scope
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -86,10 +112,14 @@ def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract |
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def list_contracts(session: Session) -> list[EnergyContract]:
|
def list_contracts(session: Session, *, scope: str = "electricity") -> list[EnergyContract]:
|
||||||
"""Return all contracts ordered by id (ascending)."""
|
"""Return contracts in one scope, ordered by id (ascending)."""
|
||||||
return list(
|
return list(
|
||||||
session.execute(select(EnergyContract).order_by(EnergyContract.id)).scalars().all()
|
session.execute(
|
||||||
|
select(EnergyContract)
|
||||||
|
.where(EnergyContract.scope == scope)
|
||||||
|
.order_by(EnergyContract.id)
|
||||||
|
).scalars().all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +147,7 @@ def create_contract(
|
|||||||
name: str,
|
name: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
currency: str = "EUR",
|
currency: str = "EUR",
|
||||||
|
scope: str | None = None,
|
||||||
values: dict[str, Any],
|
values: dict[str, Any],
|
||||||
effective_from: datetime,
|
effective_from: datetime,
|
||||||
) -> EnergyContract:
|
) -> EnergyContract:
|
||||||
@@ -150,6 +181,7 @@ def create_contract(
|
|||||||
ProfileValidationError
|
ProfileValidationError
|
||||||
If *values* does not conform to the profile structure.
|
If *values* does not conform to the profile structure.
|
||||||
"""
|
"""
|
||||||
|
resolved_scope = contract_scope_for_kind(kind, scope)
|
||||||
# Validate (and fill defaults) before any DB write.
|
# Validate (and fill defaults) before any DB write.
|
||||||
filled_values = validate_values(kind, values)
|
filled_values = validate_values(kind, values)
|
||||||
|
|
||||||
@@ -157,6 +189,7 @@ def create_contract(
|
|||||||
contract = EnergyContract(
|
contract = EnergyContract(
|
||||||
name=name,
|
name=name,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
|
scope=resolved_scope,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
active=False, # New contracts are inactive; caller must explicitly activate.
|
active=False, # New contracts are inactive; caller must explicitly activate.
|
||||||
created_at=now,
|
created_at=now,
|
||||||
@@ -262,15 +295,18 @@ def add_version(
|
|||||||
def activate_contract(session: Session, contract: EnergyContract) -> None:
|
def activate_contract(session: Session, contract: EnergyContract) -> None:
|
||||||
"""Activate a contract with mutual exclusion.
|
"""Activate a contract with mutual exclusion.
|
||||||
|
|
||||||
Sets every other contract's ``active`` flag to False, then sets the given
|
Sets every other contract in the same scope inactive, then sets the given
|
||||||
contract's ``active`` to True. This guarantees at most one active contract
|
contract active. This guarantees at most one active contract per scope.
|
||||||
at any time.
|
|
||||||
|
|
||||||
Caller must commit after this returns.
|
Caller must commit after this returns.
|
||||||
"""
|
"""
|
||||||
# Deactivate all contracts (including the target; we re-activate below).
|
# This bulk update is a single write statement inside the caller's
|
||||||
for other in session.execute(select(EnergyContract)).scalars().all():
|
# transaction. SQLite serializes writers, and another scope is never touched.
|
||||||
other.active = False
|
session.execute(
|
||||||
|
update(EnergyContract)
|
||||||
|
.where(EnergyContract.scope == contract.scope, EnergyContract.id != contract.id)
|
||||||
|
.values(active=False)
|
||||||
|
)
|
||||||
contract.active = True
|
contract.active = True
|
||||||
contract.updated_at = datetime.now(UTC)
|
contract.updated_at = datetime.now(UTC)
|
||||||
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
|
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
|
||||||
@@ -286,7 +322,9 @@ def deactivate_contract(session: Session, contract: EnergyContract) -> None:
|
|||||||
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
|
||||||
|
|
||||||
|
|
||||||
def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
|
def active_contract_versions(
|
||||||
|
session: Session, *, scope: str = "electricity"
|
||||||
|
) -> list[EnergyContractVersion]:
|
||||||
"""Return all versions of the currently active contract, ordered by effective_from ascending.
|
"""Return all versions of the currently active contract, ordered by effective_from ascending.
|
||||||
|
|
||||||
Returns an empty list when there is no active contract. The list spans the
|
Returns an empty list when there is no active contract. The list spans the
|
||||||
@@ -295,7 +333,9 @@ def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
|
|||||||
cost / credit accumulation (Principle C).
|
cost / credit accumulation (Principle C).
|
||||||
"""
|
"""
|
||||||
active = session.execute(
|
active = session.execute(
|
||||||
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
select(EnergyContract)
|
||||||
|
.where(EnergyContract.active.is_(True), EnergyContract.scope == scope)
|
||||||
|
.limit(1)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if active is None:
|
if active is None:
|
||||||
@@ -313,7 +353,7 @@ def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
|
|||||||
|
|
||||||
|
|
||||||
def active_contract_version_at(
|
def active_contract_version_at(
|
||||||
session: Session, ts: datetime
|
session: Session, ts: datetime, *, scope: str = "electricity"
|
||||||
) -> EnergyContractVersion | None:
|
) -> EnergyContractVersion | None:
|
||||||
"""Return the active contract's version that covers *ts*.
|
"""Return the active contract's version that covers *ts*.
|
||||||
|
|
||||||
@@ -336,7 +376,9 @@ def active_contract_version_at(
|
|||||||
EnergyContractVersion | None
|
EnergyContractVersion | None
|
||||||
"""
|
"""
|
||||||
active = session.execute(
|
active = session.execute(
|
||||||
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
|
select(EnergyContract)
|
||||||
|
.where(EnergyContract.active.is_(True), EnergyContract.scope == scope)
|
||||||
|
.limit(1)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if active is None:
|
if active is None:
|
||||||
|
|||||||
@@ -727,7 +727,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T12 — 合同 Scope 与按 Scope 激活 [structural]
|
### M8-T12 — 合同 Scope 与按 Scope 激活 [structural]
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T08
|
- **Depends**: M8-T08
|
||||||
- **Context**: electricity 与 thermal 必须能各有一份 active 合同,同时保持旧客户端默认看 electricity。
|
- **Context**: electricity 与 thermal 必须能各有一份 active 合同,同时保持旧客户端默认看 electricity。
|
||||||
|
|
||||||
@@ -740,6 +740,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
- `modify scripts/app_db_adopt.py`
|
- `modify scripts/app_db_adopt.py`
|
||||||
- `modify tests/test_api_energy_contracts.py`
|
- `modify tests/test_api_energy_contracts.py`
|
||||||
- `modify tests/test_energy_models.py`
|
- `modify tests/test_energy_models.py`
|
||||||
|
- `modify tests/test_warmtelink_models.py`
|
||||||
- `modify openapi/openapi.json`
|
- `modify openapi/openapi.json`
|
||||||
- `modify openapi/openapi.yaml`
|
- `modify openapi/openapi.yaml`
|
||||||
- `modify frontend/src/api/schema.d.ts`
|
- `modify frontend/src/api/schema.d.ts`
|
||||||
|
|||||||
Vendored
+22
-5
@@ -472,7 +472,7 @@ export interface paths {
|
|||||||
* List Energy Contracts
|
* List Energy Contracts
|
||||||
* @description List all energy contracts with their active status.
|
* @description List all energy contracts with their active status.
|
||||||
*
|
*
|
||||||
* Returns a flat list (no embedded version history); use
|
* Scope defaults to ``electricity`` for old clients. Returns a flat list (no embedded version history); use
|
||||||
* GET /api/energy/contracts/{id} to fetch the full version history for a
|
* GET /api/energy/contracts/{id} to fetch the full version history for a
|
||||||
* specific contract.
|
* specific contract.
|
||||||
*/
|
*/
|
||||||
@@ -519,11 +519,11 @@ export interface paths {
|
|||||||
* @description Partially update a contract: rename or change activation status.
|
* @description Partially update a contract: rename or change activation status.
|
||||||
*
|
*
|
||||||
* - ``name``: updates the human-readable label.
|
* - ``name``: updates the human-readable label.
|
||||||
* - ``active=true``: activates this contract (all others are deactivated).
|
* - ``active=true``: activates this contract (same-scope contracts are deactivated).
|
||||||
* - ``active=false``: deactivates this contract (no effect on others).
|
* - ``active=false``: deactivates this contract (no effect on others).
|
||||||
*
|
*
|
||||||
* At most one contract may be active at any time; the service layer enforces
|
* At most one contract may be active per scope; the service layer enforces
|
||||||
* mutual exclusion.
|
* scope-local mutual exclusion.
|
||||||
*/
|
*/
|
||||||
patch: operations["patch_energy_contract_api_energy_contracts__contract_id__patch"];
|
patch: operations["patch_energy_contract_api_energy_contracts__contract_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
@@ -1549,6 +1549,8 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Kind */
|
/** Kind */
|
||||||
kind: string;
|
kind: string;
|
||||||
|
/** Scope */
|
||||||
|
scope?: string | null;
|
||||||
/**
|
/**
|
||||||
* Currency
|
* Currency
|
||||||
* @default EUR
|
* @default EUR
|
||||||
@@ -1578,6 +1580,8 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Kind */
|
/** Kind */
|
||||||
kind: string;
|
kind: string;
|
||||||
|
/** Scope */
|
||||||
|
scope: string;
|
||||||
/** Active */
|
/** Active */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
/** Currency */
|
/** Currency */
|
||||||
@@ -1632,6 +1636,8 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Kind */
|
/** Kind */
|
||||||
kind: string;
|
kind: string;
|
||||||
|
/** Scope */
|
||||||
|
scope: string;
|
||||||
/** Active */
|
/** Active */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
/** Currency */
|
/** Currency */
|
||||||
@@ -3494,7 +3500,9 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
list_energy_contracts_api_energy_contracts_get: {
|
list_energy_contracts_api_energy_contracts_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -3510,6 +3518,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["ContractListResponse"];
|
"application/json": components["schemas"]["ContractListResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
create_energy_contract_api_energy_contracts_post: {
|
create_energy_contract_api_energy_contracts_post: {
|
||||||
|
|||||||
+47
-2
@@ -1126,8 +1126,20 @@
|
|||||||
"api-energy-contracts"
|
"api-energy-contracts"
|
||||||
],
|
],
|
||||||
"summary": "List Energy Contracts",
|
"summary": "List Energy Contracts",
|
||||||
"description": "List all energy contracts with their active status.\n\nReturns a flat list (no embedded version history); use\nGET /api/energy/contracts/{id} to fetch the full version history for a\nspecific contract.",
|
"description": "List all energy contracts with their active status.\n\nScope defaults to ``electricity`` for old clients. Returns a flat list (no embedded version history); use\nGET /api/energy/contracts/{id} to fetch the full version history for a\nspecific contract.",
|
||||||
"operationId": "list_energy_contracts_api_energy_contracts_get",
|
"operationId": "list_energy_contracts_api_energy_contracts_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "scope",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "electricity",
|
||||||
|
"title": "Scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
@@ -1138,6 +1150,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1247,7 +1269,7 @@
|
|||||||
"api-energy-contracts"
|
"api-energy-contracts"
|
||||||
],
|
],
|
||||||
"summary": "Patch Energy Contract",
|
"summary": "Patch Energy Contract",
|
||||||
"description": "Partially update a contract: rename or change activation status.\n\n- ``name``: updates the human-readable label.\n- ``active=true``: activates this contract (all others are deactivated).\n- ``active=false``: deactivates this contract (no effect on others).\n\nAt most one contract may be active at any time; the service layer enforces\nmutual exclusion.",
|
"description": "Partially update a contract: rename or change activation status.\n\n- ``name``: updates the human-readable label.\n- ``active=true``: activates this contract (same-scope contracts are deactivated).\n- ``active=false``: deactivates this contract (no effect on others).\n\nAt most one contract may be active per scope; the service layer enforces\nscope-local mutual exclusion.",
|
||||||
"operationId": "patch_energy_contract_api_energy_contracts__contract_id__patch",
|
"operationId": "patch_energy_contract_api_energy_contracts__contract_id__patch",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -3705,6 +3727,19 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 32,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scope"
|
||||||
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 8,
|
"maxLength": 8,
|
||||||
@@ -3754,6 +3789,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Scope"
|
||||||
|
},
|
||||||
"active": {
|
"active": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Active"
|
"title": "Active"
|
||||||
@@ -3785,6 +3824,7 @@
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"kind",
|
"kind",
|
||||||
|
"scope",
|
||||||
"active",
|
"active",
|
||||||
"currency",
|
"currency",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -3861,6 +3901,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Kind"
|
"title": "Kind"
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Scope"
|
||||||
|
},
|
||||||
"active": {
|
"active": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Active"
|
"title": "Active"
|
||||||
@@ -3885,6 +3929,7 @@
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"kind",
|
"kind",
|
||||||
|
"scope",
|
||||||
"active",
|
"active",
|
||||||
"currency",
|
"currency",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|||||||
+34
-4
@@ -877,12 +877,21 @@ paths:
|
|||||||
description: 'List all energy contracts with their active status.
|
description: 'List all energy contracts with their active status.
|
||||||
|
|
||||||
|
|
||||||
Returns a flat list (no embedded version history); use
|
Scope defaults to ``electricity`` for old clients. Returns a flat list (no
|
||||||
|
embedded version history); use
|
||||||
|
|
||||||
GET /api/energy/contracts/{id} to fetch the full version history for a
|
GET /api/energy/contracts/{id} to fetch the full version history for a
|
||||||
|
|
||||||
specific contract.'
|
specific contract.'
|
||||||
operationId: list_energy_contracts_api_energy_contracts_get
|
operationId: list_energy_contracts_api_energy_contracts_get
|
||||||
|
parameters:
|
||||||
|
- name: scope
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
default: electricity
|
||||||
|
title: Scope
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successful Response
|
description: Successful Response
|
||||||
@@ -890,6 +899,12 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ContractListResponse'
|
$ref: '#/components/schemas/ContractListResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- api-energy-contracts
|
- api-energy-contracts
|
||||||
@@ -974,14 +989,14 @@ paths:
|
|||||||
|
|
||||||
- ``name``: updates the human-readable label.
|
- ``name``: updates the human-readable label.
|
||||||
|
|
||||||
- ``active=true``: activates this contract (all others are deactivated).
|
- ``active=true``: activates this contract (same-scope contracts are deactivated).
|
||||||
|
|
||||||
- ``active=false``: deactivates this contract (no effect on others).
|
- ``active=false``: deactivates this contract (no effect on others).
|
||||||
|
|
||||||
|
|
||||||
At most one contract may be active at any time; the service layer enforces
|
At most one contract may be active per scope; the service layer enforces
|
||||||
|
|
||||||
mutual exclusion.'
|
scope-local mutual exclusion.'
|
||||||
operationId: patch_energy_contract_api_energy_contracts__contract_id__patch
|
operationId: patch_energy_contract_api_energy_contracts__contract_id__patch
|
||||||
parameters:
|
parameters:
|
||||||
- name: contract_id
|
- name: contract_id
|
||||||
@@ -2768,6 +2783,13 @@ components:
|
|||||||
maxLength: 32
|
maxLength: 32
|
||||||
minLength: 1
|
minLength: 1
|
||||||
title: Kind
|
title: Kind
|
||||||
|
scope:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
maxLength: 32
|
||||||
|
minLength: 1
|
||||||
|
- type: 'null'
|
||||||
|
title: Scope
|
||||||
currency:
|
currency:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 8
|
maxLength: 8
|
||||||
@@ -2813,6 +2835,9 @@ components:
|
|||||||
kind:
|
kind:
|
||||||
type: string
|
type: string
|
||||||
title: Kind
|
title: Kind
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
title: Scope
|
||||||
active:
|
active:
|
||||||
type: boolean
|
type: boolean
|
||||||
title: Active
|
title: Active
|
||||||
@@ -2837,6 +2862,7 @@ components:
|
|||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
- kind
|
- kind
|
||||||
|
- scope
|
||||||
- active
|
- active
|
||||||
- currency
|
- currency
|
||||||
- created_at
|
- created_at
|
||||||
@@ -2901,6 +2927,9 @@ components:
|
|||||||
kind:
|
kind:
|
||||||
type: string
|
type: string
|
||||||
title: Kind
|
title: Kind
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
title: Scope
|
||||||
active:
|
active:
|
||||||
type: boolean
|
type: boolean
|
||||||
title: Active
|
title: Active
|
||||||
@@ -2920,6 +2949,7 @@ components:
|
|||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
- kind
|
- kind
|
||||||
|
- scope
|
||||||
- active
|
- active
|
||||||
- currency
|
- currency
|
||||||
- created_at
|
- created_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 = "20260822_17_warmtelink_readings"
|
APP_BASELINE_REVISION = "20260822_18_contract_scopes"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.energy import EnergyContract, EnergyContractVersion
|
from app.models.energy import EnergyContract, EnergyContractVersion
|
||||||
|
from app.services.contracts import activate_contract
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared helpers
|
# Shared helpers
|
||||||
@@ -567,6 +568,72 @@ def test_deactivate_contract(contracts_client):
|
|||||||
assert resp.json()["active"] is False
|
assert resp.json()["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_defaults_filtering_and_kind_mismatch(contracts_client):
|
||||||
|
"""Old clients default to electricity; a supplied incompatible scope is rejected."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
created = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["scope"] == "electricity"
|
||||||
|
assert client.get("/api/energy/contracts").json()["total"] == 1
|
||||||
|
assert client.get("/api/energy/contracts?scope=thermal").json()["items"] == []
|
||||||
|
|
||||||
|
mismatch = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(scope="thermal"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert mismatch.status_code == 422
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert len(session.execute(select(EnergyContract)).scalars().all()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_activation_is_scope_local_and_transaction_rollback_is_safe(contracts_client):
|
||||||
|
"""A thermal activation neither deactivates electricity nor survives rollback."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
electricity = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Electricity"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
).json()
|
||||||
|
client.patch(
|
||||||
|
f"/api/energy/contracts/{electricity['id']}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
thermal = EnergyContract(
|
||||||
|
name="Future thermal", kind="district_heating", scope="thermal", active=False,
|
||||||
|
currency="EUR", created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(thermal)
|
||||||
|
session.commit()
|
||||||
|
thermal_id = thermal.id
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
thermal = session.get(EnergyContract, thermal_id)
|
||||||
|
assert thermal is not None
|
||||||
|
activate_contract(session, thermal)
|
||||||
|
session.rollback() # Simulate a later write failure in this transaction.
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
rows = {row.scope: row for row in session.execute(select(EnergyContract)).scalars()}
|
||||||
|
assert rows["electricity"].active is True
|
||||||
|
assert rows["thermal"].active is False
|
||||||
|
activate_contract(session, rows["thermal"])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
active = session.execute(select(EnergyContract).where(EnergyContract.active.is_(True))).scalars().all()
|
||||||
|
assert {row.scope for row in active} == {"electricity", "thermal"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /api/energy/contracts/{id}/versions
|
# POST /api/energy/contracts/{id}/versions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+160
-1
@@ -157,11 +157,15 @@ def test_energy_contract_columns(energy_db):
|
|||||||
inspector = inspect(energy_db)
|
inspector = inspect(energy_db)
|
||||||
columns = {col["name"]: col for col in inspector.get_columns("energy_contract")}
|
columns = {col["name"]: col for col in inspector.get_columns("energy_contract")}
|
||||||
|
|
||||||
required_non_nullable = {"id", "name", "kind", "active", "currency", "created_at", "updated_at"}
|
required_non_nullable = {
|
||||||
|
"id", "name", "kind", "scope", "active", "currency", "created_at", "updated_at"
|
||||||
|
}
|
||||||
for col_name in required_non_nullable:
|
for col_name in required_non_nullable:
|
||||||
assert col_name in columns, f"Missing column: {col_name}"
|
assert col_name in columns, f"Missing column: {col_name}"
|
||||||
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
|
||||||
|
|
||||||
|
assert any(index["name"] == "ix_energy_contract_scope" for index in inspector.get_indexes("energy_contract"))
|
||||||
|
|
||||||
|
|
||||||
def test_energy_contract_version_columns(energy_db):
|
def test_energy_contract_version_columns(energy_db):
|
||||||
"""energy_contract_version must have all required columns with correct nullability."""
|
"""energy_contract_version must have all required columns with correct nullability."""
|
||||||
@@ -537,6 +541,7 @@ def test_energy_contract_insert_and_retrieve(energy_db):
|
|||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.name == "My Manual Contract"
|
assert fetched.name == "My Manual Contract"
|
||||||
assert fetched.kind == "manual"
|
assert fetched.kind == "manual"
|
||||||
|
assert fetched.scope == "electricity"
|
||||||
assert fetched.active is True
|
assert fetched.active is True
|
||||||
assert fetched.currency == "EUR"
|
assert fetched.currency == "EUR"
|
||||||
|
|
||||||
@@ -1208,3 +1213,157 @@ def test_migration_downgrade_removes_meter_table(tmp_path: Path):
|
|||||||
"meter_id must be removed from energy_cost_period after downgrade"
|
"meter_id must be removed from energy_cost_period after downgrade"
|
||||||
)
|
)
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_scope_migration_preserves_historical_contract_audit(tmp_path: Path):
|
||||||
|
"""A revision-17 fixture upgrades/downgrades without altering contract audit rows."""
|
||||||
|
db_url = f"sqlite:///{tmp_path / 'contract_scope_history.db'}"
|
||||||
|
cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(cfg, "20260822_17_warmtelink_readings")
|
||||||
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||||
|
now = datetime.now(tz=timezone.utc).replace(tzinfo=None)
|
||||||
|
values = '{"energy":{"buy":{"normal":0.4}}}'
|
||||||
|
pricing = '{"historic":"unchanged"}'
|
||||||
|
with engine.begin() as connection:
|
||||||
|
contract_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_contract (name, kind, active, currency, created_at, updated_at) "
|
||||||
|
"VALUES ('Historic', 'manual', 1, 'EUR', :now, :now)"
|
||||||
|
),
|
||||||
|
{"now": now},
|
||||||
|
).lastrowid
|
||||||
|
version_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_contract_version "
|
||||||
|
"(contract_id, effective_from, effective_to, \"values\", created_at) "
|
||||||
|
"VALUES (:contract_id, :now, NULL, :values, :now)"
|
||||||
|
),
|
||||||
|
{"contract_id": contract_id, "now": now, "values": values},
|
||||||
|
).lastrowid
|
||||||
|
connection.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, meter_id, "
|
||||||
|
"source_binding_id) VALUES (:now, 1, 2, 0, 0, 3, 0, 3, 'EUR', :pricing, :version_id, "
|
||||||
|
"0, :now, NULL, NULL)"
|
||||||
|
),
|
||||||
|
{"now": now, "pricing": pricing, "version_id": version_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
||||||
|
"20260822_18_contract_scopes"
|
||||||
|
)
|
||||||
|
assert connection.execute(text("SELECT scope FROM energy_contract")).scalar_one() == "electricity"
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract_version")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT \"values\" FROM energy_contract_version")).scalar_one() == values
|
||||||
|
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == pricing
|
||||||
|
assert connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM energy_contract_version v LEFT JOIN energy_contract c "
|
||||||
|
"ON c.id = v.contract_id WHERE c.id IS NULL"
|
||||||
|
)
|
||||||
|
).scalar_one() == 0
|
||||||
|
inspector = inspect(connection)
|
||||||
|
assert any(item["name"] == "ix_energy_contract_scope" for item in inspector.get_indexes("energy_contract"))
|
||||||
|
|
||||||
|
command.downgrade(cfg, "20260822_17_warmtelink_readings")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert "scope" not in {item["name"] for item in inspect(connection).get_columns("energy_contract")}
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract_version")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_scope_migration_audit_failure_restores_revision_17(tmp_path: Path):
|
||||||
|
"""A post-DDL audit failure leaves no SQLite batch-migration residue."""
|
||||||
|
db_url = f"sqlite:///{tmp_path / 'contract_scope_audit_failure.db'}"
|
||||||
|
cfg = _make_app_alembic_config(db_url)
|
||||||
|
command.upgrade(cfg, "20260822_17_warmtelink_readings")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
now = datetime.now(tz=timezone.utc).replace(tzinfo=None)
|
||||||
|
values = '{"energy":{"buy":{"normal":0.4}}}'
|
||||||
|
pricing = '{"historic":"unchanged"}'
|
||||||
|
with engine.begin() as connection:
|
||||||
|
contract_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_contract (name, kind, active, currency, created_at, updated_at) "
|
||||||
|
"VALUES ('Historic', 'manual', 1, 'EUR', :now, :now)"
|
||||||
|
),
|
||||||
|
{"now": now},
|
||||||
|
).lastrowid
|
||||||
|
version_id = connection.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO energy_contract_version "
|
||||||
|
"(contract_id, effective_from, effective_to, \"values\", created_at) "
|
||||||
|
"VALUES (:contract_id, :now, NULL, :values, :now)"
|
||||||
|
),
|
||||||
|
{"contract_id": contract_id, "now": now, "values": values},
|
||||||
|
).lastrowid
|
||||||
|
connection.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, meter_id, "
|
||||||
|
"source_binding_id) VALUES (:now, 1, 2, 0, 0, 3, 0, 3, 'EUR', :pricing, :version_id, "
|
||||||
|
"0, :now, NULL, NULL)"
|
||||||
|
),
|
||||||
|
{"now": now, "pricing": pricing, "version_id": version_id},
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def _raise_after_ddl() -> None:
|
||||||
|
raise RuntimeError("injected post-DDL audit failure")
|
||||||
|
|
||||||
|
cfg.attributes["m8_t12_post_ddl_audit_failure"] = _raise_after_ddl
|
||||||
|
with pytest.raises(RuntimeError, match="injected post-DDL audit failure"):
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
||||||
|
"20260822_17_warmtelink_readings"
|
||||||
|
)
|
||||||
|
assert "scope" not in {item["name"] for item in inspect(connection).get_columns("energy_contract")}
|
||||||
|
assert not any(
|
||||||
|
item["name"] == "ix_energy_contract_scope"
|
||||||
|
for item in inspect(connection).get_indexes("energy_contract")
|
||||||
|
)
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_contract_version")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
|
||||||
|
assert connection.execute(text("SELECT \"values\" FROM energy_contract_version")).scalar_one() == values
|
||||||
|
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == pricing
|
||||||
|
assert connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM energy_contract_version v LEFT JOIN energy_contract c "
|
||||||
|
"ON c.id = v.contract_id WHERE c.id IS NULL"
|
||||||
|
)
|
||||||
|
).scalar_one() == 0
|
||||||
|
assert connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM energy_cost_period p LEFT JOIN energy_contract_version v "
|
||||||
|
"ON v.id = p.contract_version_id "
|
||||||
|
"WHERE p.contract_version_id IS NOT NULL AND v.id IS NULL"
|
||||||
|
)
|
||||||
|
).scalar_one() == 0
|
||||||
|
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
||||||
|
assert connection.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE name LIKE '_alembic_tmp_%'")
|
||||||
|
).all() == []
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
del cfg.attributes["m8_t12_post_ddl_audit_failure"]
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
engine = _engine_with_fk(db_url)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
|
||||||
|
"20260822_18_contract_scopes"
|
||||||
|
)
|
||||||
|
assert connection.execute(text("SELECT scope FROM energy_contract")).scalar_one() == "electricity"
|
||||||
|
engine.dispose()
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ from sqlalchemy import create_engine, event, inspect, text
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.meter_source import MeterSourceChannel, WarmteLinkReading
|
from app.models.meter_source import MeterSourceChannel, WarmteLinkReading
|
||||||
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
|
||||||
|
|
||||||
REVISION_16 = "20260822_16_dsmr_source_adoption"
|
REVISION_16 = "20260822_16_dsmr_source_adoption"
|
||||||
REVISION_17 = "20260822_17_warmtelink_readings"
|
REVISION_17 = "20260822_17_warmtelink_readings"
|
||||||
|
|
||||||
@@ -78,8 +76,8 @@ def _insert_reading(connection, channel_id: int, timestamp: datetime, value: Dec
|
|||||||
def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_path: Path):
|
def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_path: Path):
|
||||||
empty_url = f"sqlite:///{tmp_path / 'warmtelink_empty.db'}"
|
empty_url = f"sqlite:///{tmp_path / 'warmtelink_empty.db'}"
|
||||||
empty_config = _config(empty_url)
|
empty_config = _config(empty_url)
|
||||||
command.upgrade(empty_config, "head")
|
command.upgrade(empty_config, REVISION_17)
|
||||||
command.upgrade(empty_config, "head")
|
command.upgrade(empty_config, REVISION_17)
|
||||||
empty_engine = _engine(empty_url)
|
empty_engine = _engine(empty_url)
|
||||||
try:
|
try:
|
||||||
with empty_engine.connect() as connection:
|
with empty_engine.connect() as connection:
|
||||||
@@ -106,8 +104,8 @@ def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_
|
|||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
command.upgrade(config, "head")
|
command.upgrade(config, REVISION_17)
|
||||||
command.upgrade(config, "head")
|
command.upgrade(config, REVISION_17)
|
||||||
engine = _engine(database_url)
|
engine = _engine(database_url)
|
||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
@@ -125,7 +123,7 @@ def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_
|
|||||||
def test_warmtelink_reading_constraints_indexes_and_decimal_round_trip(tmp_path: Path):
|
def test_warmtelink_reading_constraints_indexes_and_decimal_round_trip(tmp_path: Path):
|
||||||
database_url = f"sqlite:///{tmp_path / 'warmtelink_constraints.db'}"
|
database_url = f"sqlite:///{tmp_path / 'warmtelink_constraints.db'}"
|
||||||
config = _config(database_url)
|
config = _config(database_url)
|
||||||
command.upgrade(config, "head")
|
command.upgrade(config, REVISION_17)
|
||||||
timestamp = datetime(2026, 8, 22, 10, 30, tzinfo=timezone.utc)
|
timestamp = datetime(2026, 8, 22, 10, 30, tzinfo=timezone.utc)
|
||||||
engine = _engine(database_url)
|
engine = _engine(database_url)
|
||||||
try:
|
try:
|
||||||
@@ -233,13 +231,12 @@ def test_warmtelink_reading_model_uses_restrictive_relationship_and_aware_column
|
|||||||
assert "delete-orphan" not in relationship.cascade
|
assert "delete-orphan" not in relationship.cascade
|
||||||
assert WarmteLinkReading.__table__.c.recorded_at.type.timezone is True
|
assert WarmteLinkReading.__table__.c.recorded_at.type.timezone is True
|
||||||
assert WarmteLinkReading.__table__.c.received_at.type.timezone is True
|
assert WarmteLinkReading.__table__.c.received_at.type.timezone is True
|
||||||
assert APP_BASELINE_REVISION == REVISION_17
|
|
||||||
|
|
||||||
|
|
||||||
def test_warmtelink_reading_downgrade_is_schema_only_on_temporary_database(tmp_path: Path):
|
def test_warmtelink_reading_downgrade_is_schema_only_on_temporary_database(tmp_path: Path):
|
||||||
database_url = f"sqlite:///{tmp_path / 'warmtelink_downgrade.db'}"
|
database_url = f"sqlite:///{tmp_path / 'warmtelink_downgrade.db'}"
|
||||||
config = _config(database_url)
|
config = _config(database_url)
|
||||||
command.upgrade(config, "head")
|
command.upgrade(config, REVISION_17)
|
||||||
command.downgrade(config, REVISION_16)
|
command.downgrade(config, REVISION_16)
|
||||||
|
|
||||||
engine = _engine(database_url)
|
engine = _engine(database_url)
|
||||||
|
|||||||
Reference in New Issue
Block a user