2026-06-23 21:19:25 +02:00
|
|
|
"""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
|
2026-06-25 11:13:30 +02:00
|
|
|
from app.services import timezone as _tz_mod
|
2026-06-23 21:19:25 +02:00
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 21:19:25 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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.
|
|
|
|
|
"""
|
2026-06-25 11:13:30 +02:00
|
|
|
effective_from = _localize_effective_from(body.effective_from)
|
2026-06-23 21:19:25 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-06-25 11:13:30 +02:00
|
|
|
effective_from = _localize_effective_from(body.effective_from)
|
2026-06-23 21:19:25 +02:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
add_version(
|
|
|
|
|
db,
|
|
|
|
|
contract,
|
2026-06-25 11:13:30 +02:00
|
|
|
effective_from=effective_from,
|
2026-06-23 21:19:25 +02:00
|
|
|
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)
|