Files
home-automation/app/api/routes/api/energy_contracts.py
T

332 lines
11 KiB
Python
Raw Normal View History

"""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 import timezone as _tz_mod
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),
)
def _localize_effective_from(dt: datetime | None) -> datetime:
"""Resolve *dt* to an aware UTC datetime for storage.
Rules (Principle A):
- If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before).
- If *dt* is timezone-aware → convert to UTC as-is.
- If *dt* is timezone-naive → interpret as server local wall-clock time,
localize with ``local_tz()``, then convert to UTC.
This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it
interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
not as UTC midnight.
"""
if dt is None:
return datetime.now(UTC)
if dt.tzinfo is not None:
# Already aware: convert to UTC.
return dt.astimezone(UTC)
# Naive: assume server local wall-clock.
tz = _tz_mod.local_tz()
local_dt = dt.replace(tzinfo=tz)
return local_dt.astimezone(UTC)
# ---------------------------------------------------------------------------
# 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 = _localize_effective_from(body.effective_from)
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)
effective_from = _localize_effective_from(body.effective_from)
try:
add_version(
db,
contract,
effective_from=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)