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:
2026-06-23 21:19:25 +02:00
parent bedea196c3
commit 4284bd40a7
8 changed files with 2584 additions and 1 deletions
+473
View File
@@ -516,6 +516,235 @@ paths:
application/json:
schema:
$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:
get:
tags:
@@ -1464,6 +1693,212 @@ components:
required:
- sections
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:
properties:
identifiers:
@@ -2113,6 +2548,28 @@ components:
- description
title: ProfileSummary
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:
properties:
status:
@@ -2384,3 +2841,19 @@ components:
- msg
- type
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.