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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user