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."
}
}
}