M6-T04: add EnergyContract CRUD + versions + profile validation API
- app/services/contracts.py: create_contract, add_version (append-only,
auto-closes prior version), activate_contract (mutex), and
active_contract_version_at (half-open [from, to)) for the billing engine.
- app/schemas/energy_contract.py + routes/api/energy_contracts.py: 6 endpoints
(GET/POST contracts, GET/PATCH contracts/{id}, POST .../versions, GET profiles).
Values validated against pricing profiles (422 on mismatch); no DELETE.
- main.py registers router; OpenAPI re-exported; tests for CRUD/version/activate.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""EnergyContract CRUD, versioning, and pricing-profile API (M6-T04).
|
||||
|
||||
All endpoints are under /api/energy, require an authenticated session, and
|
||||
write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
|
||||
header.
|
||||
|
||||
There is deliberately no DELETE endpoint for contracts: the FK RESTRICT
|
||||
constraint on energy_contract_version.contract_id and
|
||||
energy_cost_period.contract_version_id prevents accidental deletion of
|
||||
contracts that have billing history. Users should instead deactivate a
|
||||
contract (PATCH active=false) to stop it from being used.
|
||||
|
||||
Route ordering note
|
||||
-------------------
|
||||
GET /api/energy/profiles and GET /api/energy/contracts/{id} are on separate
|
||||
path prefixes (/energy/profiles vs /energy/contracts/{id}) so there is no
|
||||
ambiguity even without extra ordering gymnastics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_db
|
||||
from app.integrations.pricing.profiles import (
|
||||
ProfileNotFoundError,
|
||||
ProfileValidationError,
|
||||
list_profiles,
|
||||
)
|
||||
from app.schemas.energy_contract import (
|
||||
ContractCreate,
|
||||
ContractDetailResponse,
|
||||
ContractListResponse,
|
||||
ContractPatch,
|
||||
ContractResponse,
|
||||
ContractVersionResponse,
|
||||
ProfilesResponse,
|
||||
VersionCreate,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.contracts import (
|
||||
ContractVersionError,
|
||||
activate_contract,
|
||||
add_version,
|
||||
create_contract,
|
||||
deactivate_contract,
|
||||
get_contract_or_none,
|
||||
list_contracts,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/energy", tags=["api-energy-contracts"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_contract_or_404(db: Session, contract_id: int):
|
||||
"""Return the contract with the given id or raise 404."""
|
||||
contract = get_contract_or_none(db, contract_id)
|
||||
if contract is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Energy contract {contract_id!r} not found.",
|
||||
)
|
||||
return contract
|
||||
|
||||
|
||||
def _contract_detail(db: Session, contract) -> ContractDetailResponse:
|
||||
"""Build a ContractDetailResponse for *contract*, loading versions."""
|
||||
# Eager-load versions ordered by effective_from for consistent display.
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.energy import EnergyContractVersion
|
||||
|
||||
versions = list(
|
||||
db.execute(
|
||||
select(EnergyContractVersion)
|
||||
.where(EnergyContractVersion.contract_id == contract.id)
|
||||
.order_by(EnergyContractVersion.effective_from)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
version_schemas = [ContractVersionResponse.model_validate(v) for v in versions]
|
||||
return ContractDetailResponse(
|
||||
id=contract.id,
|
||||
name=contract.name,
|
||||
kind=contract.kind,
|
||||
active=contract.active,
|
||||
currency=contract.currency,
|
||||
created_at=contract.created_at,
|
||||
updated_at=contract.updated_at,
|
||||
versions=version_schemas,
|
||||
)
|
||||
|
||||
|
||||
def _raise_422_for_profile_error(exc: Exception) -> Any:
|
||||
"""Convert a ProfileValidationError or ProfileNotFoundError into HTTP 422."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=ProfilesResponse)
|
||||
def get_profiles(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ProfilesResponse:
|
||||
"""List all available pricing profile structures.
|
||||
|
||||
Returns the full profile structure for each supported contract kind
|
||||
(``manual`` and ``tibber``). The front-end uses this to dynamically
|
||||
render the correct fields and labels for the contract creation/editing form.
|
||||
"""
|
||||
return ProfilesResponse(profiles=list_profiles())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/contracts", response_model=ContractListResponse)
|
||||
def list_energy_contracts(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ContractListResponse:
|
||||
"""List all energy contracts with their active status.
|
||||
|
||||
Returns a flat list (no embedded version history); use
|
||||
GET /api/energy/contracts/{id} to fetch the full version history for a
|
||||
specific contract.
|
||||
"""
|
||||
contracts = list_contracts(db)
|
||||
items = [ContractResponse.model_validate(c) for c in contracts]
|
||||
return ContractListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contracts",
|
||||
response_model=ContractDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_energy_contract(
|
||||
body: ContractCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ContractDetailResponse:
|
||||
"""Create a new energy contract with an initial pricing version.
|
||||
|
||||
The ``values`` dict is validated against the YAML profile for the given
|
||||
``kind``; non-conforming values result in 422 Unprocessable Entity.
|
||||
The new contract is created with ``active=False``; use
|
||||
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
|
||||
"""
|
||||
effective_from = body.effective_from or datetime.now(UTC)
|
||||
|
||||
try:
|
||||
contract = create_contract(
|
||||
db,
|
||||
name=body.name,
|
||||
kind=body.kind,
|
||||
currency=body.currency,
|
||||
values=body.values,
|
||||
effective_from=effective_from,
|
||||
)
|
||||
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||
_raise_422_for_profile_error(exc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
logger.info("Created energy contract id=%d name=%r kind=%s", contract.id, contract.name, contract.kind)
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/contracts/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/contracts/{contract_id}", response_model=ContractDetailResponse)
|
||||
def get_energy_contract(
|
||||
contract_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ContractDetailResponse:
|
||||
"""Return a single energy contract with its full version history.
|
||||
|
||||
Versions are ordered by ``effective_from`` ascending so the caller can
|
||||
easily inspect the pricing timeline.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/energy/contracts/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/contracts/{contract_id}", response_model=ContractDetailResponse)
|
||||
def patch_energy_contract(
|
||||
contract_id: int,
|
||||
body: ContractPatch,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ContractDetailResponse:
|
||||
"""Partially update a contract: rename or change activation status.
|
||||
|
||||
- ``name``: updates the human-readable label.
|
||||
- ``active=true``: activates this contract (all others are deactivated).
|
||||
- ``active=false``: deactivates this contract (no effect on others).
|
||||
|
||||
At most one contract may be active at any time; the service layer enforces
|
||||
mutual exclusion.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
|
||||
if body.name is not None:
|
||||
contract.name = body.name
|
||||
contract.updated_at = datetime.now(UTC)
|
||||
|
||||
if body.active is True:
|
||||
activate_contract(db, contract)
|
||||
elif body.active is False:
|
||||
deactivate_contract(db, contract)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
return _contract_detail(db, contract)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/contracts/{id}/versions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contracts/{contract_id}/versions",
|
||||
response_model=ContractDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_contract_version(
|
||||
contract_id: int,
|
||||
body: VersionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ContractDetailResponse:
|
||||
"""Add a new pricing version to an existing contract.
|
||||
|
||||
This is how price changes are recorded: the current open version is
|
||||
automatically closed (its ``effective_to`` is set to ``body.effective_from``)
|
||||
and a new version is created starting at ``body.effective_from``.
|
||||
|
||||
The ``values`` dict must conform to the contract's pricing profile.
|
||||
Non-conforming values return 422. If ``effective_from`` is not strictly
|
||||
after the previous version's ``effective_from``, 422 is returned without
|
||||
writing any rows.
|
||||
|
||||
Historical versions are never modified; this endpoint is append-only.
|
||||
"""
|
||||
contract = _get_contract_or_404(db, contract_id)
|
||||
|
||||
try:
|
||||
add_version(
|
||||
db,
|
||||
contract,
|
||||
effective_from=body.effective_from,
|
||||
values=body.values,
|
||||
)
|
||||
except ContractVersionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
)
|
||||
except (ProfileNotFoundError, ProfileValidationError) as exc:
|
||||
_raise_422_for_profile_error(exc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(contract)
|
||||
return _contract_detail(db, contract)
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
|
||||
from app.api.routes.api.expose import router as api_expose_router
|
||||
from app.api.routes.api.modbus import router as api_modbus_router
|
||||
from app.api.routes.api.session import router as api_session_router
|
||||
@@ -188,6 +189,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(status.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_energy_contracts_router)
|
||||
app.include_router(api_expose_router)
|
||||
app.include_router(api_modbus_router)
|
||||
app.include_router(api_session_router)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Pydantic schemas for the EnergyContract CRUD + versioning API (M6-T04).
|
||||
|
||||
Schema hierarchy
|
||||
----------------
|
||||
ContractVersionResponse — single version row (id, dates, values, created_at)
|
||||
ContractResponse — contract head (id, name, kind, active, currency, timestamps)
|
||||
ContractDetailResponse — contract head + embedded versions list (for GET{id})
|
||||
ContractListResponse — paginated list of ContractResponse items
|
||||
ContractCreate — POST /api/energy/contracts body
|
||||
ContractPatch — PATCH /api/energy/contracts/{id} body (all fields optional)
|
||||
VersionCreate — POST /api/energy/contracts/{id}/versions body
|
||||
ProfilesResponse — GET /api/energy/profiles response
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractVersionResponse(BaseModel):
|
||||
"""Response schema for a single EnergyContractVersion row."""
|
||||
|
||||
id: int
|
||||
effective_from: datetime
|
||||
effective_to: datetime | None
|
||||
values: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractResponse(BaseModel):
|
||||
"""Response schema for a single EnergyContract (without embedded versions).
|
||||
|
||||
Used for list responses where embedding all versions would be expensive.
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
active: bool
|
||||
currency: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ContractDetailResponse(BaseModel):
|
||||
"""Response schema for a single EnergyContract with full version history.
|
||||
|
||||
Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH
|
||||
operations where the caller needs to see all version data.
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
active: bool
|
||||
currency: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
versions: list[ContractVersionResponse]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ContractListResponse(BaseModel):
|
||||
"""Response schema for GET /api/energy/contracts."""
|
||||
|
||||
items: list[ContractResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContractCreate(BaseModel):
|
||||
"""Request body for POST /api/energy/contracts.
|
||||
|
||||
``effective_from`` defaults to the current UTC time if not provided,
|
||||
giving the first version an open-ended start from "now".
|
||||
``kind`` is validated at the application layer against the profile registry;
|
||||
clients should send ``"manual"`` or ``"tibber"``.
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
kind: str = Field(..., min_length=1, max_length=32)
|
||||
currency: str = Field(default="EUR", min_length=1, max_length=8)
|
||||
values: dict[str, Any]
|
||||
effective_from: datetime | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"UTC datetime from which the first pricing version is effective. "
|
||||
"Defaults to the current UTC time when omitted."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ContractPatch(BaseModel):
|
||||
"""Request body for PATCH /api/energy/contracts/{id}.
|
||||
|
||||
All fields are optional. Sending ``active=true`` activates this contract
|
||||
(deactivating all others); ``active=false`` deactivates it without affecting
|
||||
other contracts.
|
||||
"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
class VersionCreate(BaseModel):
|
||||
"""Request body for POST /api/energy/contracts/{id}/versions."""
|
||||
|
||||
effective_from: datetime
|
||||
values: dict[str, Any]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfilesResponse(BaseModel):
|
||||
"""Response schema for GET /api/energy/profiles.
|
||||
|
||||
``profiles`` is a list of raw profile dicts as produced by
|
||||
``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum
|
||||
``kind`` and ``label``; the full nested structure allows the front-end to
|
||||
render a type-appropriate form for each pricing profile.
|
||||
"""
|
||||
|
||||
profiles: list[dict[str, Any]]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user