Files

132 lines
4.2 KiB
Python

"""Pydantic schemas for the Meter CRUD + swap declaration API (M7-T05).
Schema hierarchy
----------------
MeterResponse — single meter row (id/label/commodity/started_at/ended_at/reason/note/created_at)
MeterListResponse — ordered list of MeterResponse items
MeterDeclareRequest — POST /api/energy/meters body (declare a swap or initial meter)
MeterPatchRequest — PATCH /api/energy/meters/{id} body (all fields optional)
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MeterReason(str, Enum):
"""Allowed values for the meter epoch creation reason."""
initial = "initial"
meter_swap = "meter_swap"
home_move = "home_move"
other = "other"
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class MeterResponse(BaseModel):
"""Response schema for a single Meter epoch row.
``ended_at`` is ``null`` for the currently active meter.
"""
id: int
label: str
commodity: str
started_at: datetime
ended_at: datetime | None
reason: str
note: str | None
created_at: datetime
model_config = {"from_attributes": True}
class MeterListResponse(BaseModel):
"""Response schema for GET /api/energy/meters.
Meters are returned in ascending ``started_at`` order so the caller sees
the historical installation sequence.
"""
items: list[MeterResponse]
total: int
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
_VALID_REASONS = ", ".join(r.value for r in MeterReason)
class MeterDeclareRequest(BaseModel):
"""Request body for POST /api/energy/meters.
Declares a new meter epoch (swap, home move, or initial declaration). The
service layer closes the current active meter for the given commodity at
``started_at`` and opens a new one.
``started_at`` follows the Principle-A localisation convention: a
timezone-naive value is interpreted as the **server's local wall-clock time**
(e.g. CEST midnight → stored as UTC the night before); a timezone-aware
value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
every meter declaration must carry an explicit start timestamp.
``commodity`` defaults to ``"electricity"``; the field is available for
future use with ``gas`` or ``heating``.
"""
label: str = Field(..., min_length=1, max_length=255)
started_at: datetime = Field(
...,
description=(
"UTC (or server-local naive) datetime from which this meter epoch starts. "
"May be in the past (retroactive declaration)."
),
)
reason: MeterReason = Field(
...,
description=f"Why this epoch was created. One of: {_VALID_REASONS}.",
)
note: str | None = Field(default=None, max_length=1024)
commodity: str = Field(
default="electricity",
min_length=1,
max_length=32,
description="Energy commodity this meter measures. Defaults to 'electricity'.",
)
class MeterPatchRequest(BaseModel):
"""Request body for PATCH /api/energy/meters/{id}.
All fields are optional. Only non-``None`` values are applied.
Updating ``started_at`` is a **retroactive correction**: the service layer
maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
and the API layer triggers ``recompute_range`` over the affected window so
that billing attribution is re-judged.
"""
label: str | None = Field(default=None, min_length=1, max_length=255)
note: str | None = Field(default=None, max_length=1024)
started_at: datetime | None = Field(
default=None,
description=(
"Retroactive correction of the meter epoch start timestamp. "
"Triggers billing recompute over the affected window."
),
)