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)
|
||||
Reference in New Issue
Block a user