Files
tliu93 4284bd40a7 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.
2026-06-23 21:19:25 +02:00

149 lines
4.5 KiB
Python

"""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]]