"""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." ), )