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
+567
View File
@@ -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": {
"get": {
"tags": [
@@ -1933,6 +2214,253 @@
],
"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": {
"properties": {
"identifiers": {
@@ -2868,6 +3396,24 @@
"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.\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": {
"properties": {
"status": {
@@ -3257,6 +3803,27 @@
"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."
}
}
}
+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.