Files
home-automation/app/services/contracts.py
T

410 lines
14 KiB
Python
Raw Permalink Normal View History

"""Service layer for EnergyContract CRUD, versioning, and activation.
All functions accept an explicit SQLAlchemy Session; callers are responsible
for committing or rolling back the transaction.
Design decisions
----------------
- ``create_contract``: validates values against the pricing profile *before*
writing any rows; raises ``ProfileValidationError`` on non-compliance.
- ``add_version``: append-only; closes the previous open version by
setting its ``effective_to`` to the new version's ``effective_from``; raises
``ContractVersionError`` if the new date is strictly earlier than the previous
version's ``effective_from``.
2026-08-23 08:18:42 +02:00
- ``activate_contract``: scope-local mutual exclusion; sets other contracts in
the target scope inactive, then sets the given contract active.
- ``active_contract_version_at``: returns the single version of the currently
active contract that covers *ts* (``effective_from ≤ ts < effective_to``,
or open-ended when ``effective_to`` is None).
SQLite timezone note
--------------------
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
read-back they come out as **timezone-naive** datetimes. Wherever this code
compares timestamps from the DB against timezone-aware values (e.g. from
Pydantic or ``datetime.now(UTC)``), it calls ``_as_utc()`` to make both sides
comparable without tripping on "offset-naive vs offset-aware" TypeErrors.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
2026-08-23 08:18:42 +02:00
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.integrations.pricing.profiles import validate_values
from app.models.energy import EnergyContract, EnergyContractVersion
logger = logging.getLogger(__name__)
2026-08-23 08:18:42 +02:00
# 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
# ---------------------------------------------------------------------------
def _as_utc(dt: datetime) -> datetime:
"""Return *dt* as a timezone-aware UTC datetime.
SQLite's DateTime(timezone=True) column type stores datetimes as naive UTC
strings and gives them back as naive datetimes on read. This helper
re-attaches the UTC timezone info when it is missing, making cross-origin
comparisons safe.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
# ---------------------------------------------------------------------------
# Custom exception
# ---------------------------------------------------------------------------
class ContractVersionError(ValueError):
"""Raised when a new contract version has an invalid effective date.
Specifically: the new version's ``effective_from`` must be greater than or
equal to the previous open version's ``effective_from``. Allowing equal
timestamps would cause ambiguous overlap; the service therefore also rejects
strictly-equal values (same second) to avoid silent data loss.
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract | None:
"""Return the contract with the given id, or None if not found."""
return session.execute(
select(EnergyContract).where(EnergyContract.id == contract_id)
).scalar_one_or_none()
2026-08-23 08:18:42 +02:00
def list_contracts(session: Session, *, scope: str = "electricity") -> list[EnergyContract]:
"""Return contracts in one scope, ordered by id (ascending)."""
return list(
2026-08-23 08:18:42 +02:00
session.execute(
select(EnergyContract)
.where(EnergyContract.scope == scope)
.order_by(EnergyContract.id)
).scalars().all()
)
def _open_version(session: Session, contract: EnergyContract) -> EnergyContractVersion | None:
"""Return the current open version (effective_to IS NULL) for *contract*, or None."""
return session.execute(
select(EnergyContractVersion)
.where(
EnergyContractVersion.contract_id == contract.id,
EnergyContractVersion.effective_to.is_(None),
)
.order_by(EnergyContractVersion.effective_from.desc())
.limit(1)
).scalar_one_or_none()
# ---------------------------------------------------------------------------
# Core service functions
# ---------------------------------------------------------------------------
def create_contract(
session: Session,
*,
name: str,
kind: str,
currency: str = "EUR",
2026-08-23 08:18:42 +02:00
scope: str | None = None,
values: dict[str, Any],
effective_from: datetime,
) -> EnergyContract:
"""Create a new energy contract with its first pricing version.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
name:
Human-readable label for the contract.
kind:
Pricing strategy identifier (``"manual"``, ``"tibber"``, or
``"district_heating"``).
currency:
ISO 4217 currency code (default ``"EUR"``).
values:
Pricing values dict conforming to the named profile's structure. The
district-heating profile normalises its Decimal-safe values to strings
before the JSON snapshot is stored.
Validated via ``validate_values(kind, values)`` before any writes.
effective_from:
UTC datetime at which the first pricing version takes effect.
Returns
-------
EnergyContract
The newly created contract (not yet committed).
Raises
------
ProfileNotFoundError
If no YAML profile exists for *kind*.
ProfileValidationError
If *values* does not conform to the profile structure.
"""
2026-08-23 08:18:42 +02:00
resolved_scope = contract_scope_for_kind(kind, scope)
# Validate (and fill defaults) before any DB write.
filled_values = validate_values(kind, values)
now = datetime.now(UTC)
contract = EnergyContract(
name=name,
kind=kind,
2026-08-23 08:18:42 +02:00
scope=resolved_scope,
currency=currency,
active=False, # New contracts are inactive; caller must explicitly activate.
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush() # Assign contract.id so we can reference it in the version FK.
version = EnergyContractVersion(
contract_id=contract.id,
effective_from=effective_from,
effective_to=None,
values=filled_values,
created_at=now,
)
session.add(version)
logger.info("Created energy contract %r (kind=%s, id=%d)", name, kind, contract.id)
return contract
def add_version(
session: Session,
contract: EnergyContract,
*,
effective_from: datetime,
values: dict[str, Any],
) -> EnergyContractVersion:
"""Add a new pricing version to an existing contract (append-only).
The previous open version's ``effective_to`` is automatically set to the
new version's ``effective_from`` (version closure), ensuring there is never
a gap or overlap between consecutive versions.
The new ``effective_from`` **must be strictly greater than** the previous
open version's ``effective_from``. Equal timestamps are rejected because
they would produce two versions starting at the same instant, making it
impossible to determine which is current. If this constraint is not met,
``ContractVersionError`` is raised and **no rows are written**.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
contract:
The parent ``EnergyContract`` to append a version to.
effective_from:
UTC datetime at which this version's pricing takes effect.
values:
New pricing values dict conforming to the contract's profile.
Returns
-------
EnergyContractVersion
The newly created version (not yet committed).
Raises
------
ContractVersionError
If *effective_from* is not strictly after the previous open version's
``effective_from``.
ProfileNotFoundError
If no YAML profile exists for the contract's kind.
ProfileValidationError
If *values* does not conform to the profile structure.
"""
# Validate (and fill defaults) before any DB write.
filled_values = validate_values(contract.kind, values)
prev = _open_version(session, contract)
if prev is not None:
# Enforce strictly-after constraint to prevent ambiguous overlap.
# Use _as_utc() on both sides: the incoming value may be tz-aware
# while the DB-read value is tz-naive (SQLite limitation).
if _as_utc(effective_from) <= _as_utc(prev.effective_from):
raise ContractVersionError(
f"New version effective_from ({effective_from.isoformat()}) must be strictly "
f"after the previous open version's effective_from "
f"({prev.effective_from.isoformat()})."
)
# Close the previous open version.
prev.effective_to = effective_from
now = datetime.now(UTC)
new_version = EnergyContractVersion(
contract_id=contract.id,
effective_from=effective_from,
effective_to=None,
values=filled_values,
created_at=now,
)
session.add(new_version)
logger.info(
"Added version to contract id=%d (kind=%s, effective_from=%s)",
contract.id,
contract.kind,
effective_from.isoformat(),
)
return new_version
def activate_contract(session: Session, contract: EnergyContract) -> None:
"""Activate a contract with mutual exclusion.
2026-08-23 08:18:42 +02:00
Sets every other contract in the same scope inactive, then sets the given
contract active. This guarantees at most one active contract per scope.
Caller must commit after this returns.
"""
2026-08-23 08:18:42 +02:00
# This bulk update is a single write statement inside the caller's
# transaction. SQLite serializes writers, and another scope is never touched.
session.execute(
update(EnergyContract)
.where(EnergyContract.scope == contract.scope, EnergyContract.id != contract.id)
.values(active=False)
)
contract.active = True
contract.updated_at = datetime.now(UTC)
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
def deactivate_contract(session: Session, contract: EnergyContract) -> None:
"""Deactivate a contract without touching other contracts.
Caller must commit after this returns.
"""
contract.active = False
contract.updated_at = datetime.now(UTC)
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
2026-08-23 08:18:42 +02:00
def active_contract_versions(
session: Session, *, scope: str = "electricity"
) -> list[EnergyContractVersion]:
"""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
full history of the active contract (all closed + the current open version)
and is used to iterate over pricing-rate segments for cross-version fixed-
cost / credit accumulation (Principle C).
"""
active = session.execute(
2026-08-23 08:18:42 +02:00
select(EnergyContract)
.where(EnergyContract.active.is_(True), EnergyContract.scope == scope)
.limit(1)
).scalar_one_or_none()
if active is None:
return []
return list(
session.execute(
select(EnergyContractVersion)
.where(EnergyContractVersion.contract_id == active.id)
.order_by(EnergyContractVersion.effective_from)
)
.scalars()
.all()
)
def active_contract_version_at(
2026-08-23 08:18:42 +02:00
session: Session, ts: datetime, *, scope: str = "electricity"
) -> EnergyContractVersion | None:
"""Return the active contract's version that covers *ts*.
A version covers *ts* when:
``effective_from ≤ ts`` AND (``effective_to IS NULL`` OR ``ts < effective_to``)
Returns None when:
- There is no active contract.
- The active contract has no version covering *ts*.
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
ts:
UTC datetime to look up.
Returns
-------
EnergyContractVersion | None
"""
active = session.execute(
2026-08-23 08:18:42 +02:00
select(EnergyContract)
.where(EnergyContract.active.is_(True), EnergyContract.scope == scope)
.limit(1)
).scalar_one_or_none()
if active is None:
return None
# Build a query for all versions of the active contract covering ts.
stmt = (
select(EnergyContractVersion)
.where(
EnergyContractVersion.contract_id == active.id,
EnergyContractVersion.effective_from <= ts,
)
.order_by(EnergyContractVersion.effective_from.desc())
.limit(1)
)
version = session.execute(stmt).scalar_one_or_none()
if version is None:
return None
# Exclude versions whose effective window has already closed before ts.
if version.effective_to is not None and _as_utc(ts) >= _as_utc(version.effective_to):
return None
return version