M7-T05: add meter CRUD API + retroactive recompute + OpenAPI regen
This commit is contained in:
@@ -0,0 +1,302 @@
|
|||||||
|
"""Meter CRUD, swap declaration, and retroactive recompute API (M7-T05).
|
||||||
|
|
||||||
|
All endpoints are under /api/energy/meters, require an authenticated session,
|
||||||
|
and write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
|
||||||
|
header.
|
||||||
|
|
||||||
|
Route semantics
|
||||||
|
---------------
|
||||||
|
GET /api/energy/meters — list all meter epochs (ascending started_at)
|
||||||
|
POST /api/energy/meters — declare a meter swap / initial meter epoch
|
||||||
|
PATCH /api/energy/meters/{id} — edit label / note, or correct started_at (retroactive)
|
||||||
|
|
||||||
|
Retroactive recompute
|
||||||
|
---------------------
|
||||||
|
Whenever a write operation changes a meter's ``started_at`` (new declaration
|
||||||
|
or PATCH correction), the affected billing window is re-judged via
|
||||||
|
``recompute_range``:
|
||||||
|
|
||||||
|
- **POST** (new meter, possibly retroactive):
|
||||||
|
window = [new_meter.started_at, now)
|
||||||
|
Rationale: the new meter's ``started_at`` closes the previous meter at that
|
||||||
|
point; all periods from that boundary forward may have a different meter
|
||||||
|
attribution. Using ``now`` as the upper bound is safe because
|
||||||
|
``recompute_range`` only processes closed quarters and the operation is
|
||||||
|
idempotent.
|
||||||
|
|
||||||
|
- **PATCH started_at** (retroactive correction):
|
||||||
|
window = [min(old_started_at, new_started_at), now)
|
||||||
|
Rationale: shifting the boundary in either direction affects all periods
|
||||||
|
between the old and new boundary (and potentially beyond if re-attribution
|
||||||
|
cascades). Using the minimum of the two timestamps guarantees the entire
|
||||||
|
affected range is covered; using ``now`` as the upper bound is safe and
|
||||||
|
idempotent.
|
||||||
|
|
||||||
|
``started_at`` localisation (Principle A, FU10 convention)
|
||||||
|
----------------------------------------------------------
|
||||||
|
If the client sends a timezone-naive ``started_at`` value, it is interpreted as
|
||||||
|
the **server's local wall-clock time** and converted to UTC before storage.
|
||||||
|
Timezone-aware values are converted to UTC as-is. This is identical to the
|
||||||
|
``_localize_effective_from`` convention used in ``energy_contracts.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.routes.api.deps import require_csrf, require_session
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.models.energy import Meter
|
||||||
|
from app.schemas.meter import (
|
||||||
|
MeterDeclareRequest,
|
||||||
|
MeterListResponse,
|
||||||
|
MeterPatchRequest,
|
||||||
|
MeterResponse,
|
||||||
|
)
|
||||||
|
from app.services import timezone as _tz_mod
|
||||||
|
from app.services.auth import AuthenticatedSession
|
||||||
|
from app.services.energy_cost import recompute_range
|
||||||
|
from app.services.meters import (
|
||||||
|
MeterIntervalError,
|
||||||
|
MeterOverlapError,
|
||||||
|
declare_meter,
|
||||||
|
list_meters,
|
||||||
|
update_meter,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
|
||||||
|
"""Return the meter with the given id or raise 404."""
|
||||||
|
meter: Optional[Meter] = db.get(Meter, meter_id)
|
||||||
|
if meter is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Meter {meter_id!r} not found.",
|
||||||
|
)
|
||||||
|
return meter
|
||||||
|
|
||||||
|
|
||||||
|
def _localize_started_at(dt: datetime) -> datetime:
|
||||||
|
"""Resolve *dt* to an aware UTC datetime for storage.
|
||||||
|
|
||||||
|
Follows the same Principle-A convention as ``_localize_effective_from``
|
||||||
|
in ``energy_contracts.py`` (FU10):
|
||||||
|
|
||||||
|
- Timezone-aware → convert to UTC as-is.
|
||||||
|
- Timezone-naive → interpret as server local wall-clock time, localize
|
||||||
|
with ``local_tz()``, then convert to UTC.
|
||||||
|
|
||||||
|
A front-end sending ``"2026-06-25T00:00:00"`` (no Z) has it interpreted
|
||||||
|
as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
|
||||||
|
not as UTC midnight.
|
||||||
|
"""
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
return dt.astimezone(UTC)
|
||||||
|
tz = _tz_mod.local_tz()
|
||||||
|
local_dt = dt.replace(tzinfo=tz)
|
||||||
|
return local_dt.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
|
||||||
|
"""Trigger recompute_range from *start* to now (UTC).
|
||||||
|
|
||||||
|
This is the standard "retroactive window" call: everything from the
|
||||||
|
affected boundary up to the current moment needs re-attribution.
|
||||||
|
Using ``now`` as the upper bound is safe because ``recompute_range``
|
||||||
|
only touches closed quarter-hour periods and the operation is idempotent.
|
||||||
|
"""
|
||||||
|
end = datetime.now(UTC)
|
||||||
|
if start >= end:
|
||||||
|
# started_at is in the future — nothing to recompute.
|
||||||
|
logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start)
|
||||||
|
return 0
|
||||||
|
n = recompute_range(db, start, end)
|
||||||
|
logger.info(
|
||||||
|
"%s: recomputed %d period(s) in window [%s, %s).",
|
||||||
|
label,
|
||||||
|
n,
|
||||||
|
start.isoformat(),
|
||||||
|
end.isoformat(),
|
||||||
|
)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/meters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meters", response_model=MeterListResponse)
|
||||||
|
def list_energy_meters(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
) -> MeterListResponse:
|
||||||
|
"""List all meter epochs in ascending ``started_at`` order.
|
||||||
|
|
||||||
|
Returns the full historical sequence of meter installations across all
|
||||||
|
commodities. The active meter (``ended_at=null``) appears last because it
|
||||||
|
has the latest ``started_at``.
|
||||||
|
"""
|
||||||
|
meters = list_meters(db)
|
||||||
|
items = [MeterResponse.model_validate(m) for m in meters]
|
||||||
|
return MeterListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/meters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/meters",
|
||||||
|
response_model=MeterResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def declare_energy_meter(
|
||||||
|
body: MeterDeclareRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> MeterResponse:
|
||||||
|
"""Declare a new meter epoch (swap, home move, or initial declaration).
|
||||||
|
|
||||||
|
Closes the current active meter for the given commodity at ``started_at``
|
||||||
|
and opens a new active meter. If no active meter exists, the new meter is
|
||||||
|
simply created without closing anything.
|
||||||
|
|
||||||
|
**Validation**: ``started_at`` must be **≥** the current active meter's
|
||||||
|
own ``started_at`` (no chronological backdate below the active epoch's
|
||||||
|
start). Equal timestamps are allowed (replaces the current meter at the
|
||||||
|
same logical moment). Violation → 422.
|
||||||
|
|
||||||
|
**Retroactive recompute**: if ``started_at`` is in the past, billing
|
||||||
|
records from that point forward are re-judged via ``recompute_range`` to
|
||||||
|
reflect the new meter attribution. The response includes the count of
|
||||||
|
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
|
||||||
|
— the recompute is transparent; callers should re-fetch costs if needed).
|
||||||
|
"""
|
||||||
|
started_at_utc = _localize_started_at(body.started_at)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_meter = declare_meter(
|
||||||
|
db,
|
||||||
|
label=body.label,
|
||||||
|
started_at=started_at_utc,
|
||||||
|
reason=body.reason.value,
|
||||||
|
commodity=body.commodity,
|
||||||
|
note=body.note,
|
||||||
|
)
|
||||||
|
except MeterOverlapError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.flush() # assign PK before recompute (recompute uses session, needs meter in DB)
|
||||||
|
|
||||||
|
# Retroactive recompute: re-judge attribution from the new boundary onward.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if started_at_utc < now:
|
||||||
|
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_meter)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
||||||
|
body.commodity,
|
||||||
|
new_meter.id,
|
||||||
|
new_meter.label,
|
||||||
|
started_at_utc.isoformat(),
|
||||||
|
)
|
||||||
|
return MeterResponse.model_validate(new_meter)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/energy/meters/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/meters/{meter_id}", response_model=MeterResponse)
|
||||||
|
def patch_energy_meter(
|
||||||
|
meter_id: int,
|
||||||
|
body: MeterPatchRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: AuthenticatedSession = Depends(require_session),
|
||||||
|
_csrf: None = Depends(require_csrf),
|
||||||
|
) -> MeterResponse:
|
||||||
|
"""Partially update a meter epoch: rename, edit note, or correct started_at.
|
||||||
|
|
||||||
|
- ``label``: updates the human-readable label.
|
||||||
|
- ``note``: updates the free-form note.
|
||||||
|
- ``started_at``: **retroactive correction** — shifts this meter's start
|
||||||
|
boundary. The service layer maintains timeline continuity by also
|
||||||
|
updating the preceding meter's ``ended_at``. Validation:
|
||||||
|
* Must be strictly after the previous meter's own ``started_at``.
|
||||||
|
* Must be strictly before this meter's ``ended_at`` (if closed).
|
||||||
|
Violation → 422.
|
||||||
|
|
||||||
|
**Retroactive recompute when ``started_at`` changes**: billing records in
|
||||||
|
the window ``[min(old, new), now)`` are re-judged to reflect the corrected
|
||||||
|
meter attribution.
|
||||||
|
|
||||||
|
Not found → 404.
|
||||||
|
"""
|
||||||
|
meter = _get_meter_or_404(db, meter_id)
|
||||||
|
|
||||||
|
# Capture old started_at before mutation (needed for recompute window).
|
||||||
|
old_started_at: Optional[datetime] = meter.started_at
|
||||||
|
|
||||||
|
# Localise started_at if provided.
|
||||||
|
new_started_at_utc: Optional[datetime] = None
|
||||||
|
if body.started_at is not None:
|
||||||
|
new_started_at_utc = _localize_started_at(body.started_at)
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_meter(
|
||||||
|
db,
|
||||||
|
meter,
|
||||||
|
label=body.label,
|
||||||
|
note=body.note,
|
||||||
|
started_at=new_started_at_utc,
|
||||||
|
)
|
||||||
|
except MeterIntervalError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retroactive recompute if started_at was changed.
|
||||||
|
if new_started_at_utc is not None and old_started_at is not None:
|
||||||
|
# Normalise old_started_at to UTC-aware for comparison.
|
||||||
|
if old_started_at.tzinfo is None:
|
||||||
|
old_started_at = old_started_at.replace(tzinfo=UTC)
|
||||||
|
# Window = [min(old, new), now) — covers all periods whose attribution
|
||||||
|
# may have changed due to the boundary shift in either direction.
|
||||||
|
window_start = min(old_started_at, new_started_at_utc)
|
||||||
|
_trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(meter)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
||||||
|
meter_id,
|
||||||
|
meter.label,
|
||||||
|
meter.started_at,
|
||||||
|
)
|
||||||
|
return MeterResponse.model_validate(meter)
|
||||||
@@ -16,6 +16,7 @@ from app.api.routes.api.data import router as api_data_router
|
|||||||
from app.api.routes.api.energy import router as api_energy_router
|
from app.api.routes.api.energy import router as api_energy_router
|
||||||
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
|
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
|
||||||
from app.api.routes.api.expose import router as api_expose_router
|
from app.api.routes.api.expose import router as api_expose_router
|
||||||
|
from app.api.routes.api.meters import router as api_meters_router
|
||||||
from app.api.routes.api.modbus import router as api_modbus_router
|
from app.api.routes.api.modbus import router as api_modbus_router
|
||||||
from app.api.routes.api.session import router as api_session_router
|
from app.api.routes.api.session import router as api_session_router
|
||||||
from app.api.routes import status
|
from app.api.routes import status
|
||||||
@@ -280,6 +281,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(api_data_router)
|
app.include_router(api_data_router)
|
||||||
app.include_router(api_energy_router)
|
app.include_router(api_energy_router)
|
||||||
app.include_router(api_energy_contracts_router)
|
app.include_router(api_energy_contracts_router)
|
||||||
|
app.include_router(api_meters_router)
|
||||||
app.include_router(api_expose_router)
|
app.include_router(api_expose_router)
|
||||||
app.include_router(api_modbus_router)
|
app.include_router(api_modbus_router)
|
||||||
app.include_router(api_session_router)
|
app.include_router(api_session_router)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""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."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -1379,6 +1379,155 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/energy/meters": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-meters"
|
||||||
|
],
|
||||||
|
"summary": "List Energy Meters",
|
||||||
|
"description": "List all meter epochs in ascending ``started_at`` order.\n\nReturns the full historical sequence of meter installations across all\ncommodities. The active meter (``ended_at=null``) appears last because it\nhas the latest ``started_at``.",
|
||||||
|
"operationId": "list_energy_meters_api_energy_meters_get",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MeterListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-meters"
|
||||||
|
],
|
||||||
|
"summary": "Declare Energy Meter",
|
||||||
|
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).",
|
||||||
|
"operationId": "declare_energy_meter_api_energy_meters_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/MeterDeclareRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MeterResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/energy/meters/{meter_id}": {
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"api-energy-meters"
|
||||||
|
],
|
||||||
|
"summary": "Patch Energy Meter",
|
||||||
|
"description": "Partially update a meter epoch: rename, edit note, or correct started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``: updates the free-form note.\n- ``started_at``: **retroactive correction** — shifts this meter's start\n boundary. The service layer maintains timeline continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n * Must be strictly after the previous meter's own ``started_at``.\n * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing records in\nthe window ``[min(old, new), now)`` are re-judged to reflect the corrected\nmeter attribution.\n\nNot found → 404.",
|
||||||
|
"operationId": "patch_energy_meter_api_energy_meters__meter_id__patch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "meter_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Meter 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/MeterPatchRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MeterResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/expose": {
|
"/api/expose": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3334,6 +3483,198 @@
|
|||||||
"title": "ManualTariffSchema",
|
"title": "ManualTariffSchema",
|
||||||
"description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices."
|
"description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices."
|
||||||
},
|
},
|
||||||
|
"MeterDeclareRequest": {
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 255,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Label"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Started At",
|
||||||
|
"description": "UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration)."
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"$ref": "#/components/schemas/MeterReason",
|
||||||
|
"description": "Why this epoch was created. One of: initial, meter_swap, home_move, other."
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Note"
|
||||||
|
},
|
||||||
|
"commodity": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 32,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Commodity",
|
||||||
|
"description": "Energy commodity this meter measures. Defaults to 'electricity'.",
|
||||||
|
"default": "electricity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"label",
|
||||||
|
"started_at",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"title": "MeterDeclareRequest",
|
||||||
|
"description": "Request body for POST /api/energy/meters.\n\nDeclares a new meter epoch (swap, home move, or initial declaration). The\nservice layer closes the current active meter for the given commodity at\n``started_at`` and opens a new one.\n\n``started_at`` follows the Principle-A localisation convention: a\ntimezone-naive value is interpreted as the **server's local wall-clock time**\n(e.g. CEST midnight → stored as UTC the night before); a timezone-aware\nvalue is converted to UTC as-is. Omitting ``started_at`` is not allowed —\nevery meter declaration must carry an explicit start timestamp.\n\n``commodity`` defaults to ``\"electricity\"``; the field is available for\nfuture use with ``gas`` or ``heating``."
|
||||||
|
},
|
||||||
|
"MeterListResponse": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MeterResponse"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Items"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Total"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"items",
|
||||||
|
"total"
|
||||||
|
],
|
||||||
|
"title": "MeterListResponse",
|
||||||
|
"description": "Response schema for GET /api/energy/meters.\n\nMeters are returned in ascending ``started_at`` order so the caller sees\nthe historical installation sequence."
|
||||||
|
},
|
||||||
|
"MeterPatchRequest": {
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 255,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Label"
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Note"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Started At",
|
||||||
|
"description": "Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "MeterPatchRequest",
|
||||||
|
"description": "Request body for PATCH /api/energy/meters/{id}.\n\nAll fields are optional. Only non-``None`` values are applied.\n\nUpdating ``started_at`` is a **retroactive correction**: the service layer\nmaintains timeline continuity (adjusting the preceding meter's ``ended_at``)\nand the API layer triggers ``recompute_range`` over the affected window so\nthat billing attribution is re-judged."
|
||||||
|
},
|
||||||
|
"MeterReason": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"initial",
|
||||||
|
"meter_swap",
|
||||||
|
"home_move",
|
||||||
|
"other"
|
||||||
|
],
|
||||||
|
"title": "MeterReason",
|
||||||
|
"description": "Allowed values for the meter epoch creation reason."
|
||||||
|
},
|
||||||
|
"MeterResponse": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Label"
|
||||||
|
},
|
||||||
|
"commodity": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Commodity"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Started At"
|
||||||
|
},
|
||||||
|
"ended_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Ended At"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Reason"
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Note"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"commodity",
|
||||||
|
"started_at",
|
||||||
|
"ended_at",
|
||||||
|
"reason",
|
||||||
|
"note",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"title": "MeterResponse",
|
||||||
|
"description": "Response schema for a single Meter epoch row.\n\n``ended_at`` is ``null`` for the currently active meter."
|
||||||
|
},
|
||||||
"MetricInfo": {
|
"MetricInfo": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": {
|
"key": {
|
||||||
|
|||||||
@@ -1077,6 +1077,138 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
/api/energy/meters:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- api-energy-meters
|
||||||
|
summary: List Energy Meters
|
||||||
|
description: 'List all meter epochs in ascending ``started_at`` order.
|
||||||
|
|
||||||
|
|
||||||
|
Returns the full historical sequence of meter installations across all
|
||||||
|
|
||||||
|
commodities. The active meter (``ended_at=null``) appears last because it
|
||||||
|
|
||||||
|
has the latest ``started_at``.'
|
||||||
|
operationId: list_energy_meters_api_energy_meters_get
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MeterListResponse'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- api-energy-meters
|
||||||
|
summary: Declare Energy Meter
|
||||||
|
description: 'Declare a new meter epoch (swap, home move, or initial declaration).
|
||||||
|
|
||||||
|
|
||||||
|
Closes the current active meter for the given commodity at ``started_at``
|
||||||
|
|
||||||
|
and opens a new active meter. If no active meter exists, the new meter is
|
||||||
|
|
||||||
|
simply created without closing anything.
|
||||||
|
|
||||||
|
|
||||||
|
**Validation**: ``started_at`` must be **≥** the current active meter''s
|
||||||
|
|
||||||
|
own ``started_at`` (no chronological backdate below the active epoch''s
|
||||||
|
|
||||||
|
start). Equal timestamps are allowed (replaces the current meter at the
|
||||||
|
|
||||||
|
same logical moment). Violation → 422.
|
||||||
|
|
||||||
|
|
||||||
|
**Retroactive recompute**: if ``started_at`` is in the past, billing
|
||||||
|
|
||||||
|
records from that point forward are re-judged via ``recompute_range`` to
|
||||||
|
|
||||||
|
reflect the new meter attribution. The response includes the count of
|
||||||
|
|
||||||
|
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
|
||||||
|
|
||||||
|
— the recompute is transparent; callers should re-fetch costs if needed).'
|
||||||
|
operationId: declare_energy_meter_api_energy_meters_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/MeterDeclareRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MeterResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
/api/energy/meters/{meter_id}:
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- api-energy-meters
|
||||||
|
summary: Patch Energy Meter
|
||||||
|
description: "Partially update a meter epoch: rename, edit note, or correct\
|
||||||
|
\ started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``:\
|
||||||
|
\ updates the free-form note.\n- ``started_at``: **retroactive correction**\
|
||||||
|
\ — shifts this meter's start\n boundary. The service layer maintains timeline\
|
||||||
|
\ continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n\
|
||||||
|
\ * Must be strictly after the previous meter's own ``started_at``.\n \
|
||||||
|
\ * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation\
|
||||||
|
\ → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing\
|
||||||
|
\ records in\nthe window ``[min(old, new), now)`` are re-judged to reflect\
|
||||||
|
\ the corrected\nmeter attribution.\n\nNot found → 404."
|
||||||
|
operationId: patch_energy_meter_api_energy_meters__meter_id__patch
|
||||||
|
parameters:
|
||||||
|
- name: meter_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
title: Meter 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/MeterPatchRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MeterResponse'
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
/api/expose:
|
/api/expose:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -2595,6 +2727,182 @@ components:
|
|||||||
Prices are the *effective* buy prices as used by the billing engine
|
Prices are the *effective* buy prices as used by the billing engine
|
||||||
|
|
||||||
(energy_buy_x + energy_tax + ode) and the raw sell prices.'
|
(energy_buy_x + energy_tax + ode) and the raw sell prices.'
|
||||||
|
MeterDeclareRequest:
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
title: Label
|
||||||
|
started_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Started At
|
||||||
|
description: UTC (or server-local naive) datetime from which this meter
|
||||||
|
epoch starts. May be in the past (retroactive declaration).
|
||||||
|
reason:
|
||||||
|
$ref: '#/components/schemas/MeterReason'
|
||||||
|
description: 'Why this epoch was created. One of: initial, meter_swap,
|
||||||
|
home_move, other.'
|
||||||
|
note:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
maxLength: 1024
|
||||||
|
- type: 'null'
|
||||||
|
title: Note
|
||||||
|
commodity:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
minLength: 1
|
||||||
|
title: Commodity
|
||||||
|
description: Energy commodity this meter measures. Defaults to 'electricity'.
|
||||||
|
default: electricity
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- started_at
|
||||||
|
- reason
|
||||||
|
title: MeterDeclareRequest
|
||||||
|
description: '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``.'
|
||||||
|
MeterListResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MeterResponse'
|
||||||
|
type: array
|
||||||
|
title: Items
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
title: Total
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- items
|
||||||
|
- total
|
||||||
|
title: MeterListResponse
|
||||||
|
description: 'Response schema for GET /api/energy/meters.
|
||||||
|
|
||||||
|
|
||||||
|
Meters are returned in ascending ``started_at`` order so the caller sees
|
||||||
|
|
||||||
|
the historical installation sequence.'
|
||||||
|
MeterPatchRequest:
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
- type: 'null'
|
||||||
|
title: Label
|
||||||
|
note:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
maxLength: 1024
|
||||||
|
- type: 'null'
|
||||||
|
title: Note
|
||||||
|
started_at:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
format: date-time
|
||||||
|
- type: 'null'
|
||||||
|
title: Started At
|
||||||
|
description: Retroactive correction of the meter epoch start timestamp. Triggers
|
||||||
|
billing recompute over the affected window.
|
||||||
|
type: object
|
||||||
|
title: MeterPatchRequest
|
||||||
|
description: '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.'
|
||||||
|
MeterReason:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- initial
|
||||||
|
- meter_swap
|
||||||
|
- home_move
|
||||||
|
- other
|
||||||
|
title: MeterReason
|
||||||
|
description: Allowed values for the meter epoch creation reason.
|
||||||
|
MeterResponse:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
title: Id
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
title: Label
|
||||||
|
commodity:
|
||||||
|
type: string
|
||||||
|
title: Commodity
|
||||||
|
started_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Started At
|
||||||
|
ended_at:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
format: date-time
|
||||||
|
- type: 'null'
|
||||||
|
title: Ended At
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
title: Reason
|
||||||
|
note:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: Note
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
title: Created At
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- label
|
||||||
|
- commodity
|
||||||
|
- started_at
|
||||||
|
- ended_at
|
||||||
|
- reason
|
||||||
|
- note
|
||||||
|
- created_at
|
||||||
|
title: MeterResponse
|
||||||
|
description: 'Response schema for a single Meter epoch row.
|
||||||
|
|
||||||
|
|
||||||
|
``ended_at`` is ``null`` for the currently active meter.'
|
||||||
MetricInfo:
|
MetricInfo:
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""Tests for M7-T05: Meter CRUD + swap declaration + retroactive recompute API.
|
||||||
|
|
||||||
|
Coverage matrix
|
||||||
|
---------------
|
||||||
|
GET /api/energy/meters
|
||||||
|
- unauthenticated → 401
|
||||||
|
- authenticated, no meters → 200, items=[]
|
||||||
|
- after declaring meters → items ordered by started_at asc
|
||||||
|
|
||||||
|
POST /api/energy/meters
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- valid (first meter, no active) → 201, MeterResponse
|
||||||
|
- valid swap (active meter exists) → 201, old meter closed, new meter active
|
||||||
|
- retroactive started_at → 201, triggers recompute for affected window
|
||||||
|
- started_at before active meter's started_at (overlap) → 422
|
||||||
|
|
||||||
|
PATCH /api/energy/meters/{id}
|
||||||
|
- unauthenticated → 401
|
||||||
|
- missing CSRF → 403
|
||||||
|
- not found → 404
|
||||||
|
- rename label → 200, label updated
|
||||||
|
- edit note → 200, note updated
|
||||||
|
- correct started_at (retroactive) → 200, triggers recompute for affected window
|
||||||
|
- started_at interval violation → 422
|
||||||
|
|
||||||
|
Retroactive recompute integration
|
||||||
|
- PATCH started_at change triggers recompute over min(old, new)..now window
|
||||||
|
- POST retroactive declaration triggers recompute from new started_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.energy import Meter
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CSRF = "test-csrf-token"
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "test-password"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
|
def _declare_payload(**overrides) -> dict:
|
||||||
|
base = {
|
||||||
|
"label": "Test Meter",
|
||||||
|
"started_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"reason": "initial",
|
||||||
|
"commodity": "electricity",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def meters_client(auth_database):
|
||||||
|
"""TestClient + SQLAlchemy engine for Meter API tests."""
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app_url = auth_database["app_url"]
|
||||||
|
engine = create_engine(app_url, connect_args={"check_same_thread": False})
|
||||||
|
fastapi_app = create_app()
|
||||||
|
with TestClient(fastapi_app) as test_client:
|
||||||
|
yield test_client, engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/energy/meters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_meters_unauthenticated_returns_401(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
resp = client.get("/api/energy/meters")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_meters_empty(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.get("/api/energy/meters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_meters_ordered_by_started_at(meters_client):
|
||||||
|
"""After declaring two meters, list returns them in ascending started_at order."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
# Declare first meter (initial)
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
|
||||||
|
# Declare swap meter
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
resp = client.get("/api/energy/meters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 2
|
||||||
|
items = body["items"]
|
||||||
|
# Ordered by started_at ascending: Meter A first, Meter B second
|
||||||
|
assert items[0]["label"] == "Meter A"
|
||||||
|
assert items[1]["label"] == "Meter B"
|
||||||
|
# Meter B is active (ended_at is null)
|
||||||
|
assert items[1]["ended_at"] is None
|
||||||
|
# Meter A is closed
|
||||||
|
assert items[0]["ended_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/energy/meters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_unauthenticated_returns_401(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_missing_csrf_returns_403(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.post("/api/energy/meters", json=_declare_payload())
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_first_no_active(meters_client):
|
||||||
|
"""Declaring the first meter succeeds without closing any previous meter."""
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="First Meter", started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["label"] == "First Meter"
|
||||||
|
assert body["commodity"] == "electricity"
|
||||||
|
assert body["reason"] == "initial"
|
||||||
|
assert body["ended_at"] is None # active
|
||||||
|
|
||||||
|
# DB check: one meter, active
|
||||||
|
with Session(engine) as s:
|
||||||
|
meters = s.execute(select(Meter)).scalars().all()
|
||||||
|
assert len(meters) == 1
|
||||||
|
assert meters[0].ended_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_swap_closes_previous(meters_client):
|
||||||
|
"""Declaring a swap closes the previous active meter at started_at."""
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = datetime(2025, 3, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
# First meter
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Old Meter", started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
old_id = resp1.json()["id"]
|
||||||
|
|
||||||
|
# Swap
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="New Meter", started_at=t1.isoformat(), reason="meter_swap"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
body2 = resp2.json()
|
||||||
|
assert body2["label"] == "New Meter"
|
||||||
|
assert body2["ended_at"] is None # new meter is active
|
||||||
|
|
||||||
|
# DB check: old meter is closed at t1
|
||||||
|
with Session(engine) as s:
|
||||||
|
old_meter = s.get(Meter, old_id)
|
||||||
|
assert old_meter is not None
|
||||||
|
assert old_meter.ended_at is not None
|
||||||
|
# ended_at should equal t1 (modulo naive/aware round-trip)
|
||||||
|
ended_naive = old_meter.ended_at
|
||||||
|
if ended_naive.tzinfo is None:
|
||||||
|
ended_naive = ended_naive.replace(tzinfo=UTC)
|
||||||
|
assert ended_naive == t1
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_overlap_returns_422(meters_client):
|
||||||
|
"""Declaring a meter with started_at before active meter's started_at → 422."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t_before = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
# Declare first meter
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
# Attempt to declare a meter before t0 → overlap error
|
||||||
|
# (t_before is in the past so would trigger recompute, but service layer raises first)
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="Backdated Meter", started_at=t_before.isoformat(), reason="meter_swap"
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 422
|
||||||
|
assert "started_at" in resp2.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_missing_fields_returns_422(meters_client):
|
||||||
|
"""Missing required fields (started_at, reason) → 422 from Pydantic validation."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json={"label": "No Reason Meter"}, # missing started_at and reason
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_response_fields(meters_client):
|
||||||
|
"""POST response contains all expected MeterResponse fields."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="Full Fields Meter",
|
||||||
|
started_at=t0.isoformat(),
|
||||||
|
reason="home_move",
|
||||||
|
note="Testing note",
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
for field in ("id", "label", "commodity", "started_at", "ended_at", "reason", "note", "created_at"):
|
||||||
|
assert field in body, f"Missing field {field!r} in response"
|
||||||
|
assert body["note"] == "Testing note"
|
||||||
|
assert body["reason"] == "home_move"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_retroactive_triggers_recompute(meters_client):
|
||||||
|
"""Declaring a meter with started_at in the past triggers recompute_range.
|
||||||
|
|
||||||
|
Both POST calls are made with recompute_range mocked so the test does not
|
||||||
|
spend time iterating over thousands of empty quarter-hour periods.
|
||||||
|
"""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t_past = datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.api.routes.api.meters.recompute_range", return_value=5
|
||||||
|
) as mock_recompute:
|
||||||
|
# First meter (initial); also in the past, so recompute is called here too.
|
||||||
|
client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
# Reset call count before the swap we are actually testing.
|
||||||
|
mock_recompute.reset_mock()
|
||||||
|
|
||||||
|
# Retroactive swap: started_at in the past → should trigger recompute
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(
|
||||||
|
label="Retroactive Swap", started_at=t_past.isoformat(), reason="meter_swap"
|
||||||
|
),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
# recompute_range should have been called with start == t_past
|
||||||
|
assert mock_recompute.called
|
||||||
|
call_args = mock_recompute.call_args
|
||||||
|
recompute_start = call_args[0][1] # positional arg index 1 (session is 0)
|
||||||
|
# Normalise for comparison
|
||||||
|
if recompute_start.tzinfo is None:
|
||||||
|
recompute_start = recompute_start.replace(tzinfo=UTC)
|
||||||
|
assert recompute_start <= t_past
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/energy/meters/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_unauthenticated_returns_401(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/energy/meters/1",
|
||||||
|
json={"label": "Renamed"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_missing_csrf_returns_403(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(f"/api/energy/meters/{meter_id}", json={"label": "Renamed"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_not_found_returns_404(meters_client):
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/energy/meters/99999",
|
||||||
|
json={"label": "Does Not Exist"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_rename_label(meters_client):
|
||||||
|
"""PATCH label updates the meter's human-readable label."""
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_id}",
|
||||||
|
json={"label": "Updated Label"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["label"] == "Updated Label"
|
||||||
|
|
||||||
|
# DB check
|
||||||
|
with Session(engine) as s:
|
||||||
|
m = s.get(Meter, meter_id)
|
||||||
|
assert m.label == "Updated Label"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_edit_note(meters_client):
|
||||||
|
"""PATCH note updates the meter's note field."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(started_at=t0.isoformat(), note=None),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_id}",
|
||||||
|
json={"note": "Added a note"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["note"] == "Added a note"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_started_at_retroactive_triggers_recompute(meters_client):
|
||||||
|
"""PATCH started_at triggers recompute over min(old, new)..now window."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Set up two meters: initial + swap. All POST calls are mocked to avoid
|
||||||
|
# running recompute over thousands of empty historical periods.
|
||||||
|
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
resp_b = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_b_id = resp_b.json()["id"]
|
||||||
|
|
||||||
|
# Correct Meter B's started_at to a slightly different past timestamp
|
||||||
|
t1_corrected = datetime(2024, 12, 15, 0, 0, 0, tzinfo=UTC) # earlier than t1
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.api.routes.api.meters.recompute_range", return_value=10
|
||||||
|
) as mock_recompute:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_b_id}",
|
||||||
|
json={"started_at": t1_corrected.isoformat()},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# recompute should be triggered
|
||||||
|
assert mock_recompute.called
|
||||||
|
call_args = mock_recompute.call_args
|
||||||
|
recompute_start = call_args[0][1]
|
||||||
|
if recompute_start.tzinfo is None:
|
||||||
|
recompute_start = recompute_start.replace(tzinfo=UTC)
|
||||||
|
# Window start should be min(t1_corrected, t1) = t1_corrected
|
||||||
|
assert recompute_start <= t1_corrected
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_started_at_interval_violation_returns_422(meters_client):
|
||||||
|
"""PATCH started_at that would create an invalid interval → 422."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Set up: initial meter A, then swap to B. POST calls mocked to avoid slow recompute.
|
||||||
|
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp_a = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_a_id = resp_a.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to set Meter A's started_at to after its ended_at (t1) → interval error
|
||||||
|
t_too_late = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_a_id}",
|
||||||
|
json={"started_at": t_too_late.isoformat()},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
|
||||||
|
"""PATCH that only changes label does NOT trigger recompute."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0) as mock_recompute:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_id = resp.json()["id"]
|
||||||
|
mock_recompute.reset_mock()
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_id}",
|
||||||
|
json={"label": "Renamed Only"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# recompute should NOT be triggered (no started_at change)
|
||||||
|
assert not mock_recompute.called
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeline continuity (integration: no recompute mock)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_timeline_continuity(meters_client):
|
||||||
|
"""After two swaps, meter timeline is contiguous and self-consistent."""
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t2 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
# Declare 3 meters in sequence. POST calls mocked to avoid slow recompute over
|
||||||
|
# years of empty quarter-hour periods.
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
for label, ts, reason in [
|
||||||
|
("M1", t0, "initial"),
|
||||||
|
("M2", t1, "meter_swap"),
|
||||||
|
("M3", t2, "meter_swap"),
|
||||||
|
]:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label=label, started_at=ts.isoformat(), reason=reason),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
# Verify timeline via list endpoint
|
||||||
|
resp = client.get("/api/energy/meters")
|
||||||
|
items = resp.json()["items"]
|
||||||
|
assert len(items) == 3
|
||||||
|
|
||||||
|
# M1: [t0, t1); M2: [t1, t2); M3: [t2, None)
|
||||||
|
m1 = next(i for i in items if i["label"] == "M1")
|
||||||
|
m2 = next(i for i in items if i["label"] == "M2")
|
||||||
|
m3 = next(i for i in items if i["label"] == "M3")
|
||||||
|
|
||||||
|
assert m1["ended_at"] is not None
|
||||||
|
assert m2["ended_at"] is not None
|
||||||
|
assert m3["ended_at"] is None # active
|
||||||
|
|
||||||
|
# ended_at of M1 == started_at of M2 (contiguous)
|
||||||
|
m1_ended = datetime.fromisoformat(m1["ended_at"]).replace(tzinfo=None)
|
||||||
|
m2_started = datetime.fromisoformat(m2["started_at"]).replace(tzinfo=None)
|
||||||
|
assert m1_ended == m2_started
|
||||||
|
|
||||||
|
m2_ended = datetime.fromisoformat(m2["ended_at"]).replace(tzinfo=None)
|
||||||
|
m3_started = datetime.fromisoformat(m3["started_at"]).replace(tzinfo=None)
|
||||||
|
assert m2_ended == m3_started
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reason enum validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_invalid_reason_returns_422(meters_client):
|
||||||
|
"""Unknown reason value → 422 from Pydantic enum validation."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(started_at=t0.isoformat(), reason="unknown_reason_xyz"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Retroactive recompute: integration (no mock) — window coverage check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_started_at_earlier_updates_boundary(meters_client):
|
||||||
|
"""Moving started_at earlier should update the previous meter's ended_at."""
|
||||||
|
client, engine = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
t1_earlier = datetime(2024, 12, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp_a = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_a_id = resp_a.json()["id"]
|
||||||
|
|
||||||
|
resp_b = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_b_id = resp_b.json()["id"]
|
||||||
|
|
||||||
|
# Correct Meter B's started_at to t1_earlier (moves boundary earlier)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_b_id}",
|
||||||
|
json={"started_at": t1_earlier.isoformat()},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["id"] == meter_b_id
|
||||||
|
|
||||||
|
# DB check: Meter A's ended_at should now equal t1_earlier
|
||||||
|
with Session(engine) as s:
|
||||||
|
meter_a = s.get(Meter, meter_a_id)
|
||||||
|
assert meter_a is not None
|
||||||
|
ended = meter_a.ended_at
|
||||||
|
if ended is not None and ended.tzinfo is None:
|
||||||
|
ended = ended.replace(tzinfo=UTC)
|
||||||
|
assert ended == t1_earlier
|
||||||
Reference in New Issue
Block a user