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 import models # noqa: F401
|
||||||
from app.api.routes.api.config import router as api_config_router
|
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.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.expose import router as api_expose_router
|
||||||
from app.api.routes.api.modbus import router as api_modbus_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
|
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(status.router)
|
||||||
app.include_router(api_config_router)
|
app.include_router(api_config_router)
|
||||||
app.include_router(api_data_router)
|
app.include_router(api_data_router)
|
||||||
|
app.include_router(api_energy_contracts_router)
|
||||||
app.include_router(api_expose_router)
|
app.include_router(api_expose_router)
|
||||||
app.include_router(api_modbus_router)
|
app.include_router(api_modbus_router)
|
||||||
app.include_router(api_session_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
|
||||||
@@ -333,7 +333,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
|
- **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。
|
||||||
|
|
||||||
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API)
|
### M6-T04 — EnergyContract CRUD + 版本 + 校验(API)
|
||||||
- **Status**: `todo` · **Depends**: M6-T01, M6-T03
|
- **Status**: `done` · **Depends**: M6-T01, M6-T03
|
||||||
- **Context**: 合同增删改、版本(改价加新版本、生效日期)、激活(一次一个)、按 profile 校验数值。
|
- **Context**: 合同增删改、版本(改价加新版本、生效日期)、激活(一次一个)、按 profile 校验数值。
|
||||||
- **Files**: `create app/api/routes/api/energy_contracts.py`、`app/schemas/energy_contract.py`、`app/services/contracts.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy_contracts.py`
|
- **Files**: `create app/api/routes/api/energy_contracts.py`、`app/schemas/energy_contract.py`、`app/services/contracts.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy_contracts.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
|
|||||||
@@ -695,6 +695,287 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/energy/profiles": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-contracts"
|
||||||
|
],
|
||||||
|
"summary": "Get Profiles",
|
||||||
|
"description": "List all available pricing profile structures.\n\nReturns the full profile structure for each supported contract kind\n(``manual`` and ``tibber``). The front-end uses this to dynamically\nrender the correct fields and labels for the contract creation/editing form.",
|
||||||
|
"operationId": "get_profiles_api_energy_profiles_get",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProfilesResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/energy/contracts": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-contracts"
|
||||||
|
],
|
||||||
|
"summary": "List Energy Contracts",
|
||||||
|
"description": "List all energy contracts with their active status.\n\nReturns a flat list (no embedded version history); use\nGET /api/energy/contracts/{id} to fetch the full version history for a\nspecific contract.",
|
||||||
|
"operationId": "list_energy_contracts_api_energy_contracts_get",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-contracts"
|
||||||
|
],
|
||||||
|
"summary": "Create Energy Contract",
|
||||||
|
"description": "Create a new energy contract with an initial pricing version.\n\nThe ``values`` dict is validated against the YAML profile for the given\n``kind``; non-conforming values result in 422 Unprocessable Entity.\nThe new contract is created with ``active=False``; use\nPATCH /api/energy/contracts/{id} with ``active=true`` to activate it.",
|
||||||
|
"operationId": "create_energy_contract_api_energy_contracts_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "X-CSRF-Token",
|
||||||
|
"in": "header",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Csrf-Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractDetailResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/energy/contracts/{contract_id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-contracts"
|
||||||
|
],
|
||||||
|
"summary": "Get Energy Contract",
|
||||||
|
"description": "Return a single energy contract with its full version history.\n\nVersions are ordered by ``effective_from`` ascending so the caller can\neasily inspect the pricing timeline.",
|
||||||
|
"operationId": "get_energy_contract_api_energy_contracts__contract_id__get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "contract_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Contract Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractDetailResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-contracts"
|
||||||
|
],
|
||||||
|
"summary": "Patch Energy Contract",
|
||||||
|
"description": "Partially update a contract: rename or change activation status.\n\n- ``name``: updates the human-readable label.\n- ``active=true``: activates this contract (all others are deactivated).\n- ``active=false``: deactivates this contract (no effect on others).\n\nAt most one contract may be active at any time; the service layer enforces\nmutual exclusion.",
|
||||||
|
"operationId": "patch_energy_contract_api_energy_contracts__contract_id__patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "contract_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Contract Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "X-CSRF-Token",
|
||||||
|
"in": "header",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Csrf-Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractPatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractDetailResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/energy/contracts/{contract_id}/versions": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-contracts"
|
||||||
|
],
|
||||||
|
"summary": "Add Contract Version",
|
||||||
|
"description": "Add a new pricing version to an existing contract.\n\nThis is how price changes are recorded: the current open version is\nautomatically closed (its ``effective_to`` is set to ``body.effective_from``)\nand a new version is created starting at ``body.effective_from``.\n\nThe ``values`` dict must conform to the contract's pricing profile.\nNon-conforming values return 422. If ``effective_from`` is not strictly\nafter the previous version's ``effective_from``, 422 is returned without\nwriting any rows.\n\nHistorical versions are never modified; this endpoint is append-only.",
|
||||||
|
"operationId": "add_contract_version_api_energy_contracts__contract_id__versions_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "contract_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Contract Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "X-CSRF-Token",
|
||||||
|
"in": "header",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "X-Csrf-Token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/VersionCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ContractDetailResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/expose": {
|
"/api/expose": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1933,6 +2214,253 @@
|
|||||||
],
|
],
|
||||||
"title": "ConfigUpdateResponse"
|
"title": "ConfigUpdateResponse"
|
||||||
},
|
},
|
||||||
|
"ContractCreate": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 255,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 32,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Kind"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 8,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Currency",
|
||||||
|
"default": "EUR"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object",
|
||||||
|
"title": "Values"
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Effective From",
|
||||||
|
"description": "UTC datetime from which the first pricing version is effective. Defaults to the current UTC time when omitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"values"
|
||||||
|
],
|
||||||
|
"title": "ContractCreate",
|
||||||
|
"description": "Request body for POST /api/energy/contracts.\n\n``effective_from`` defaults to the current UTC time if not provided,\ngiving the first version an open-ended start from \"now\".\n``kind`` is validated at the application layer against the profile registry;\nclients should send ``\"manual\"`` or ``\"tibber\"``."
|
||||||
|
},
|
||||||
|
"ContractDetailResponse": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Kind"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Active"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Currency"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Updated At"
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ContractVersionResponse"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Versions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"active",
|
||||||
|
"currency",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"versions"
|
||||||
|
],
|
||||||
|
"title": "ContractDetailResponse",
|
||||||
|
"description": "Response schema for a single EnergyContract with full version history.\n\nReturned by GET /api/energy/contracts/{id} and by successful POST / PATCH\noperations where the caller needs to see all version data."
|
||||||
|
},
|
||||||
|
"ContractListResponse": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ContractResponse"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Items"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Total"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"items",
|
||||||
|
"total"
|
||||||
|
],
|
||||||
|
"title": "ContractListResponse",
|
||||||
|
"description": "Response schema for GET /api/energy/contracts."
|
||||||
|
},
|
||||||
|
"ContractPatch": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 255,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "ContractPatch",
|
||||||
|
"description": "Request body for PATCH /api/energy/contracts/{id}.\n\nAll fields are optional. Sending ``active=true`` activates this contract\n(deactivating all others); ``active=false`` deactivates it without affecting\nother contracts."
|
||||||
|
},
|
||||||
|
"ContractResponse": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Kind"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Active"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Currency"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Updated At"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"active",
|
||||||
|
"currency",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
"title": "ContractResponse",
|
||||||
|
"description": "Response schema for a single EnergyContract (without embedded versions).\n\nUsed for list responses where embedding all versions would be expensive."
|
||||||
|
},
|
||||||
|
"ContractVersionResponse": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Effective From"
|
||||||
|
},
|
||||||
|
"effective_to": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Effective To"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object",
|
||||||
|
"title": "Values"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"effective_from",
|
||||||
|
"effective_to",
|
||||||
|
"values",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"title": "ContractVersionResponse",
|
||||||
|
"description": "Response schema for a single EnergyContractVersion row."
|
||||||
|
},
|
||||||
"DeviceInfoSchema": {
|
"DeviceInfoSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"identifiers": {
|
"identifiers": {
|
||||||
@@ -2868,6 +3396,24 @@
|
|||||||
"title": "ProfileSummary",
|
"title": "ProfileSummary",
|
||||||
"description": "One entry in the GET /api/modbus/profiles response."
|
"description": "One entry in the GET /api/modbus/profiles response."
|
||||||
},
|
},
|
||||||
|
"ProfilesResponse": {
|
||||||
|
"properties": {
|
||||||
|
"profiles": {
|
||||||
|
"items": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Profiles"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"title": "ProfilesResponse",
|
||||||
|
"description": "Response schema for GET /api/energy/profiles.\n\n``profiles`` is a list of raw profile dicts as produced by\n``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum\n``kind`` and ``label``; the full nested structure allows the front-end to\nrender a type-appropriate form for each pricing profile."
|
||||||
|
},
|
||||||
"PublicIPCheckResponse": {
|
"PublicIPCheckResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
@@ -3257,6 +3803,27 @@
|
|||||||
"type"
|
"type"
|
||||||
],
|
],
|
||||||
"title": "ValidationError"
|
"title": "ValidationError"
|
||||||
|
},
|
||||||
|
"VersionCreate": {
|
||||||
|
"properties": {
|
||||||
|
"effective_from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Effective From"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object",
|
||||||
|
"title": "Values"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"effective_from",
|
||||||
|
"values"
|
||||||
|
],
|
||||||
|
"title": "VersionCreate",
|
||||||
|
"description": "Request body for POST /api/energy/contracts/{id}/versions."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,6 +516,235 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
/api/energy/profiles:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- api-energy-contracts
|
||||||
|
summary: Get Profiles
|
||||||
|
description: '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.'
|
||||||
|
operationId: get_profiles_api_energy_profiles_get
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProfilesResponse'
|
||||||
|
/api/energy/contracts:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- api-energy-contracts
|
||||||
|
summary: List Energy Contracts
|
||||||
|
description: '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.'
|
||||||
|
operationId: list_energy_contracts_api_energy_contracts_get
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractListResponse'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- api-energy-contracts
|
||||||
|
summary: Create Energy Contract
|
||||||
|
description: '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.'
|
||||||
|
operationId: create_energy_contract_api_energy_contracts_post
|
||||||
|
parameters:
|
||||||
|
- name: X-CSRF-Token
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: X-Csrf-Token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractCreate'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractDetailResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
/api/energy/contracts/{contract_id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- api-energy-contracts
|
||||||
|
summary: Get Energy Contract
|
||||||
|
description: '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.'
|
||||||
|
operationId: get_energy_contract_api_energy_contracts__contract_id__get
|
||||||
|
parameters:
|
||||||
|
- name: contract_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
title: Contract Id
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractDetailResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- api-energy-contracts
|
||||||
|
summary: Patch Energy Contract
|
||||||
|
description: '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.'
|
||||||
|
operationId: patch_energy_contract_api_energy_contracts__contract_id__patch
|
||||||
|
parameters:
|
||||||
|
- name: contract_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
title: Contract Id
|
||||||
|
- name: X-CSRF-Token
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: X-Csrf-Token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractPatch'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractDetailResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
/api/energy/contracts/{contract_id}/versions:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- api-energy-contracts
|
||||||
|
summary: Add Contract Version
|
||||||
|
description: '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.'
|
||||||
|
operationId: add_contract_version_api_energy_contracts__contract_id__versions_post
|
||||||
|
parameters:
|
||||||
|
- name: contract_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
title: Contract Id
|
||||||
|
- name: X-CSRF-Token
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: X-Csrf-Token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VersionCreate'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ContractDetailResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
/api/expose:
|
/api/expose:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -1464,6 +1693,212 @@ components:
|
|||||||
required:
|
required:
|
||||||
- sections
|
- sections
|
||||||
title: ConfigUpdateResponse
|
title: ConfigUpdateResponse
|
||||||
|
ContractCreate:
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
title: Name
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
minLength: 1
|
||||||
|
title: Kind
|
||||||
|
currency:
|
||||||
|
type: string
|
||||||
|
maxLength: 8
|
||||||
|
minLength: 1
|
||||||
|
title: Currency
|
||||||
|
default: EUR
|
||||||
|
values:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
title: Values
|
||||||
|
effective_from:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
format: date-time
|
||||||
|
- type: 'null'
|
||||||
|
title: Effective From
|
||||||
|
description: UTC datetime from which the first pricing version is effective.
|
||||||
|
Defaults to the current UTC time when omitted.
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- kind
|
||||||
|
- values
|
||||||
|
title: ContractCreate
|
||||||
|
description: '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"``.'
|
||||||
|
ContractDetailResponse:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
title: Id
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
title: Name
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
title: Kind
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
title: Active
|
||||||
|
currency:
|
||||||
|
type: string
|
||||||
|
title: Currency
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Created At
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Updated At
|
||||||
|
versions:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ContractVersionResponse'
|
||||||
|
type: array
|
||||||
|
title: Versions
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- kind
|
||||||
|
- active
|
||||||
|
- currency
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
- versions
|
||||||
|
title: ContractDetailResponse
|
||||||
|
description: '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.'
|
||||||
|
ContractListResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ContractResponse'
|
||||||
|
type: array
|
||||||
|
title: Items
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
title: Total
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- items
|
||||||
|
- total
|
||||||
|
title: ContractListResponse
|
||||||
|
description: Response schema for GET /api/energy/contracts.
|
||||||
|
ContractPatch:
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
- type: 'null'
|
||||||
|
title: Name
|
||||||
|
active:
|
||||||
|
anyOf:
|
||||||
|
- type: boolean
|
||||||
|
- type: 'null'
|
||||||
|
title: Active
|
||||||
|
type: object
|
||||||
|
title: ContractPatch
|
||||||
|
description: '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.'
|
||||||
|
ContractResponse:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
title: Id
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
title: Name
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
title: Kind
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
title: Active
|
||||||
|
currency:
|
||||||
|
type: string
|
||||||
|
title: Currency
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Created At
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Updated At
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- kind
|
||||||
|
- active
|
||||||
|
- currency
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
title: ContractResponse
|
||||||
|
description: 'Response schema for a single EnergyContract (without embedded
|
||||||
|
versions).
|
||||||
|
|
||||||
|
|
||||||
|
Used for list responses where embedding all versions would be expensive.'
|
||||||
|
ContractVersionResponse:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
title: Id
|
||||||
|
effective_from:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Effective From
|
||||||
|
effective_to:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
format: date-time
|
||||||
|
- type: 'null'
|
||||||
|
title: Effective To
|
||||||
|
values:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
title: Values
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Created At
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- effective_from
|
||||||
|
- effective_to
|
||||||
|
- values
|
||||||
|
- created_at
|
||||||
|
title: ContractVersionResponse
|
||||||
|
description: Response schema for a single EnergyContractVersion row.
|
||||||
DeviceInfoSchema:
|
DeviceInfoSchema:
|
||||||
properties:
|
properties:
|
||||||
identifiers:
|
identifiers:
|
||||||
@@ -2113,6 +2548,28 @@ components:
|
|||||||
- description
|
- description
|
||||||
title: ProfileSummary
|
title: ProfileSummary
|
||||||
description: One entry in the GET /api/modbus/profiles response.
|
description: One entry in the GET /api/modbus/profiles response.
|
||||||
|
ProfilesResponse:
|
||||||
|
properties:
|
||||||
|
profiles:
|
||||||
|
items:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
title: Profiles
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- profiles
|
||||||
|
title: ProfilesResponse
|
||||||
|
description: '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.'
|
||||||
PublicIPCheckResponse:
|
PublicIPCheckResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
@@ -2384,3 +2841,19 @@ components:
|
|||||||
- msg
|
- msg
|
||||||
- type
|
- type
|
||||||
title: ValidationError
|
title: ValidationError
|
||||||
|
VersionCreate:
|
||||||
|
properties:
|
||||||
|
effective_from:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Effective From
|
||||||
|
values:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
title: Values
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- effective_from
|
||||||
|
- values
|
||||||
|
title: VersionCreate
|
||||||
|
description: Request body for POST /api/energy/contracts/{id}/versions.
|
||||||
|
|||||||
@@ -0,0 +1,750 @@
|
|||||||
|
"""Tests for M6-T04: EnergyContract CRUD + versioning + pricing-profile API.
|
||||||
|
|
||||||
|
Coverage matrix
|
||||||
|
---------------
|
||||||
|
GET /api/energy/profiles
|
||||||
|
- unauthenticated → 401
|
||||||
|
- authenticated → 200, both 'manual' and 'tibber' profiles present
|
||||||
|
|
||||||
|
GET /api/energy/contracts
|
||||||
|
- unauthenticated → 401
|
||||||
|
- authenticated empty → 200, items=[]
|
||||||
|
- after creating two contracts → items with active flag correct
|
||||||
|
|
||||||
|
POST /api/energy/contracts
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- invalid values (missing required field) → 422, DB unchanged
|
||||||
|
- unknown kind → 422
|
||||||
|
- valid manual → 201, ContractDetailResponse with versions
|
||||||
|
- valid tibber → 201
|
||||||
|
|
||||||
|
GET /api/energy/contracts/{id}
|
||||||
|
- unauthenticated → 401
|
||||||
|
- not found → 404
|
||||||
|
- success → versions ordered by effective_from
|
||||||
|
|
||||||
|
PATCH /api/energy/contracts/{id}
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- not found → 404
|
||||||
|
- rename → name updated
|
||||||
|
- activate → mutual exclusion (A active → activate B → A.active=False)
|
||||||
|
- deactivate → active=False, others unchanged
|
||||||
|
|
||||||
|
POST /api/energy/contracts/{id}/versions
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- not found → 404
|
||||||
|
- invalid values → 422, no rows written, old version unchanged
|
||||||
|
- effective_from ≤ previous → 422
|
||||||
|
- success → old version closed (effective_to set), new open version added,
|
||||||
|
old version values unchanged (only-append semantics)
|
||||||
|
|
||||||
|
Auth/CSRF matrix tested for all write endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.energy import EnergyContract, EnergyContractVersion
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CSRF = "test-csrf-token"
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "test-password"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal valid values for each kind.
|
||||||
|
|
||||||
|
_MANUAL_VALUES: dict[str, Any] = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"normal": 0.40, "dal": 0.30},
|
||||||
|
"sell": {"normal": 0.10, "dal": 0.10},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {
|
||||||
|
"network_fee": 25.0,
|
||||||
|
"management_fee": 9.87,
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"heffingskorting": 600.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_TIBBER_VALUES: dict[str, Any] = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_adjust": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {
|
||||||
|
"management_fee": 5.99,
|
||||||
|
"network_fee": 25.0,
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"heffingskorting": 600.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_payload(**overrides) -> dict[str, Any]:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"name": "Test Manual Contract",
|
||||||
|
"kind": "manual",
|
||||||
|
"currency": "EUR",
|
||||||
|
"values": _MANUAL_VALUES,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _tibber_payload(**overrides) -> dict[str, Any]:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"name": "Test Tibber Contract",
|
||||||
|
"kind": "tibber",
|
||||||
|
"currency": "EUR",
|
||||||
|
"values": _TIBBER_VALUES,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def contracts_client(auth_database):
|
||||||
|
"""TestClient + SQLAlchemy engine for EnergyContract API tests."""
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app_url = auth_database["app_url"]
|
||||||
|
engine = create_engine(app_url, connect_args={"check_same_thread": False})
|
||||||
|
fastapi_app = create_app()
|
||||||
|
with TestClient(fastapi_app) as test_client:
|
||||||
|
yield test_client, engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.get("/api/energy/profiles")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_returns_both_kinds(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/profiles")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "profiles" in body
|
||||||
|
kinds = {p["kind"] for p in body["profiles"]}
|
||||||
|
assert "manual" in kinds
|
||||||
|
assert "tibber" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_contain_structure(contracts_client):
|
||||||
|
"""Each profile entry must have the nested structure the front-end needs."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/profiles")
|
||||||
|
body = resp.json()
|
||||||
|
for profile in body["profiles"]:
|
||||||
|
assert "kind" in profile
|
||||||
|
assert "label" in profile
|
||||||
|
assert "energy" in profile
|
||||||
|
assert "standing" in profile
|
||||||
|
assert "credits" in profile
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_contracts_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.get("/api/energy/contracts")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_contracts_empty(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/contracts")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_contracts_shows_active_flag(contracts_client):
|
||||||
|
"""After creating two contracts and activating one, active flag is correct."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Create contract A
|
||||||
|
resp_a = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Contract A"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_a.status_code == 201
|
||||||
|
id_a = resp_a.json()["id"]
|
||||||
|
|
||||||
|
# Create contract B
|
||||||
|
resp_b = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Contract B"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_b.status_code == 201
|
||||||
|
id_b = resp_b.json()["id"]
|
||||||
|
|
||||||
|
# Activate A
|
||||||
|
client.patch(
|
||||||
|
f"/api/energy/contracts/{id_a}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/energy/contracts")
|
||||||
|
items = {c["id"]: c for c in resp.json()["items"]}
|
||||||
|
assert items[id_a]["active"] is True
|
||||||
|
assert items[id_b]["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_missing_csrf_returns_403(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post("/api/energy/contracts", json=_manual_payload())
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_invalid_values_returns_422_no_db_write(contracts_client):
|
||||||
|
"""Non-conforming values must return 422 without writing any rows."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Missing required field energy.buy.normal
|
||||||
|
bad_values = {
|
||||||
|
"energy": {
|
||||||
|
"buy": {"dal": 0.30}, # missing "normal"
|
||||||
|
"sell": {"normal": 0.10, "dal": 0.10},
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"ode": 0.0,
|
||||||
|
},
|
||||||
|
"standing": {"network_fee": 25.0, "management_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(values=bad_values),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# Confirm no rows were written.
|
||||||
|
with Session(engine) as s:
|
||||||
|
count = s.execute(select(EnergyContract)).scalars().all()
|
||||||
|
assert len(count) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_unknown_kind_returns_422(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json={
|
||||||
|
"name": "Bad Kind",
|
||||||
|
"kind": "nonexistent_kind_xyz",
|
||||||
|
"values": {},
|
||||||
|
},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_manual_contract_success(contracts_client):
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "Test Manual Contract"
|
||||||
|
assert body["kind"] == "manual"
|
||||||
|
assert body["active"] is False # new contracts are inactive by default
|
||||||
|
assert "versions" in body
|
||||||
|
assert len(body["versions"]) == 1
|
||||||
|
v = body["versions"][0]
|
||||||
|
assert v["effective_to"] is None # open-ended first version
|
||||||
|
|
||||||
|
# DB check
|
||||||
|
with Session(engine) as s:
|
||||||
|
contracts = s.execute(select(EnergyContract)).scalars().all()
|
||||||
|
assert len(contracts) == 1
|
||||||
|
assert contracts[0].name == "Test Manual Contract"
|
||||||
|
versions = s.execute(select(EnergyContractVersion)).scalars().all()
|
||||||
|
assert len(versions) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_tibber_contract_success(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_tibber_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "tibber"
|
||||||
|
assert len(body["versions"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_contract_defaults_effective_from(contracts_client):
|
||||||
|
"""When effective_from is omitted, the version is created with a recent timestamp.
|
||||||
|
|
||||||
|
SQLite stores datetimes as naive UTC strings; we compare both sides as naive UTC.
|
||||||
|
"""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
# Use naive UTC (no tzinfo) to match what SQLite gives back via Pydantic.
|
||||||
|
# Remove tzinfo from datetime.now(UTC) to get a comparable naive datetime.
|
||||||
|
before = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
v = resp.json()["versions"][0]
|
||||||
|
# Parse as naive (SQLite round-trip strips tz)
|
||||||
|
raw_eff = datetime.fromisoformat(v["effective_from"])
|
||||||
|
# Strip tzinfo from raw_eff if present (shouldn't be, but guard anyway)
|
||||||
|
eff_naive = raw_eff.replace(tzinfo=None)
|
||||||
|
assert eff_naive >= before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/contracts/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contract_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.get("/api/energy/contracts/1")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contract_not_found_returns_404(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/contracts/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contract_returns_versions_ordered(contracts_client):
|
||||||
|
"""GET {id} must return versions ordered by effective_from."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
|
||||||
|
t1 = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
new_values = dict(_MANUAL_VALUES)
|
||||||
|
new_values = {
|
||||||
|
**_MANUAL_VALUES,
|
||||||
|
"energy": {**_MANUAL_VALUES["energy"], "buy": {"normal": 0.50, "dal": 0.40}},
|
||||||
|
}
|
||||||
|
client.post(
|
||||||
|
f"/api/energy/contracts/{contract_id}/versions",
|
||||||
|
json={"effective_from": t1.isoformat(), "values": new_values},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/energy/contracts/{contract_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
versions = resp.json()["versions"]
|
||||||
|
assert len(versions) == 2
|
||||||
|
# Ordered by effective_from ascending
|
||||||
|
eff0 = datetime.fromisoformat(versions[0]["effective_from"])
|
||||||
|
eff1 = datetime.fromisoformat(versions[1]["effective_from"])
|
||||||
|
assert eff0 < eff1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/energy/contracts/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/energy/contracts/1",
|
||||||
|
json={"name": "Renamed"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_missing_csrf_returns_403(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
# Create a contract first
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
# PATCH without CSRF
|
||||||
|
resp = client.patch(f"/api/energy/contracts/{contract_id}", json={"name": "Renamed"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_not_found_returns_404(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/energy/contracts/99999",
|
||||||
|
json={"name": "Does Not Exist"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_contract_rename(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="Original Name"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{contract_id}",
|
||||||
|
json={"name": "Renamed Contract"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "Renamed Contract"
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_contract_mutual_exclusion(contracts_client):
|
||||||
|
"""Activating B deactivates A; at most one contract is active."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Create A and B
|
||||||
|
resp_a = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="A"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
id_a = resp_a.json()["id"]
|
||||||
|
|
||||||
|
resp_b = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(name="B"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
id_b = resp_b.json()["id"]
|
||||||
|
|
||||||
|
# Activate A
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{id_a}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is True
|
||||||
|
|
||||||
|
# Now activate B — A must become inactive
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{id_b}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is True
|
||||||
|
|
||||||
|
# Verify A is now inactive via list
|
||||||
|
list_resp = client.get("/api/energy/contracts")
|
||||||
|
items = {c["id"]: c for c in list_resp.json()["items"]}
|
||||||
|
assert items[id_a]["active"] is False
|
||||||
|
assert items[id_b]["active"] is True
|
||||||
|
|
||||||
|
# DB check: only one active
|
||||||
|
with Session(engine) as s:
|
||||||
|
active_contracts = (
|
||||||
|
s.execute(select(EnergyContract).where(EnergyContract.active.is_(True)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(active_contracts) == 1
|
||||||
|
assert active_contracts[0].id == id_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivate_contract(contracts_client):
|
||||||
|
"""PATCH active=false deactivates the contract without touching others."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
contract_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Activate it first
|
||||||
|
client.patch(
|
||||||
|
f"/api/energy/contracts/{contract_id}",
|
||||||
|
json={"active": True},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now deactivate
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/contracts/{contract_id}",
|
||||||
|
json={"active": False},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/contracts/{id}/versions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_unauthenticated_returns_401(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts/1/versions",
|
||||||
|
json={"effective_from": datetime.now(UTC).isoformat(), "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_missing_csrf_returns_403(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
# POST version without CSRF
|
||||||
|
t1 = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t1, "values": _MANUAL_VALUES},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_not_found_returns_404(contracts_client):
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
t1 = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/contracts/99999/versions",
|
||||||
|
json={"effective_from": t1, "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_invalid_values_returns_422_no_db_write(contracts_client):
|
||||||
|
"""Non-conforming values for a new version must return 422; DB unchanged."""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
|
||||||
|
bad_values = {"energy": {}} # missing almost everything
|
||||||
|
t1 = datetime(2026, 6, 1, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t1.isoformat(), "values": bad_values},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# Only the original version should exist in DB.
|
||||||
|
with Session(engine) as s:
|
||||||
|
versions = (
|
||||||
|
s.execute(
|
||||||
|
select(EnergyContractVersion).where(EnergyContractVersion.contract_id == cid)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(versions) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_effective_from_not_after_previous_returns_422(contracts_client):
|
||||||
|
"""effective_from ≤ previous open version's effective_from must return 422."""
|
||||||
|
client, _ = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 6, 1, tzinfo=UTC)
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
|
||||||
|
# Attempt to add a version with t1 == t0 (not strictly after)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t0.isoformat(), "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# Attempt to add a version with t1 < t0
|
||||||
|
t_before = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t_before.isoformat(), "values": _MANUAL_VALUES},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_version_closes_previous_and_appends(contracts_client):
|
||||||
|
"""Adding a new version must close the previous one and create a new open version.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- Old version's effective_to == new version's effective_from.
|
||||||
|
- Old version's values are unchanged (append-only; never overwritten).
|
||||||
|
- New version has effective_to=None (open-ended).
|
||||||
|
"""
|
||||||
|
client, engine = contracts_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
old_values = _MANUAL_VALUES
|
||||||
|
|
||||||
|
resp_c = client.post(
|
||||||
|
"/api/energy/contracts",
|
||||||
|
json=_manual_payload(effective_from=t0.isoformat(), values=old_values),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp_c.status_code == 201
|
||||||
|
cid = resp_c.json()["id"]
|
||||||
|
v1_id = resp_c.json()["versions"][0]["id"]
|
||||||
|
|
||||||
|
t1 = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
new_values = {
|
||||||
|
**old_values,
|
||||||
|
"energy": {**old_values["energy"], "buy": {"normal": 0.50, "dal": 0.40}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/energy/contracts/{cid}/versions",
|
||||||
|
json={"effective_from": t1.isoformat(), "values": new_values},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
versions = resp.json()["versions"]
|
||||||
|
assert len(versions) == 2
|
||||||
|
|
||||||
|
# Ordered ascending — first is the old version
|
||||||
|
ver_old = versions[0]
|
||||||
|
ver_new = versions[1]
|
||||||
|
|
||||||
|
# Old version must be closed at t1.
|
||||||
|
# SQLite round-trips datetimes as naive UTC strings; compare without tzinfo.
|
||||||
|
assert ver_old["id"] == v1_id
|
||||||
|
assert ver_old["effective_to"] is not None
|
||||||
|
eff_to = datetime.fromisoformat(ver_old["effective_to"]).replace(tzinfo=None)
|
||||||
|
assert eff_to == t1.replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Old version values must be unchanged
|
||||||
|
assert ver_old["values"]["energy"]["buy"]["normal"] == old_values["energy"]["buy"]["normal"]
|
||||||
|
|
||||||
|
# New version must be open-ended
|
||||||
|
assert ver_new["effective_to"] is None
|
||||||
|
assert ver_new["values"]["energy"]["buy"]["normal"] == 0.50
|
||||||
|
|
||||||
|
# Double-check via DB
|
||||||
|
with Session(engine) as s:
|
||||||
|
v1 = s.get(EnergyContractVersion, v1_id)
|
||||||
|
assert v1 is not None
|
||||||
|
assert v1.effective_to is not None
|
||||||
|
assert v1.values["energy"]["buy"]["normal"] == old_values["energy"]["buy"]["normal"]
|
||||||
|
|
||||||
|
all_versions = (
|
||||||
|
s.execute(
|
||||||
|
select(EnergyContractVersion).where(EnergyContractVersion.contract_id == cid)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(all_versions) == 2
|
||||||
Reference in New Issue
Block a user