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

365 lines
12 KiB
Python
Raw 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``.
- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active``
to False, then sets the given contract's ``active`` to True.
- ``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
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.integrations.pricing.profiles import validate_values
from app.models.energy import EnergyContract, EnergyContractVersion
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 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()
def list_contracts(session: Session) -> list[EnergyContract]:
"""Return all contracts ordered by id (ascending)."""
return list(
session.execute(select(EnergyContract).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",
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"`` or ``"tibber"``).
currency:
ISO 4217 currency code (default ``"EUR"``).
values:
Pricing values dict conforming to the named profile's structure.
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.
"""
# Validate (and fill defaults) before any DB write.
filled_values = validate_values(kind, values)
now = datetime.now(UTC)
contract = EnergyContract(
name=name,
kind=kind,
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.
Sets every other contract's ``active`` flag to False, then sets the given
contract's ``active`` to True. This guarantees at most one active contract
at any time.
Caller must commit after this returns.
"""
# Deactivate all contracts (including the target; we re-activate below).
for other in session.execute(select(EnergyContract)).scalars().all():
other.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)
def active_contract_versions(session: Session) -> 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(
select(EnergyContract).where(EnergyContract.active.is_(True)).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(
session: Session, ts: datetime
) -> 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(
select(EnergyContract).where(EnergyContract.active.is_(True)).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