339 lines
11 KiB
Python
339 lines
11 KiB
Python
"""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_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
|