M6-T09: add Energy data API (prices/costs/summary/dsmr-latest/recompute/tibber-test)
- routes/api/energy.py + schemas/energy.py: GET prices (tibber points or manual tariff, buy/sell consistent with the pricing strategies), GET costs (time window + limit, ascending), GET costs/summary (summarize passthrough), GET dsmr/latest, POST costs/recompute (idempotent, <=366d guard, CSRF), POST tibber/test (three-state, token never echoed, CSRF). - main.py registers router; OpenAPI re-exported; tests for all endpoints.
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
"""Energy data API: prices, costs, summary, DSMR, recompute, Tibber test (M6-T09).
|
||||
|
||||
All endpoints are under /api/energy, require an authenticated session, and
|
||||
write endpoints (POST) additionally require a non-empty X-CSRF-Token header.
|
||||
|
||||
Route prefix note
|
||||
-----------------
|
||||
This router shares the ``/api/energy`` prefix with ``energy_contracts.py``
|
||||
(which handles contract CRUD at /contracts/* and /profiles). The sub-paths
|
||||
used here (/prices, /costs, /costs/summary, /costs/recompute, /dsmr/latest,
|
||||
/tibber/test) are disjoint from the contract router's paths, so there is no
|
||||
conflict.
|
||||
|
||||
Tibber token security
|
||||
---------------------
|
||||
``POST /api/energy/tibber/test`` calls the Tibber API but **never** echoes the
|
||||
token in the response body or in log messages. Three-state logic mirrors the
|
||||
MQTT test endpoint (M5-T10, app/api/routes/api/config.py::post_mqtt_test):
|
||||
|
||||
200 { result: "success", message: ..., price: {...} }
|
||||
400 { result: "config-error", message: ... }
|
||||
502 { result: "failed", message: ... }
|
||||
|
||||
Recompute safety
|
||||
----------------
|
||||
``POST /api/energy/costs/recompute`` is idempotent: it calls
|
||||
``energy_cost.recompute_range`` which upserts existing rows without deleting
|
||||
anything. The endpoint enforces a maximum time-window of 366 days to avoid
|
||||
unbounded recomputation triggered by erroneous client requests.
|
||||
|
||||
Prices endpoint behaviour
|
||||
-------------------------
|
||||
``GET /api/energy/prices`` queries the ``tibber_price`` table for tibber
|
||||
contracts, or derives the effective fixed-tariff prices for manual contracts
|
||||
using the same formula as the billing engine (_manual_strategy in strategies.py):
|
||||
|
||||
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
|
||||
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
|
||||
sell_dal = energy.sell.dal (no tax added to sell price)
|
||||
sell_normal = energy.sell.normal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.config import Settings
|
||||
from app.integrations.tibber.client import (
|
||||
TibberAuthError,
|
||||
TibberError,
|
||||
fetch_current_price,
|
||||
)
|
||||
from app.models.energy import DsmrReading, EnergyCostPeriod, TibberPrice
|
||||
from app.schemas.energy import (
|
||||
CostPeriodSchema,
|
||||
CostsResponse,
|
||||
DsmrLatestResponse,
|
||||
ManualTariffSchema,
|
||||
PricePointSchema,
|
||||
PricesResponse,
|
||||
RecomputeResponse,
|
||||
SummaryResponse,
|
||||
TibberTestPriceSchema,
|
||||
TibberTestResponse,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.contracts import active_contract_version_at
|
||||
from app.services.energy_cost import recompute_range, summarize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/energy", tags=["api-energy"])
|
||||
|
||||
# Maximum number of cost periods returned per request (mirrors modbus readings cap).
|
||||
_COSTS_LIMIT_MAX = 5000
|
||||
|
||||
# Maximum allowed time-window for recompute to prevent unbounded computation.
|
||||
_RECOMPUTE_MAX_DAYS = 366
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def _manual_tariff_from_values(values: dict[str, Any]) -> ManualTariffSchema:
|
||||
"""Derive the effective fixed tariff from a manual contract version's values dict.
|
||||
|
||||
Mirrors the _manual_strategy formula (strategies.py):
|
||||
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
|
||||
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
|
||||
sell_dal = energy.sell.dal (no tax added)
|
||||
sell_normal = energy.sell.normal
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
def _d(v: Any) -> Decimal:
|
||||
return Decimal(str(v or 0))
|
||||
|
||||
energy = values.get("energy", {})
|
||||
buy = energy.get("buy", {})
|
||||
sell = energy.get("sell", {})
|
||||
energy_tax = _d(energy.get("energy_tax", 0))
|
||||
ode = _d(energy.get("ode", 0))
|
||||
|
||||
buy_dal = _d(buy.get("dal", 0)) + energy_tax + ode
|
||||
buy_normal = _d(buy.get("normal", 0)) + energy_tax + ode
|
||||
sell_dal = _d(sell.get("dal", 0))
|
||||
sell_normal = _d(sell.get("normal", 0))
|
||||
|
||||
return ManualTariffSchema(
|
||||
buy_dal=float(buy_dal),
|
||||
buy_normal=float(buy_normal),
|
||||
sell_dal=float(sell_dal),
|
||||
sell_normal=float(sell_normal),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/prices", response_model=PricesResponse)
|
||||
def get_prices(
|
||||
start: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive start of the time window (ISO 8601). "
|
||||
"Defaults to the start of today UTC when omitted.",
|
||||
),
|
||||
end: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive end of the time window (ISO 8601). "
|
||||
"Defaults to the end of tomorrow UTC when omitted.",
|
||||
),
|
||||
limit: int = Query(
|
||||
default=500,
|
||||
ge=1,
|
||||
le=_COSTS_LIMIT_MAX,
|
||||
description="Maximum number of Tibber price points to return.",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> PricesResponse:
|
||||
"""Return the price curve for the active contract.
|
||||
|
||||
**Tibber contracts** (kind="tibber"):
|
||||
Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending
|
||||
by ``starts_at``. At most ``limit`` rows are returned (most recent first
|
||||
within the window, then reversed to ascending order — identical to the
|
||||
modbus readings pattern).
|
||||
|
||||
Response ``points`` carries per-slot:
|
||||
- ``buy = total`` (Tibber all-inclusive price)
|
||||
- ``sell = total − energy_tax − sell_adjust`` (from active version values)
|
||||
- ``level`` (Tibber price level, may be null)
|
||||
|
||||
``tariff`` is null.
|
||||
|
||||
**Manual contracts** (kind="manual"):
|
||||
``points`` is empty. ``tariff`` carries the four effective prices
|
||||
derived using the billing engine formula:
|
||||
- ``buy_dal = energy.buy.dal + energy_tax + ode``
|
||||
- ``buy_normal = energy.buy.normal + energy_tax + ode``
|
||||
- ``sell_dal = energy.sell.dal``
|
||||
- ``sell_normal = energy.sell.normal``
|
||||
|
||||
**No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200).
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Default window: today + tomorrow.
|
||||
if start is None:
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end is None:
|
||||
end = (start + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
|
||||
# Resolve the active contract version at the start of the window.
|
||||
version = active_contract_version_at(db, start_utc)
|
||||
|
||||
if version is None:
|
||||
return PricesResponse(
|
||||
kind=None,
|
||||
currency="EUR",
|
||||
points=[],
|
||||
tariff=None,
|
||||
)
|
||||
|
||||
contract = version.contract
|
||||
currency = contract.currency
|
||||
|
||||
if contract.kind == "tibber":
|
||||
# Fetch tibber_price rows in the window.
|
||||
stmt = (
|
||||
select(TibberPrice)
|
||||
.where(
|
||||
TibberPrice.starts_at >= start_utc,
|
||||
TibberPrice.starts_at <= end_utc,
|
||||
)
|
||||
.order_by(TibberPrice.starts_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||
|
||||
# Derive sell price per-point using version values (energy_tax + sell_adjust).
|
||||
from decimal import Decimal
|
||||
|
||||
def _d(v: Any) -> Decimal:
|
||||
return Decimal(str(v or 0))
|
||||
|
||||
energy = version.values.get("energy", {}) if version.values else {}
|
||||
energy_tax = _d(energy.get("energy_tax", 0))
|
||||
sell_adjust = _d(energy.get("sell_adjust", 0))
|
||||
|
||||
points = []
|
||||
for row in rows:
|
||||
total = _d(row.total)
|
||||
sell = float(total - energy_tax - sell_adjust)
|
||||
points.append(
|
||||
PricePointSchema(
|
||||
starts_at=_as_utc(row.starts_at),
|
||||
buy=row.total,
|
||||
sell=sell,
|
||||
level=row.level,
|
||||
)
|
||||
)
|
||||
|
||||
return PricesResponse(
|
||||
kind="tibber",
|
||||
currency=currency,
|
||||
points=points,
|
||||
tariff=None,
|
||||
)
|
||||
|
||||
elif contract.kind == "manual":
|
||||
tariff = _manual_tariff_from_values(version.values or {})
|
||||
return PricesResponse(
|
||||
kind="manual",
|
||||
currency=currency,
|
||||
points=[],
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
else:
|
||||
# Unknown kind — return empty response gracefully.
|
||||
return PricesResponse(
|
||||
kind=contract.kind,
|
||||
currency=currency,
|
||||
points=[],
|
||||
tariff=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/costs", response_model=CostsResponse)
|
||||
def get_costs(
|
||||
start: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive lower bound for period_start (ISO 8601).",
|
||||
),
|
||||
end: datetime | None = Query(
|
||||
default=None,
|
||||
description="Inclusive upper bound for period_start (ISO 8601).",
|
||||
),
|
||||
limit: int = Query(
|
||||
default=500,
|
||||
ge=1,
|
||||
le=_COSTS_LIMIT_MAX,
|
||||
description=f"Maximum number of cost periods to return (default 500, max {_COSTS_LIMIT_MAX}).",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> CostsResponse:
|
||||
"""Return energy_cost_period rows within a time window.
|
||||
|
||||
Rows are ordered by ``period_start`` ascending. When the window contains
|
||||
more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
|
||||
then reversed to ascending order — identical to the modbus readings pattern.
|
||||
|
||||
Query parameters:
|
||||
- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
|
||||
- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).
|
||||
- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).
|
||||
"""
|
||||
stmt = (
|
||||
select(EnergyCostPeriod)
|
||||
.order_by(EnergyCostPeriod.period_start.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if start is not None:
|
||||
stmt = stmt.where(EnergyCostPeriod.period_start >= _as_utc(start))
|
||||
if end is not None:
|
||||
stmt = stmt.where(EnergyCostPeriod.period_start <= _as_utc(end))
|
||||
|
||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||
items = [CostPeriodSchema.model_validate(r) for r in rows]
|
||||
return CostsResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/costs/summary", response_model=SummaryResponse)
|
||||
def get_costs_summary(
|
||||
start: datetime | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Inclusive start of the summary interval (ISO 8601). "
|
||||
"Defaults to the start of the current UTC day."
|
||||
),
|
||||
),
|
||||
end: datetime | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Exclusive end of the summary interval (ISO 8601). "
|
||||
"Defaults to the start of the next UTC day (i.e. today's full data)."
|
||||
),
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> SummaryResponse:
|
||||
"""Aggregate billing for a time interval.
|
||||
|
||||
Calls ``energy_cost.summarize(session, start, end)`` which computes:
|
||||
|
||||
total_payable = Σ(net_cost) + fixed_costs − credits
|
||||
|
||||
where ``fixed_costs`` is (network_fee + management_fee) apportioned to the
|
||||
interval length in days (÷30 per month), and ``credits`` is heffingskorting
|
||||
apportioned similarly (÷365 per year).
|
||||
|
||||
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
|
||||
contract version at ``end``**. When no active contract exists they are 0.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if start is None:
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end is None:
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
result = summarize(db, _as_utc(start), _as_utc(end))
|
||||
return SummaryResponse(**result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/dsmr/latest", response_model=DsmrLatestResponse)
|
||||
def get_dsmr_latest(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> DsmrLatestResponse:
|
||||
"""Return the most recent dsmr_reading row.
|
||||
|
||||
Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
|
||||
404) when no rows exist yet, so the front-end can distinguish "no data" from
|
||||
a server error.
|
||||
"""
|
||||
row = db.execute(
|
||||
select(DsmrReading)
|
||||
.order_by(DsmrReading.recorded_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return DsmrLatestResponse(found=False)
|
||||
|
||||
return DsmrLatestResponse(
|
||||
found=True,
|
||||
recorded_at=_as_utc(row.recorded_at),
|
||||
payload=row.payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/costs/recompute",
|
||||
responses={
|
||||
200: {"model": RecomputeResponse},
|
||||
422: {"description": "Validation error (missing window or range too large)"},
|
||||
},
|
||||
)
|
||||
def post_recompute(
|
||||
start: datetime = Query(
|
||||
...,
|
||||
description="Inclusive start of the recompute window (ISO 8601). Required.",
|
||||
),
|
||||
end: datetime = Query(
|
||||
...,
|
||||
description="Exclusive end of the recompute window (ISO 8601). Required.",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> RecomputeResponse:
|
||||
"""Idempotently recompute billing records in a time window.
|
||||
|
||||
Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
|
||||
existing rows (including successful ones) for every UTC quarter-hour boundary
|
||||
in ``[start, end)``.
|
||||
|
||||
**Idempotency**: repeated calls with the same window produce the same
|
||||
outcome. No rows are deleted; only upserted.
|
||||
|
||||
**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.
|
||||
Requests exceeding this return 422.
|
||||
|
||||
Returns the number of periods for which a billing record was written.
|
||||
Periods skipped due to missing contract or missing Tibber price are not counted.
|
||||
"""
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
|
||||
if end_utc <= start_utc:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="'end' must be strictly after 'start'.",
|
||||
)
|
||||
|
||||
span_days = (end_utc - start_utc).total_seconds() / 86400
|
||||
if span_days > _RECOMPUTE_MAX_DAYS:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Time window is {span_days:.1f} days which exceeds the maximum of "
|
||||
f"{_RECOMPUTE_MAX_DAYS} days. Use a smaller window."
|
||||
),
|
||||
)
|
||||
|
||||
n = recompute_range(db, start_utc, end_utc)
|
||||
logger.info(
|
||||
"POST /api/energy/costs/recompute [%s, %s): wrote %d period(s).",
|
||||
start_utc.isoformat(),
|
||||
end_utc.isoformat(),
|
||||
n,
|
||||
)
|
||||
return RecomputeResponse(recomputed=n)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tibber/test",
|
||||
responses={
|
||||
200: {"model": TibberTestResponse},
|
||||
400: {"model": TibberTestResponse},
|
||||
502: {"model": TibberTestResponse},
|
||||
},
|
||||
)
|
||||
def post_tibber_test(
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
"""Test Tibber API connectivity by fetching the current price point.
|
||||
|
||||
Three possible outcomes:
|
||||
|
||||
- **200** ``{ result: "success", message: ..., price: {...} }``
|
||||
The Tibber API responded with a valid current price. ``price`` contains
|
||||
starts_at, total, energy, tax, currency, and level.
|
||||
|
||||
- **400** ``{ result: "config-error", message: ... }``
|
||||
The Tibber API token is empty or not configured.
|
||||
|
||||
- **502** ``{ result: "failed", message: ... }``
|
||||
The API call failed (authentication rejected, network error, timeout,
|
||||
unexpected response, etc.).
|
||||
|
||||
The API token is **never** included in the response body or logged.
|
||||
"""
|
||||
token = settings.tibber_api_token
|
||||
home_id = settings.tibber_home_id or None # treat empty string as None
|
||||
|
||||
if not token:
|
||||
logger.info("POST /api/energy/tibber/test: no token configured.")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=TibberTestResponse(
|
||||
result="config-error",
|
||||
message=(
|
||||
"Tibber API token is not configured. "
|
||||
"Set TIBBER_API_TOKEN in the Config page."
|
||||
),
|
||||
price=None,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
try:
|
||||
price_point = fetch_current_price(token, home_id)
|
||||
except TibberAuthError:
|
||||
logger.warning("POST /api/energy/tibber/test: authentication failed.")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content=TibberTestResponse(
|
||||
result="failed",
|
||||
message=(
|
||||
"Tibber API authentication failed. "
|
||||
"Check that your API token is correct."
|
||||
),
|
||||
price=None,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
except TibberError as exc:
|
||||
logger.warning("POST /api/energy/tibber/test: API call failed — %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content=TibberTestResponse(
|
||||
result="failed",
|
||||
message=f"Tibber API call failed: {exc}",
|
||||
price=None,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
price_schema = TibberTestPriceSchema(
|
||||
starts_at=price_point.starts_at,
|
||||
total=price_point.total,
|
||||
energy=price_point.energy,
|
||||
tax=price_point.tax,
|
||||
currency=price_point.currency,
|
||||
level=price_point.level,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"POST /api/energy/tibber/test: success (starts_at=%s, total=%s %s).",
|
||||
price_point.starts_at.isoformat(),
|
||||
price_point.total,
|
||||
price_point.currency,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=TibberTestResponse(
|
||||
result="success",
|
||||
message=(
|
||||
f"Tibber API connected. Current price: "
|
||||
f"{price_point.total} {price_point.currency}/kWh "
|
||||
f"(starts {price_point.starts_at.isoformat()})."
|
||||
),
|
||||
price=price_schema,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
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_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.modbus import router as api_modbus_router
|
||||
@@ -288,6 +289,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(status.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_energy_router)
|
||||
app.include_router(api_energy_contracts_router)
|
||||
app.include_router(api_expose_router)
|
||||
app.include_router(api_modbus_router)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Pydantic schemas for the Energy data API (M6-T09).
|
||||
|
||||
Covers six endpoint groups under /api/energy:
|
||||
- GET /prices — price curve (tibber 15min points or manual tariff)
|
||||
- GET /costs — energy_cost_period rows (time-range, paginated)
|
||||
- GET /costs/summary — aggregated metered + standing charges − credits
|
||||
- GET /dsmr/latest — most recent dsmr_reading blob
|
||||
- POST /costs/recompute — explicit idempotent recompute (returns count)
|
||||
- POST /tibber/test — three-state Tibber connection test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PricePointSchema(BaseModel):
|
||||
"""A single 15-minute price point (tibber) or placeholder entry."""
|
||||
|
||||
starts_at: datetime
|
||||
buy: float = Field(description="All-in buy price in EUR/kWh (including taxes).")
|
||||
sell: float = Field(description="Net sell price in EUR/kWh.")
|
||||
level: str | None = Field(
|
||||
default=None,
|
||||
description="Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.",
|
||||
)
|
||||
|
||||
|
||||
class ManualTariffSchema(BaseModel):
|
||||
"""Fixed-tariff breakdown for manual contracts.
|
||||
|
||||
Prices are the *effective* buy prices as used by the billing engine
|
||||
(energy_buy_x + energy_tax + ode) and the raw sell prices.
|
||||
"""
|
||||
|
||||
buy_dal: float = Field(description="Effective buy price, low-tariff / dal (EUR/kWh).")
|
||||
buy_normal: float = Field(description="Effective buy price, normal / high-tariff (EUR/kWh).")
|
||||
sell_dal: float = Field(description="Sell price, low-tariff / dal (EUR/kWh).")
|
||||
sell_normal: float = Field(description="Sell price, normal / high-tariff (EUR/kWh).")
|
||||
|
||||
|
||||
class PricesResponse(BaseModel):
|
||||
"""Response for GET /api/energy/prices.
|
||||
|
||||
``kind`` mirrors the active contract kind:
|
||||
- ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.
|
||||
- ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
|
||||
- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
|
||||
|
||||
``currency`` comes from the active contract (or "EUR" fallback).
|
||||
``points`` is always ascending by ``starts_at``.
|
||||
"""
|
||||
|
||||
kind: str | None = Field(
|
||||
description="Active contract kind ('tibber' or 'manual'), or null if no active contract."
|
||||
)
|
||||
currency: str = Field(description="ISO 4217 currency code.")
|
||||
points: list[PricePointSchema] = Field(
|
||||
description=(
|
||||
"15-minute price points for tibber contracts (ascending by starts_at). "
|
||||
"Empty for manual contracts or when no active contract exists."
|
||||
)
|
||||
)
|
||||
tariff: ManualTariffSchema | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Fixed tariff table for manual contracts. "
|
||||
"Null for tibber contracts and when no active contract exists."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CostPeriodSchema(BaseModel):
|
||||
"""One 15-minute billing record from the energy_cost_period table."""
|
||||
|
||||
period_start: datetime
|
||||
d1_kwh: float = Field(description="Delivered low-tariff kWh for this period.")
|
||||
d2_kwh: float = Field(description="Delivered normal-tariff kWh for this period.")
|
||||
r1_kwh: float = Field(description="Returned low-tariff kWh for this period.")
|
||||
r2_kwh: float = Field(description="Returned normal-tariff kWh for this period.")
|
||||
import_cost: float = Field(description="Cost of electricity drawn from grid (EUR).")
|
||||
export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).")
|
||||
net_cost: float = Field(description="import_cost − export_revenue (EUR).")
|
||||
currency: str = Field(description="ISO 4217 currency code.")
|
||||
degraded: bool = Field(
|
||||
description="True when the period was computed with incomplete data."
|
||||
)
|
||||
contract_version_id: int | None = Field(
|
||||
default=None,
|
||||
description="FK to the contract version used for this billing period (null when degraded).",
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CostsResponse(BaseModel):
|
||||
"""Response for GET /api/energy/costs."""
|
||||
|
||||
items: list[CostPeriodSchema]
|
||||
total: int = Field(description="Number of items returned.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
"""Response for GET /api/energy/costs/summary.
|
||||
|
||||
All monetary values are in ``currency``.
|
||||
|
||||
``total_payable = metered_net + fixed_costs − credits``
|
||||
"""
|
||||
|
||||
currency: str
|
||||
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
|
||||
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
|
||||
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
|
||||
fixed_costs: float = Field(
|
||||
description="Standing charges (network_fee + management_fee) apportioned over the interval."
|
||||
)
|
||||
credits: float = Field(
|
||||
description="Energy-tax credit (heffingskorting) apportioned over the interval."
|
||||
)
|
||||
total_payable: float = Field(
|
||||
description="metered_net + fixed_costs − credits (actual amount owed)."
|
||||
)
|
||||
period_count: int = Field(description="Number of non-degraded billing periods in range.")
|
||||
degraded_count: int = Field(description="Number of degraded billing periods in range.")
|
||||
days: float = Field(description="Interval length in days.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DsmrLatestResponse(BaseModel):
|
||||
"""Response for GET /api/energy/dsmr/latest.
|
||||
|
||||
``found`` is False when no dsmr_reading rows exist yet. The front-end
|
||||
should check ``found`` before reading ``recorded_at`` or ``payload``.
|
||||
"""
|
||||
|
||||
found: bool
|
||||
recorded_at: datetime | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecomputeResponse(BaseModel):
|
||||
"""Response for POST /api/energy/costs/recompute."""
|
||||
|
||||
recomputed: int = Field(
|
||||
description=(
|
||||
"Number of 15-minute periods for which a billing record was written "
|
||||
"(inserted or updated). Periods skipped due to missing contract or "
|
||||
"missing Tibber price are not counted."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TibberTestPriceSchema(BaseModel):
|
||||
"""Current Tibber price point returned on a successful test.
|
||||
|
||||
Carries enough fields for the front-end to confirm the API is working and
|
||||
display the live price. The API token is **never** included.
|
||||
"""
|
||||
|
||||
starts_at: datetime
|
||||
total: float
|
||||
energy: float
|
||||
tax: float
|
||||
currency: str
|
||||
level: str | None = None
|
||||
|
||||
|
||||
class TibberTestResponse(BaseModel):
|
||||
"""Three-state response for POST /api/energy/tibber/test.
|
||||
|
||||
Possible ``result`` values:
|
||||
|
||||
- ``"success"`` — Tibber API responded with a valid price.
|
||||
- ``"config-error"`` — Token is missing or not configured.
|
||||
- ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.).
|
||||
|
||||
``price`` is populated only on ``"success"``; it is null otherwise.
|
||||
``message`` always contains a human-readable explanation.
|
||||
"""
|
||||
|
||||
result: Literal["success", "config-error", "failed"]
|
||||
message: str
|
||||
price: TibberTestPriceSchema | None = None
|
||||
@@ -405,7 +405,7 @@ Phase D(API + 前端)
|
||||
- **Reviewer checklist**: `key` 稳定(固定字符串);复用既有 provider 协议、未改其它 provider;累计 `total_increasing` 语义正确;不阻塞计费 job。
|
||||
|
||||
### M6-T09 — Energy 数据 API
|
||||
- **Status**: `todo` · **Depends**: M6-T05, M6-T07
|
||||
- **Status**: `done` · **Depends**: M6-T05, M6-T07
|
||||
- **Context**: 价格/费用/汇总/最新 DSMR/重算/Tibber 测试端点(合同 CRUD 在 T04)。
|
||||
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`;`modify app/main.py`(注册);`create tests/test_api_energy.py`
|
||||
- **Steps**: `GET /prices`、`GET /costs`、`GET /costs/summary`、`GET /dsmr/latest`、`POST /costs/recompute`(调 T07)、`POST /tibber/test`(调 T05 client,三态);session+CSRF;查询走索引 + 窗口/上限。
|
||||
|
||||
@@ -695,6 +695,409 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/prices": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"api-energy"
|
||||
],
|
||||
"summary": "Get Prices",
|
||||
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
||||
"operationId": "get_prices_api_energy_prices_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted.",
|
||||
"title": "Start"
|
||||
},
|
||||
"description": "Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted."
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted.",
|
||||
"title": "End"
|
||||
},
|
||||
"description": "Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted."
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"maximum": 5000,
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of Tibber price points to return.",
|
||||
"default": 500,
|
||||
"title": "Limit"
|
||||
},
|
||||
"description": "Maximum number of Tibber price points to return."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PricesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/costs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"api-energy"
|
||||
],
|
||||
"summary": "Get Costs",
|
||||
"description": "Return energy_cost_period rows within a time window.\n\nRows are ordered by ``period_start`` ascending. When the window contains\nmore rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),\nthen reversed to ascending order — identical to the modbus readings pattern.\n\nQuery parameters:\n- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).\n- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).\n- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).",
|
||||
"operationId": "get_costs_api_energy_costs_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Inclusive lower bound for period_start (ISO 8601).",
|
||||
"title": "Start"
|
||||
},
|
||||
"description": "Inclusive lower bound for period_start (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Inclusive upper bound for period_start (ISO 8601).",
|
||||
"title": "End"
|
||||
},
|
||||
"description": "Inclusive upper bound for period_start (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"maximum": 5000,
|
||||
"minimum": 1,
|
||||
"description": "Maximum number of cost periods to return (default 500, max 5000).",
|
||||
"default": 500,
|
||||
"title": "Limit"
|
||||
},
|
||||
"description": "Maximum number of cost periods to return (default 500, max 5000)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CostsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/costs/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"api-energy"
|
||||
],
|
||||
"summary": "Get Costs Summary",
|
||||
"description": "Aggregate billing for a time interval.\n\nCalls ``energy_cost.summarize(session, start, end)`` which computes:\n\n total_payable = Σ(net_cost) + fixed_costs − credits\n\nwhere ``fixed_costs`` is (network_fee + management_fee) apportioned to the\ninterval length in days (÷30 per month), and ``credits`` is heffingskorting\napportioned similarly (÷365 per year).\n\nBoth ``fixed_costs`` and ``credits`` are derived from the **currently active\ncontract version at ``end``**. When no active contract exists they are 0.",
|
||||
"operationId": "get_costs_summary_api_energy_costs_summary_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day.",
|
||||
"title": "Start"
|
||||
},
|
||||
"description": "Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day."
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data).",
|
||||
"title": "End"
|
||||
},
|
||||
"description": "Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/dsmr/latest": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"api-energy"
|
||||
],
|
||||
"summary": "Get Dsmr Latest",
|
||||
"description": "Return the most recent dsmr_reading row.\n\nReturns ``{\"found\": false, \"recorded_at\": null, \"payload\": null}`` (200, not\n404) when no rows exist yet, so the front-end can distinguish \"no data\" from\na server error.",
|
||||
"operationId": "get_dsmr_latest_api_energy_dsmr_latest_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DsmrLatestResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/costs/recompute": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"api-energy"
|
||||
],
|
||||
"summary": "Post Recompute",
|
||||
"description": "Idempotently recompute billing records in a time window.\n\nCalls ``energy_cost.recompute_range(session, start, end)`` which overwrites\nexisting rows (including successful ones) for every UTC quarter-hour boundary\nin ``[start, end)``.\n\n**Idempotency**: repeated calls with the same window produce the same\noutcome. No rows are deleted; only upserted.\n\n**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.\nRequests exceeding this return 422.\n\nReturns the number of periods for which a billing record was written.\nPeriods skipped due to missing contract or missing Tibber price are not counted.",
|
||||
"operationId": "post_recompute_api_energy_costs_recompute_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Inclusive start of the recompute window (ISO 8601). Required.",
|
||||
"title": "Start"
|
||||
},
|
||||
"description": "Inclusive start of the recompute window (ISO 8601). Required."
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Exclusive end of the recompute window (ISO 8601). Required.",
|
||||
"title": "End"
|
||||
},
|
||||
"description": "Exclusive end of the recompute window (ISO 8601). Required."
|
||||
},
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RecomputeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation error (missing window or range too large)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/tibber/test": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"api-energy"
|
||||
],
|
||||
"summary": "Post Tibber Test",
|
||||
"description": "Test Tibber API connectivity by fetching the current price point.\n\nThree possible outcomes:\n\n- **200** ``{ result: \"success\", message: ..., price: {...} }``\n The Tibber API responded with a valid current price. ``price`` contains\n starts_at, total, energy, tax, currency, and level.\n\n- **400** ``{ result: \"config-error\", message: ... }``\n The Tibber API token is empty or not configured.\n\n- **502** ``{ result: \"failed\", message: ... }``\n The API call failed (authentication rejected, network error, timeout,\n unexpected response, etc.).\n\nThe API token is **never** included in the response body or logged.",
|
||||
"operationId": "post_tibber_test_api_energy_tibber_test_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TibberTestResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TibberTestResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"502": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TibberTestResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Gateway"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/energy/profiles": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2461,6 +2864,110 @@
|
||||
"title": "ContractVersionResponse",
|
||||
"description": "Response schema for a single EnergyContractVersion row."
|
||||
},
|
||||
"CostPeriodSchema": {
|
||||
"properties": {
|
||||
"period_start": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Period Start"
|
||||
},
|
||||
"d1_kwh": {
|
||||
"type": "number",
|
||||
"title": "D1 Kwh",
|
||||
"description": "Delivered low-tariff kWh for this period."
|
||||
},
|
||||
"d2_kwh": {
|
||||
"type": "number",
|
||||
"title": "D2 Kwh",
|
||||
"description": "Delivered normal-tariff kWh for this period."
|
||||
},
|
||||
"r1_kwh": {
|
||||
"type": "number",
|
||||
"title": "R1 Kwh",
|
||||
"description": "Returned low-tariff kWh for this period."
|
||||
},
|
||||
"r2_kwh": {
|
||||
"type": "number",
|
||||
"title": "R2 Kwh",
|
||||
"description": "Returned normal-tariff kWh for this period."
|
||||
},
|
||||
"import_cost": {
|
||||
"type": "number",
|
||||
"title": "Import Cost",
|
||||
"description": "Cost of electricity drawn from grid (EUR)."
|
||||
},
|
||||
"export_revenue": {
|
||||
"type": "number",
|
||||
"title": "Export Revenue",
|
||||
"description": "Revenue from electricity fed to grid (EUR)."
|
||||
},
|
||||
"net_cost": {
|
||||
"type": "number",
|
||||
"title": "Net Cost",
|
||||
"description": "import_cost − export_revenue (EUR)."
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "Currency",
|
||||
"description": "ISO 4217 currency code."
|
||||
},
|
||||
"degraded": {
|
||||
"type": "boolean",
|
||||
"title": "Degraded",
|
||||
"description": "True when the period was computed with incomplete data."
|
||||
},
|
||||
"contract_version_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Contract Version Id",
|
||||
"description": "FK to the contract version used for this billing period (null when degraded)."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"period_start",
|
||||
"d1_kwh",
|
||||
"d2_kwh",
|
||||
"r1_kwh",
|
||||
"r2_kwh",
|
||||
"import_cost",
|
||||
"export_revenue",
|
||||
"net_cost",
|
||||
"currency",
|
||||
"degraded"
|
||||
],
|
||||
"title": "CostPeriodSchema",
|
||||
"description": "One 15-minute billing record from the energy_cost_period table."
|
||||
},
|
||||
"CostsResponse": {
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CostPeriodSchema"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Items"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"title": "Total",
|
||||
"description": "Number of items returned."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"title": "CostsResponse",
|
||||
"description": "Response for GET /api/energy/costs."
|
||||
},
|
||||
"DeviceInfoSchema": {
|
||||
"properties": {
|
||||
"identifiers": {
|
||||
@@ -2483,6 +2990,44 @@
|
||||
"title": "DeviceInfoSchema",
|
||||
"description": "HA device grouping info for an exposable entity."
|
||||
},
|
||||
"DsmrLatestResponse": {
|
||||
"properties": {
|
||||
"found": {
|
||||
"type": "boolean",
|
||||
"title": "Found"
|
||||
},
|
||||
"recorded_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Recorded At"
|
||||
},
|
||||
"payload": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Payload"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"found"
|
||||
],
|
||||
"title": "DsmrLatestResponse",
|
||||
"description": "Response for GET /api/energy/dsmr/latest.\n\n``found`` is False when no dsmr_reading rows exist yet. The front-end\nshould check ``found`` before reading ``recorded_at`` or ``payload``."
|
||||
},
|
||||
"ExposableEntitySchema": {
|
||||
"properties": {
|
||||
"key": {
|
||||
@@ -2746,6 +3291,39 @@
|
||||
],
|
||||
"title": "LoginRequest"
|
||||
},
|
||||
"ManualTariffSchema": {
|
||||
"properties": {
|
||||
"buy_dal": {
|
||||
"type": "number",
|
||||
"title": "Buy Dal",
|
||||
"description": "Effective buy price, low-tariff / dal (EUR/kWh)."
|
||||
},
|
||||
"buy_normal": {
|
||||
"type": "number",
|
||||
"title": "Buy Normal",
|
||||
"description": "Effective buy price, normal / high-tariff (EUR/kWh)."
|
||||
},
|
||||
"sell_dal": {
|
||||
"type": "number",
|
||||
"title": "Sell Dal",
|
||||
"description": "Sell price, low-tariff / dal (EUR/kWh)."
|
||||
},
|
||||
"sell_normal": {
|
||||
"type": "number",
|
||||
"title": "Sell Normal",
|
||||
"description": "Sell price, normal / high-tariff (EUR/kWh)."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"buy_dal",
|
||||
"buy_normal",
|
||||
"sell_dal",
|
||||
"sell_normal"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
"MetricInfo": {
|
||||
"properties": {
|
||||
"key": {
|
||||
@@ -3377,6 +3955,93 @@
|
||||
"title": "PooUpdateRequest",
|
||||
"description": "PATCH body for a poo record — all fields optional; PK field excluded."
|
||||
},
|
||||
"PricePointSchema": {
|
||||
"properties": {
|
||||
"starts_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Starts At"
|
||||
},
|
||||
"buy": {
|
||||
"type": "number",
|
||||
"title": "Buy",
|
||||
"description": "All-in buy price in EUR/kWh (including taxes)."
|
||||
},
|
||||
"sell": {
|
||||
"type": "number",
|
||||
"title": "Sell",
|
||||
"description": "Net sell price in EUR/kWh."
|
||||
},
|
||||
"level": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Level",
|
||||
"description": "Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"starts_at",
|
||||
"buy",
|
||||
"sell"
|
||||
],
|
||||
"title": "PricePointSchema",
|
||||
"description": "A single 15-minute price point (tibber) or placeholder entry."
|
||||
},
|
||||
"PricesResponse": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Kind",
|
||||
"description": "Active contract kind ('tibber' or 'manual'), or null if no active contract."
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "Currency",
|
||||
"description": "ISO 4217 currency code."
|
||||
},
|
||||
"points": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PricePointSchema"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Points",
|
||||
"description": "15-minute price points for tibber contracts (ascending by starts_at). Empty for manual contracts or when no active contract exists."
|
||||
},
|
||||
"tariff": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ManualTariffSchema"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"currency",
|
||||
"points"
|
||||
],
|
||||
"title": "PricesResponse",
|
||||
"description": "Response for GET /api/energy/prices.\n\n``kind`` mirrors the active contract kind:\n- ``\"tibber\"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.\n- ``\"manual\"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.\n- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.\n\n``currency`` comes from the active contract (or \"EUR\" fallback).\n``points`` is always ascending by ``starts_at``."
|
||||
},
|
||||
"ProfileSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -3596,6 +4261,21 @@
|
||||
],
|
||||
"title": "PublicIPStateSchema"
|
||||
},
|
||||
"RecomputeResponse": {
|
||||
"properties": {
|
||||
"recomputed": {
|
||||
"type": "integer",
|
||||
"title": "Recomputed",
|
||||
"description": "Number of 15-minute periods for which a billing record was written (inserted or updated). Periods skipped due to missing contract or missing Tibber price are not counted."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"recomputed"
|
||||
],
|
||||
"title": "RecomputeResponse",
|
||||
"description": "Response for POST /api/energy/costs/recompute."
|
||||
},
|
||||
"RepublishResponse": {
|
||||
"properties": {
|
||||
"ok": {
|
||||
@@ -3687,6 +4367,154 @@
|
||||
],
|
||||
"title": "StatusResponse"
|
||||
},
|
||||
"SummaryResponse": {
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "Currency"
|
||||
},
|
||||
"metered_import": {
|
||||
"type": "number",
|
||||
"title": "Metered Import",
|
||||
"description": "Σ import_cost for non-degraded periods."
|
||||
},
|
||||
"metered_export": {
|
||||
"type": "number",
|
||||
"title": "Metered Export",
|
||||
"description": "Σ export_revenue for non-degraded periods."
|
||||
},
|
||||
"metered_net": {
|
||||
"type": "number",
|
||||
"title": "Metered Net",
|
||||
"description": "Σ net_cost for non-degraded periods."
|
||||
},
|
||||
"fixed_costs": {
|
||||
"type": "number",
|
||||
"title": "Fixed Costs",
|
||||
"description": "Standing charges (network_fee + management_fee) apportioned over the interval."
|
||||
},
|
||||
"credits": {
|
||||
"type": "number",
|
||||
"title": "Credits",
|
||||
"description": "Energy-tax credit (heffingskorting) apportioned over the interval."
|
||||
},
|
||||
"total_payable": {
|
||||
"type": "number",
|
||||
"title": "Total Payable",
|
||||
"description": "metered_net + fixed_costs − credits (actual amount owed)."
|
||||
},
|
||||
"period_count": {
|
||||
"type": "integer",
|
||||
"title": "Period Count",
|
||||
"description": "Number of non-degraded billing periods in range."
|
||||
},
|
||||
"degraded_count": {
|
||||
"type": "integer",
|
||||
"title": "Degraded Count",
|
||||
"description": "Number of degraded billing periods in range."
|
||||
},
|
||||
"days": {
|
||||
"type": "number",
|
||||
"title": "Days",
|
||||
"description": "Interval length in days."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"currency",
|
||||
"metered_import",
|
||||
"metered_export",
|
||||
"metered_net",
|
||||
"fixed_costs",
|
||||
"credits",
|
||||
"total_payable",
|
||||
"period_count",
|
||||
"degraded_count",
|
||||
"days"
|
||||
],
|
||||
"title": "SummaryResponse",
|
||||
"description": "Response for GET /api/energy/costs/summary.\n\nAll monetary values are in ``currency``.\n\n``total_payable = metered_net + fixed_costs − credits``"
|
||||
},
|
||||
"TibberTestPriceSchema": {
|
||||
"properties": {
|
||||
"starts_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Starts At"
|
||||
},
|
||||
"total": {
|
||||
"type": "number",
|
||||
"title": "Total"
|
||||
},
|
||||
"energy": {
|
||||
"type": "number",
|
||||
"title": "Energy"
|
||||
},
|
||||
"tax": {
|
||||
"type": "number",
|
||||
"title": "Tax"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "Currency"
|
||||
},
|
||||
"level": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Level"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"starts_at",
|
||||
"total",
|
||||
"energy",
|
||||
"tax",
|
||||
"currency"
|
||||
],
|
||||
"title": "TibberTestPriceSchema",
|
||||
"description": "Current Tibber price point returned on a successful test.\n\nCarries enough fields for the front-end to confirm the API is working and\ndisplay the live price. The API token is **never** included."
|
||||
},
|
||||
"TibberTestResponse": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success",
|
||||
"config-error",
|
||||
"failed"
|
||||
],
|
||||
"title": "Result"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"title": "Message"
|
||||
},
|
||||
"price": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TibberTestPriceSchema"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"result",
|
||||
"message"
|
||||
],
|
||||
"title": "TibberTestResponse",
|
||||
"description": "Three-state response for POST /api/energy/tibber/test.\n\nPossible ``result`` values:\n\n- ``\"success\"`` — Tibber API responded with a valid price.\n- ``\"config-error\"`` — Token is missing or not configured.\n- ``\"failed\"`` — API call failed (auth rejected, network error, timeout, etc.).\n\n``price`` is populated only on ``\"success\"``; it is null otherwise.\n``message`` always contains a human-readable explanation."
|
||||
},
|
||||
"TotpDisableRequest": {
|
||||
"properties": {
|
||||
"password": {
|
||||
|
||||
@@ -516,6 +516,338 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/energy/prices:
|
||||
get:
|
||||
tags:
|
||||
- api-energy
|
||||
summary: Get Prices
|
||||
description: "Return the price curve for the active contract.\n\n**Tibber contracts**\
|
||||
\ (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``,\
|
||||
\ ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned\
|
||||
\ (most recent first\n within the window, then reversed to ascending order\
|
||||
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
||||
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
||||
\ price)\n - ``sell = total − energy_tax − sell_adjust`` (from active\
|
||||
\ version values)\n - ``level`` (Tibber price level,\
|
||||
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
||||
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
||||
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
||||
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
||||
\ + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal\
|
||||
\ = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"\
|
||||
EUR\", points=[], tariff=null (200)."
|
||||
operationId: get_prices_api_energy_prices_get
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
description: Inclusive start of the time window (ISO 8601). Defaults to
|
||||
the start of today UTC when omitted.
|
||||
title: Start
|
||||
description: Inclusive start of the time window (ISO 8601). Defaults to the
|
||||
start of today UTC when omitted.
|
||||
- name: end
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
description: Inclusive end of the time window (ISO 8601). Defaults to the
|
||||
end of tomorrow UTC when omitted.
|
||||
title: End
|
||||
description: Inclusive end of the time window (ISO 8601). Defaults to the
|
||||
end of tomorrow UTC when omitted.
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
maximum: 5000
|
||||
minimum: 1
|
||||
description: Maximum number of Tibber price points to return.
|
||||
default: 500
|
||||
title: Limit
|
||||
description: Maximum number of Tibber price points to return.
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PricesResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/energy/costs:
|
||||
get:
|
||||
tags:
|
||||
- api-energy
|
||||
summary: Get Costs
|
||||
description: 'Return energy_cost_period rows within a time window.
|
||||
|
||||
|
||||
Rows are ordered by ``period_start`` ascending. When the window contains
|
||||
|
||||
more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
|
||||
|
||||
then reversed to ascending order — identical to the modbus readings pattern.
|
||||
|
||||
|
||||
Query parameters:
|
||||
|
||||
- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
|
||||
|
||||
- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).
|
||||
|
||||
- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).'
|
||||
operationId: get_costs_api_energy_costs_get
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
description: Inclusive lower bound for period_start (ISO 8601).
|
||||
title: Start
|
||||
description: Inclusive lower bound for period_start (ISO 8601).
|
||||
- name: end
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
description: Inclusive upper bound for period_start (ISO 8601).
|
||||
title: End
|
||||
description: Inclusive upper bound for period_start (ISO 8601).
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
maximum: 5000
|
||||
minimum: 1
|
||||
description: Maximum number of cost periods to return (default 500, max
|
||||
5000).
|
||||
default: 500
|
||||
title: Limit
|
||||
description: Maximum number of cost periods to return (default 500, max 5000).
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CostsResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/energy/costs/summary:
|
||||
get:
|
||||
tags:
|
||||
- api-energy
|
||||
summary: Get Costs Summary
|
||||
description: "Aggregate billing for a time interval.\n\nCalls ``energy_cost.summarize(session,\
|
||||
\ start, end)`` which computes:\n\n total_payable = Σ(net_cost) + fixed_costs\
|
||||
\ − credits\n\nwhere ``fixed_costs`` is (network_fee + management_fee) apportioned\
|
||||
\ to the\ninterval length in days (÷30 per month), and ``credits`` is heffingskorting\n\
|
||||
apportioned similarly (÷365 per year).\n\nBoth ``fixed_costs`` and ``credits``\
|
||||
\ are derived from the **currently active\ncontract version at ``end``**.\
|
||||
\ When no active contract exists they are 0."
|
||||
operationId: get_costs_summary_api_energy_costs_summary_get
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
description: Inclusive start of the summary interval (ISO 8601). Defaults
|
||||
to the start of the current UTC day.
|
||||
title: Start
|
||||
description: Inclusive start of the summary interval (ISO 8601). Defaults
|
||||
to the start of the current UTC day.
|
||||
- name: end
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
description: Exclusive end of the summary interval (ISO 8601). Defaults
|
||||
to the start of the next UTC day (i.e. today's full data).
|
||||
title: End
|
||||
description: Exclusive end of the summary interval (ISO 8601). Defaults to
|
||||
the start of the next UTC day (i.e. today's full data).
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SummaryResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/energy/dsmr/latest:
|
||||
get:
|
||||
tags:
|
||||
- api-energy
|
||||
summary: Get Dsmr Latest
|
||||
description: 'Return the most recent dsmr_reading row.
|
||||
|
||||
|
||||
Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
|
||||
|
||||
404) when no rows exist yet, so the front-end can distinguish "no data" from
|
||||
|
||||
a server error.'
|
||||
operationId: get_dsmr_latest_api_energy_dsmr_latest_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DsmrLatestResponse'
|
||||
/api/energy/costs/recompute:
|
||||
post:
|
||||
tags:
|
||||
- api-energy
|
||||
summary: Post Recompute
|
||||
description: 'Idempotently recompute billing records in a time window.
|
||||
|
||||
|
||||
Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
|
||||
|
||||
existing rows (including successful ones) for every UTC quarter-hour boundary
|
||||
|
||||
in ``[start, end)``.
|
||||
|
||||
|
||||
**Idempotency**: repeated calls with the same window produce the same
|
||||
|
||||
outcome. No rows are deleted; only upserted.
|
||||
|
||||
|
||||
**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS}
|
||||
days.
|
||||
|
||||
Requests exceeding this return 422.
|
||||
|
||||
|
||||
Returns the number of periods for which a billing record was written.
|
||||
|
||||
Periods skipped due to missing contract or missing Tibber price are not counted.'
|
||||
operationId: post_recompute_api_energy_costs_recompute_post
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Inclusive start of the recompute window (ISO 8601). Required.
|
||||
title: Start
|
||||
description: Inclusive start of the recompute window (ISO 8601). Required.
|
||||
- name: end
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Exclusive end of the recompute window (ISO 8601). Required.
|
||||
title: End
|
||||
description: Exclusive end of the recompute window (ISO 8601). Required.
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RecomputeResponse'
|
||||
'422':
|
||||
description: Validation error (missing window or range too large)
|
||||
/api/energy/tibber/test:
|
||||
post:
|
||||
tags:
|
||||
- api-energy
|
||||
summary: Post Tibber Test
|
||||
description: "Test Tibber API connectivity by fetching the current price point.\n\
|
||||
\nThree possible outcomes:\n\n- **200** ``{ result: \"success\", message:\
|
||||
\ ..., price: {...} }``\n The Tibber API responded with a valid current price.\
|
||||
\ ``price`` contains\n starts_at, total, energy, tax, currency, and level.\n\
|
||||
\n- **400** ``{ result: \"config-error\", message: ... }``\n The Tibber API\
|
||||
\ token is empty or not configured.\n\n- **502** ``{ result: \"failed\", \
|
||||
\ message: ... }``\n The API call failed (authentication rejected, network\
|
||||
\ error, timeout,\n unexpected response, etc.).\n\nThe API token is **never**\
|
||||
\ included in the response body or logged."
|
||||
operationId: post_tibber_test_api_energy_tibber_test_post
|
||||
parameters:
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TibberTestResponse'
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TibberTestResponse'
|
||||
description: Bad Request
|
||||
'502':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TibberTestResponse'
|
||||
description: Bad Gateway
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/energy/profiles:
|
||||
get:
|
||||
tags:
|
||||
@@ -1899,6 +2231,86 @@ components:
|
||||
- created_at
|
||||
title: ContractVersionResponse
|
||||
description: Response schema for a single EnergyContractVersion row.
|
||||
CostPeriodSchema:
|
||||
properties:
|
||||
period_start:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Period Start
|
||||
d1_kwh:
|
||||
type: number
|
||||
title: D1 Kwh
|
||||
description: Delivered low-tariff kWh for this period.
|
||||
d2_kwh:
|
||||
type: number
|
||||
title: D2 Kwh
|
||||
description: Delivered normal-tariff kWh for this period.
|
||||
r1_kwh:
|
||||
type: number
|
||||
title: R1 Kwh
|
||||
description: Returned low-tariff kWh for this period.
|
||||
r2_kwh:
|
||||
type: number
|
||||
title: R2 Kwh
|
||||
description: Returned normal-tariff kWh for this period.
|
||||
import_cost:
|
||||
type: number
|
||||
title: Import Cost
|
||||
description: Cost of electricity drawn from grid (EUR).
|
||||
export_revenue:
|
||||
type: number
|
||||
title: Export Revenue
|
||||
description: Revenue from electricity fed to grid (EUR).
|
||||
net_cost:
|
||||
type: number
|
||||
title: Net Cost
|
||||
description: import_cost − export_revenue (EUR).
|
||||
currency:
|
||||
type: string
|
||||
title: Currency
|
||||
description: ISO 4217 currency code.
|
||||
degraded:
|
||||
type: boolean
|
||||
title: Degraded
|
||||
description: True when the period was computed with incomplete data.
|
||||
contract_version_id:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
title: Contract Version Id
|
||||
description: FK to the contract version used for this billing period (null
|
||||
when degraded).
|
||||
type: object
|
||||
required:
|
||||
- period_start
|
||||
- d1_kwh
|
||||
- d2_kwh
|
||||
- r1_kwh
|
||||
- r2_kwh
|
||||
- import_cost
|
||||
- export_revenue
|
||||
- net_cost
|
||||
- currency
|
||||
- degraded
|
||||
title: CostPeriodSchema
|
||||
description: One 15-minute billing record from the energy_cost_period table.
|
||||
CostsResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/CostPeriodSchema'
|
||||
type: array
|
||||
title: Items
|
||||
total:
|
||||
type: integer
|
||||
title: Total
|
||||
description: Number of items returned.
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
- total
|
||||
title: CostsResponse
|
||||
description: Response for GET /api/energy/costs.
|
||||
DeviceInfoSchema:
|
||||
properties:
|
||||
identifiers:
|
||||
@@ -1915,6 +2327,33 @@ components:
|
||||
- name
|
||||
title: DeviceInfoSchema
|
||||
description: HA device grouping info for an exposable entity.
|
||||
DsmrLatestResponse:
|
||||
properties:
|
||||
found:
|
||||
type: boolean
|
||||
title: Found
|
||||
recorded_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Recorded At
|
||||
payload:
|
||||
anyOf:
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: 'null'
|
||||
title: Payload
|
||||
type: object
|
||||
required:
|
||||
- found
|
||||
title: DsmrLatestResponse
|
||||
description: 'Response for GET /api/energy/dsmr/latest.
|
||||
|
||||
|
||||
``found`` is False when no dsmr_reading rows exist yet. The front-end
|
||||
|
||||
should check ``found`` before reading ``recorded_at`` or ``payload``.'
|
||||
ExposableEntitySchema:
|
||||
properties:
|
||||
key:
|
||||
@@ -2097,6 +2536,37 @@ components:
|
||||
- username
|
||||
- password
|
||||
title: LoginRequest
|
||||
ManualTariffSchema:
|
||||
properties:
|
||||
buy_dal:
|
||||
type: number
|
||||
title: Buy Dal
|
||||
description: Effective buy price, low-tariff / dal (EUR/kWh).
|
||||
buy_normal:
|
||||
type: number
|
||||
title: Buy Normal
|
||||
description: Effective buy price, normal / high-tariff (EUR/kWh).
|
||||
sell_dal:
|
||||
type: number
|
||||
title: Sell Dal
|
||||
description: Sell price, low-tariff / dal (EUR/kWh).
|
||||
sell_normal:
|
||||
type: number
|
||||
title: Sell Normal
|
||||
description: Sell price, normal / high-tariff (EUR/kWh).
|
||||
type: object
|
||||
required:
|
||||
- buy_dal
|
||||
- buy_normal
|
||||
- sell_dal
|
||||
- sell_normal
|
||||
title: ManualTariffSchema
|
||||
description: 'Fixed-tariff breakdown for manual contracts.
|
||||
|
||||
|
||||
Prices are the *effective* buy prices as used by the billing engine
|
||||
|
||||
(energy_buy_x + energy_tax + ode) and the raw sell prices.'
|
||||
MetricInfo:
|
||||
properties:
|
||||
key:
|
||||
@@ -2534,6 +3004,81 @@ components:
|
||||
type: object
|
||||
title: PooUpdateRequest
|
||||
description: PATCH body for a poo record — all fields optional; PK field excluded.
|
||||
PricePointSchema:
|
||||
properties:
|
||||
starts_at:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Starts At
|
||||
buy:
|
||||
type: number
|
||||
title: Buy
|
||||
description: All-in buy price in EUR/kWh (including taxes).
|
||||
sell:
|
||||
type: number
|
||||
title: Sell
|
||||
description: Net sell price in EUR/kWh.
|
||||
level:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Level
|
||||
description: Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.
|
||||
type: object
|
||||
required:
|
||||
- starts_at
|
||||
- buy
|
||||
- sell
|
||||
title: PricePointSchema
|
||||
description: A single 15-minute price point (tibber) or placeholder entry.
|
||||
PricesResponse:
|
||||
properties:
|
||||
kind:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Kind
|
||||
description: Active contract kind ('tibber' or 'manual'), or null if no
|
||||
active contract.
|
||||
currency:
|
||||
type: string
|
||||
title: Currency
|
||||
description: ISO 4217 currency code.
|
||||
points:
|
||||
items:
|
||||
$ref: '#/components/schemas/PricePointSchema'
|
||||
type: array
|
||||
title: Points
|
||||
description: 15-minute price points for tibber contracts (ascending by starts_at).
|
||||
Empty for manual contracts or when no active contract exists.
|
||||
tariff:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/ManualTariffSchema'
|
||||
- type: 'null'
|
||||
description: Fixed tariff table for manual contracts. Null for tibber contracts
|
||||
and when no active contract exists.
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- currency
|
||||
- points
|
||||
title: PricesResponse
|
||||
description: 'Response for GET /api/energy/prices.
|
||||
|
||||
|
||||
``kind`` mirrors the active contract kind:
|
||||
|
||||
- ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is
|
||||
null.
|
||||
|
||||
- ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
|
||||
|
||||
- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
|
||||
|
||||
|
||||
``currency`` comes from the active contract (or "EUR" fallback).
|
||||
|
||||
``points`` is always ascending by ``starts_at``.'
|
||||
ProfileSummary:
|
||||
properties:
|
||||
name:
|
||||
@@ -2689,6 +3234,19 @@ components:
|
||||
- last_check_error
|
||||
- last_provider
|
||||
title: PublicIPStateSchema
|
||||
RecomputeResponse:
|
||||
properties:
|
||||
recomputed:
|
||||
type: integer
|
||||
title: Recomputed
|
||||
description: Number of 15-minute periods for which a billing record was
|
||||
written (inserted or updated). Periods skipped due to missing contract
|
||||
or missing Tibber price are not counted.
|
||||
type: object
|
||||
required:
|
||||
- recomputed
|
||||
title: RecomputeResponse
|
||||
description: Response for POST /api/energy/costs/recompute.
|
||||
RepublishResponse:
|
||||
properties:
|
||||
ok:
|
||||
@@ -2755,6 +3313,143 @@ components:
|
||||
required:
|
||||
- status
|
||||
title: StatusResponse
|
||||
SummaryResponse:
|
||||
properties:
|
||||
currency:
|
||||
type: string
|
||||
title: Currency
|
||||
metered_import:
|
||||
type: number
|
||||
title: Metered Import
|
||||
description: Σ import_cost for non-degraded periods.
|
||||
metered_export:
|
||||
type: number
|
||||
title: Metered Export
|
||||
description: Σ export_revenue for non-degraded periods.
|
||||
metered_net:
|
||||
type: number
|
||||
title: Metered Net
|
||||
description: Σ net_cost for non-degraded periods.
|
||||
fixed_costs:
|
||||
type: number
|
||||
title: Fixed Costs
|
||||
description: Standing charges (network_fee + management_fee) apportioned
|
||||
over the interval.
|
||||
credits:
|
||||
type: number
|
||||
title: Credits
|
||||
description: Energy-tax credit (heffingskorting) apportioned over the interval.
|
||||
total_payable:
|
||||
type: number
|
||||
title: Total Payable
|
||||
description: metered_net + fixed_costs − credits (actual amount owed).
|
||||
period_count:
|
||||
type: integer
|
||||
title: Period Count
|
||||
description: Number of non-degraded billing periods in range.
|
||||
degraded_count:
|
||||
type: integer
|
||||
title: Degraded Count
|
||||
description: Number of degraded billing periods in range.
|
||||
days:
|
||||
type: number
|
||||
title: Days
|
||||
description: Interval length in days.
|
||||
type: object
|
||||
required:
|
||||
- currency
|
||||
- metered_import
|
||||
- metered_export
|
||||
- metered_net
|
||||
- fixed_costs
|
||||
- credits
|
||||
- total_payable
|
||||
- period_count
|
||||
- degraded_count
|
||||
- days
|
||||
title: SummaryResponse
|
||||
description: 'Response for GET /api/energy/costs/summary.
|
||||
|
||||
|
||||
All monetary values are in ``currency``.
|
||||
|
||||
|
||||
``total_payable = metered_net + fixed_costs − credits``'
|
||||
TibberTestPriceSchema:
|
||||
properties:
|
||||
starts_at:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Starts At
|
||||
total:
|
||||
type: number
|
||||
title: Total
|
||||
energy:
|
||||
type: number
|
||||
title: Energy
|
||||
tax:
|
||||
type: number
|
||||
title: Tax
|
||||
currency:
|
||||
type: string
|
||||
title: Currency
|
||||
level:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Level
|
||||
type: object
|
||||
required:
|
||||
- starts_at
|
||||
- total
|
||||
- energy
|
||||
- tax
|
||||
- currency
|
||||
title: TibberTestPriceSchema
|
||||
description: 'Current Tibber price point returned on a successful test.
|
||||
|
||||
|
||||
Carries enough fields for the front-end to confirm the API is working and
|
||||
|
||||
display the live price. The API token is **never** included.'
|
||||
TibberTestResponse:
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- config-error
|
||||
- failed
|
||||
title: Result
|
||||
message:
|
||||
type: string
|
||||
title: Message
|
||||
price:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/TibberTestPriceSchema'
|
||||
- type: 'null'
|
||||
type: object
|
||||
required:
|
||||
- result
|
||||
- message
|
||||
title: TibberTestResponse
|
||||
description: 'Three-state response for POST /api/energy/tibber/test.
|
||||
|
||||
|
||||
Possible ``result`` values:
|
||||
|
||||
|
||||
- ``"success"`` — Tibber API responded with a valid price.
|
||||
|
||||
- ``"config-error"`` — Token is missing or not configured.
|
||||
|
||||
- ``"failed"`` — API call failed (auth rejected, network error, timeout,
|
||||
etc.).
|
||||
|
||||
|
||||
``price`` is populated only on ``"success"``; it is null otherwise.
|
||||
|
||||
``message`` always contains a human-readable explanation.'
|
||||
TotpDisableRequest:
|
||||
properties:
|
||||
password:
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
"""Tests for M6-T09: Energy data API.
|
||||
|
||||
Coverage
|
||||
--------
|
||||
GET /api/energy/prices
|
||||
- unauthenticated → 401
|
||||
- no active contract → 200, kind=null, points=[], tariff=null
|
||||
- tibber active contract → 200, points from tibber_price, tariff=null
|
||||
- manual active contract → 200, points=[], tariff with effective prices
|
||||
- limit parameter caps tibber points
|
||||
|
||||
GET /api/energy/costs
|
||||
- unauthenticated → 401
|
||||
- empty DB → 200, items=[]
|
||||
- returns rows ordered by period_start ascending
|
||||
- limit parameter caps results
|
||||
|
||||
GET /api/energy/costs/summary
|
||||
- unauthenticated → 401
|
||||
- empty DB → 200, all-zeros (no active contract, no periods)
|
||||
- returns summarize() result structure
|
||||
|
||||
GET /api/energy/dsmr/latest
|
||||
- unauthenticated → 401
|
||||
- no data → 200, found=false
|
||||
- with data → 200, found=true, correct payload
|
||||
|
||||
POST /api/energy/costs/recompute
|
||||
- unauthenticated → 401
|
||||
- missing CSRF → 403
|
||||
- end ≤ start → 422
|
||||
- window > 366 days → 422
|
||||
- valid call → 200, recomputed=int (idempotent)
|
||||
|
||||
POST /api/energy/tibber/test
|
||||
- unauthenticated → 401
|
||||
- missing CSRF → 403
|
||||
- token empty → 400 config-error
|
||||
- mock client success → 200 success with price data
|
||||
- mock client TibberAuthError → 502 failed
|
||||
- mock client TibberError → 502 failed
|
||||
- token never appears in success/failed responses
|
||||
|
||||
Auth/CSRF matrix: all write endpoints need session + CSRF.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.tibber.client import PricePoint, TibberAuthError, TibberError
|
||||
from app.models.energy import (
|
||||
DsmrReading,
|
||||
EnergyCostPeriod,
|
||||
EnergyContract,
|
||||
EnergyContractVersion,
|
||||
TibberPrice,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CSRF = "test-csrf-token"
|
||||
|
||||
_MANUAL_VALUES: dict[str, Any] = {
|
||||
"energy": {
|
||||
"buy": {"normal": 0.40, "dal": 0.30},
|
||||
"sell": {"normal": 0.10, "dal": 0.10},
|
||||
"energy_tax": 0.1108,
|
||||
"ode": 0.0,
|
||||
},
|
||||
"standing": {
|
||||
"network_fee": 25.0,
|
||||
"management_fee": 9.87,
|
||||
},
|
||||
"credits": {
|
||||
"heffingskorting": 600.0,
|
||||
},
|
||||
}
|
||||
|
||||
_TIBBER_VALUES: dict[str, Any] = {
|
||||
"energy": {
|
||||
"energy_tax": 0.1108,
|
||||
"sell_adjust": 0.0,
|
||||
},
|
||||
"standing": {
|
||||
"management_fee": 5.99,
|
||||
"network_fee": 25.0,
|
||||
},
|
||||
"credits": {
|
||||
"heffingskorting": 600.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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 _set_app_config(engine, key: str, value: str) -> None:
|
||||
"""Upsert a key/value pair into the app_config table.
|
||||
|
||||
Used by tibber test helpers to inject a non-empty TIBBER_API_TOKEN without
|
||||
needing to patch get_app_settings (which is shared with auth infrastructure).
|
||||
"""
|
||||
from sqlalchemy.orm import Session as _Session
|
||||
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
now = datetime.now(UTC)
|
||||
with _Session(engine) as session:
|
||||
existing = session.execute(
|
||||
__import__("sqlalchemy").select(AppConfigEntry).where(AppConfigEntry.key == key)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.value = value
|
||||
existing.updated_at = now
|
||||
else:
|
||||
session.add(AppConfigEntry(key=key, value=value, updated_at=now))
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def energy_client(auth_database):
|
||||
"""TestClient + SQLAlchemy engine + FastAPI app for Energy 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, fastapi_app
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _make_active_manual_contract(engine) -> tuple[EnergyContract, EnergyContractVersion]:
|
||||
"""Insert an active manual EnergyContract with one version and return both."""
|
||||
now = datetime.now(UTC)
|
||||
with Session(engine) as session:
|
||||
contract = EnergyContract(
|
||||
name="Test Manual",
|
||||
kind="manual",
|
||||
active=True,
|
||||
currency="EUR",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(contract)
|
||||
session.flush()
|
||||
version = EnergyContractVersion(
|
||||
contract_id=contract.id,
|
||||
effective_from=now - timedelta(days=30),
|
||||
effective_to=None,
|
||||
values=_MANUAL_VALUES,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
# capture ids before session closes
|
||||
contract_id = contract.id
|
||||
version_id = version.id
|
||||
|
||||
# Return plain data structs (not ORM objects tied to closed session)
|
||||
class _ContractStub:
|
||||
id = contract_id
|
||||
|
||||
class _VersionStub:
|
||||
id = version_id
|
||||
|
||||
return _ContractStub(), _VersionStub() # type: ignore[return-value]
|
||||
|
||||
|
||||
def _make_active_tibber_contract(engine) -> tuple[int, int]:
|
||||
"""Insert an active tibber EnergyContract with one version and return (contract_id, version_id)."""
|
||||
now = datetime.now(UTC)
|
||||
with Session(engine) as session:
|
||||
contract = EnergyContract(
|
||||
name="Test Tibber",
|
||||
kind="tibber",
|
||||
active=True,
|
||||
currency="EUR",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(contract)
|
||||
session.flush()
|
||||
version = EnergyContractVersion(
|
||||
contract_id=contract.id,
|
||||
effective_from=now - timedelta(days=30),
|
||||
effective_to=None,
|
||||
values=_TIBBER_VALUES,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return contract.id, version.id
|
||||
|
||||
|
||||
def _make_tibber_prices(engine, count: int = 3) -> list[datetime]:
|
||||
"""Insert ``count`` TibberPrice rows starting from one hour ago; return starts_at list."""
|
||||
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
base = now - timedelta(hours=1)
|
||||
starts = [base + timedelta(minutes=15 * i) for i in range(count)]
|
||||
with Session(engine) as session:
|
||||
for s in starts:
|
||||
row = TibberPrice(
|
||||
starts_at=s,
|
||||
resolution="QUARTER_HOURLY",
|
||||
energy=0.18,
|
||||
tax=0.065,
|
||||
total=0.245,
|
||||
level="NORMAL",
|
||||
currency="EUR",
|
||||
fetched_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return starts
|
||||
|
||||
|
||||
def _make_cost_periods(engine, count: int = 3, version_id: int | None = None) -> list[datetime]:
|
||||
"""Insert ``count`` EnergyCostPeriod rows starting from one hour ago; return period_start list."""
|
||||
now = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
base = now - timedelta(hours=2)
|
||||
starts = []
|
||||
with Session(engine) as session:
|
||||
for i in range(count):
|
||||
t0 = base + timedelta(minutes=15 * i)
|
||||
period = EnergyCostPeriod(
|
||||
period_start=t0,
|
||||
d1_kwh=0.1 * i,
|
||||
d2_kwh=0.05 * i,
|
||||
r1_kwh=0.0,
|
||||
r2_kwh=0.0,
|
||||
import_cost=0.05 * i,
|
||||
export_revenue=0.0,
|
||||
net_cost=0.05 * i,
|
||||
currency="EUR",
|
||||
pricing={"kind": "manual"},
|
||||
contract_version_id=version_id,
|
||||
degraded=False,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(period)
|
||||
starts.append(t0)
|
||||
session.commit()
|
||||
return starts
|
||||
|
||||
|
||||
def _make_dsmr_reading(engine, recorded_at: datetime | None = None) -> datetime:
|
||||
"""Insert one DsmrReading and return its recorded_at."""
|
||||
now = recorded_at or datetime.now(UTC)
|
||||
with Session(engine) as session:
|
||||
row = DsmrReading(
|
||||
recorded_at=now,
|
||||
source_id=42,
|
||||
payload={"electricity_delivered_1": "100.0", "test": True},
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return now
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices — auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prices_unauthenticated_returns_401(energy_client):
|
||||
client, _, _app = energy_client
|
||||
resp = client.get("/api/energy/prices")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices — no active contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prices_no_contract_returns_empty(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
resp = client.get("/api/energy/prices")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["kind"] is None
|
||||
assert body["points"] == []
|
||||
assert body["tariff"] is None
|
||||
assert "currency" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices — manual contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prices_manual_contract_returns_tariff(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_manual_contract(engine)
|
||||
|
||||
resp = client.get("/api/energy/prices")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert body["kind"] == "manual"
|
||||
assert body["currency"] == "EUR"
|
||||
assert body["points"] == []
|
||||
assert body["tariff"] is not None
|
||||
|
||||
tariff = body["tariff"]
|
||||
# buy_dal = 0.30 + 0.1108 + 0.0 = 0.4108
|
||||
assert abs(tariff["buy_dal"] - 0.4108) < 1e-6, f"buy_dal wrong: {tariff['buy_dal']}"
|
||||
# buy_normal = 0.40 + 0.1108 + 0.0 = 0.5108
|
||||
assert abs(tariff["buy_normal"] - 0.5108) < 1e-6, f"buy_normal wrong: {tariff['buy_normal']}"
|
||||
# sell_dal = 0.10 (no tax added)
|
||||
assert abs(tariff["sell_dal"] - 0.10) < 1e-6
|
||||
# sell_normal = 0.10
|
||||
assert abs(tariff["sell_normal"] - 0.10) < 1e-6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/prices — tibber contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prices_tibber_contract_returns_points(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_tibber_contract(engine)
|
||||
_make_tibber_prices(engine, count=3)
|
||||
|
||||
# Use a wide enough window to include our test prices.
|
||||
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
||||
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert body["kind"] == "tibber"
|
||||
assert body["tariff"] is None
|
||||
assert isinstance(body["points"], list)
|
||||
assert len(body["points"]) == 3
|
||||
|
||||
# Verify ascending order.
|
||||
starts_at_list = [p["starts_at"] for p in body["points"]]
|
||||
assert starts_at_list == sorted(starts_at_list)
|
||||
|
||||
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0
|
||||
for p in body["points"]:
|
||||
assert abs(p["buy"] - 0.245) < 1e-6
|
||||
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
||||
assert p["level"] == "NORMAL"
|
||||
|
||||
|
||||
def test_prices_tibber_limit_caps_results(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_tibber_contract(engine)
|
||||
_make_tibber_prices(engine, count=5)
|
||||
|
||||
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
|
||||
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
||||
resp = client.get(
|
||||
"/api/energy/prices", params={"start": start, "end": end, "limit": 2}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["points"]) <= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs — auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_costs_unauthenticated_returns_401(energy_client):
|
||||
client, _, _app = energy_client
|
||||
resp = client.get("/api/energy/costs")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs — empty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_costs_empty_returns_empty(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
resp = client.get("/api/energy/costs")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["items"] == []
|
||||
assert body["total"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs — with data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_costs_returns_sorted_ascending(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_manual_contract(engine)
|
||||
_make_cost_periods(engine, count=3)
|
||||
|
||||
resp = client.get("/api/energy/costs")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 3
|
||||
|
||||
period_starts = [item["period_start"] for item in body["items"]]
|
||||
assert period_starts == sorted(period_starts), "Items must be ascending by period_start"
|
||||
|
||||
|
||||
def test_costs_limit_caps_results(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_manual_contract(engine)
|
||||
_make_cost_periods(engine, count=5)
|
||||
|
||||
resp = client.get("/api/energy/costs?limit=2")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] <= 2
|
||||
assert len(body["items"]) <= 2
|
||||
|
||||
|
||||
def test_costs_schema_fields_present(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_manual_contract(engine)
|
||||
_make_cost_periods(engine, count=1)
|
||||
|
||||
resp = client.get("/api/energy/costs")
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
for field in (
|
||||
"period_start",
|
||||
"d1_kwh", "d2_kwh", "r1_kwh", "r2_kwh",
|
||||
"import_cost", "export_revenue", "net_cost",
|
||||
"currency", "degraded",
|
||||
):
|
||||
assert field in item, f"Missing field: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary — auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_summary_unauthenticated_returns_401(energy_client):
|
||||
client, _, _app = energy_client
|
||||
resp = client.get("/api/energy/costs/summary")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary — empty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_summary_empty_returns_zeros(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
resp = client.get("/api/energy/costs/summary")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["period_count"] == 0
|
||||
assert body["metered_net"] == 0.0
|
||||
assert "total_payable" in body
|
||||
assert "currency" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/costs/summary — with data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_summary_returns_correct_structure(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
_make_active_manual_contract(engine)
|
||||
_make_cost_periods(engine, count=2)
|
||||
|
||||
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
|
||||
end = datetime.now(UTC).isoformat()
|
||||
resp = client.get(
|
||||
"/api/energy/costs/summary", params={"start": start, "end": end}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
for field in (
|
||||
"currency",
|
||||
"metered_import",
|
||||
"metered_export",
|
||||
"metered_net",
|
||||
"fixed_costs",
|
||||
"credits",
|
||||
"total_payable",
|
||||
"period_count",
|
||||
"degraded_count",
|
||||
"days",
|
||||
):
|
||||
assert field in body, f"Missing field in summary: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest — auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dsmr_latest_unauthenticated_returns_401(energy_client):
|
||||
client, _, _app = energy_client
|
||||
resp = client.get("/api/energy/dsmr/latest")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest — no data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dsmr_latest_no_data_returns_not_found(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
resp = client.get("/api/energy/dsmr/latest")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["found"] is False
|
||||
assert body["recorded_at"] is None
|
||||
assert body["payload"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/energy/dsmr/latest — with data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dsmr_latest_returns_most_recent(energy_client):
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
|
||||
# Insert two readings; the more recent one should be returned.
|
||||
older = datetime.now(UTC) - timedelta(minutes=5)
|
||||
newer = datetime.now(UTC) - timedelta(seconds=30)
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(DsmrReading(recorded_at=older, source_id=1, payload={"order": "first"}))
|
||||
session.add(DsmrReading(recorded_at=newer, source_id=2, payload={"order": "second"}))
|
||||
session.commit()
|
||||
|
||||
resp = client.get("/api/energy/dsmr/latest")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["found"] is True
|
||||
assert body["payload"]["order"] == "second"
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute — auth / CSRF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recompute_unauthenticated_returns_401(energy_client):
|
||||
client, _, _app = energy_client
|
||||
now = datetime.now(UTC)
|
||||
resp = client.post(
|
||||
"/api/energy/costs/recompute",
|
||||
params={"start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_recompute_missing_csrf_returns_403(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
now = datetime.now(UTC)
|
||||
resp = client.post(
|
||||
"/api/energy/costs/recompute",
|
||||
params={"start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute — validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recompute_end_before_start_returns_422(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
now = datetime.now(UTC)
|
||||
resp = client.post(
|
||||
"/api/energy/costs/recompute",
|
||||
params={
|
||||
"start": now.isoformat(),
|
||||
"end": (now - timedelta(hours=1)).isoformat(),
|
||||
},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_recompute_window_too_large_returns_422(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
now = datetime.now(UTC)
|
||||
resp = client.post(
|
||||
"/api/energy/costs/recompute",
|
||||
params={
|
||||
"start": (now - timedelta(days=400)).isoformat(),
|
||||
"end": now.isoformat(),
|
||||
},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/costs/recompute — success (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recompute_returns_count(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
# A window with no data is fine — recompute returns 0 but is not an error.
|
||||
now = datetime.now(UTC)
|
||||
params = {
|
||||
"start": (now - timedelta(hours=1)).isoformat(),
|
||||
"end": now.isoformat(),
|
||||
}
|
||||
resp = client.post(
|
||||
"/api/energy/costs/recompute",
|
||||
params=params,
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "recomputed" in body
|
||||
assert isinstance(body["recomputed"], int)
|
||||
|
||||
|
||||
def test_recompute_is_idempotent(energy_client):
|
||||
"""Calling recompute twice with the same window must produce the same result."""
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
now = datetime.now(UTC)
|
||||
params = {
|
||||
"start": (now - timedelta(hours=1)).isoformat(),
|
||||
"end": now.isoformat(),
|
||||
}
|
||||
headers = {"X-CSRF-Token": _CSRF}
|
||||
|
||||
resp1 = client.post("/api/energy/costs/recompute", params=params, headers=headers)
|
||||
resp2 = client.post("/api/energy/costs/recompute", params=params, headers=headers)
|
||||
assert resp1.status_code == 200
|
||||
assert resp2.status_code == 200
|
||||
assert resp1.json()["recomputed"] == resp2.json()["recomputed"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test — auth / CSRF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tibber_test_unauthenticated_returns_401(energy_client):
|
||||
client, _, _app = energy_client
|
||||
resp = client.post("/api/energy/tibber/test")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_tibber_test_missing_csrf_returns_403(energy_client):
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
resp = client.post("/api/energy/tibber/test")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test — token empty → 400 config-error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tibber_test_no_token_returns_config_error(energy_client):
|
||||
"""When tibber_api_token is empty, expect 400 config-error regardless of CSRF."""
|
||||
client, _, _app = energy_client
|
||||
_login(client)
|
||||
resp = client.post(
|
||||
"/api/energy/tibber/test",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
# Token is empty by default (no config in test DB).
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["result"] == "config-error"
|
||||
assert "message" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test — mock success
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tibber_test_success(energy_client):
|
||||
"""Token stored in DB app_config + mocked HTTP → 200 success with price data."""
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
|
||||
# Set TIBBER_API_TOKEN in the app_config table so get_app_settings returns it.
|
||||
_set_app_config(engine, "TIBBER_API_TOKEN", "fake-tibber-token")
|
||||
|
||||
mock_price = PricePoint(
|
||||
starts_at=datetime(2026, 6, 23, 14, 0, 0, tzinfo=UTC),
|
||||
total=0.245,
|
||||
energy=0.18,
|
||||
tax=0.065,
|
||||
currency="EUR",
|
||||
level="NORMAL",
|
||||
resolution="QUARTER_HOURLY",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.api.routes.api.energy.fetch_current_price",
|
||||
return_value=mock_price,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/energy/tibber/test",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["result"] == "success"
|
||||
assert body["price"] is not None
|
||||
assert body["price"]["total"] == 0.245
|
||||
assert body["price"]["currency"] == "EUR"
|
||||
assert body["price"]["level"] == "NORMAL"
|
||||
|
||||
# Token must NOT appear anywhere in the response.
|
||||
assert "fake-tibber-token" not in str(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test — mock TibberAuthError → 502
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tibber_test_auth_error_returns_502(energy_client):
|
||||
"""Bad token stored in DB + mocked TibberAuthError → 502 failed."""
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
|
||||
_set_app_config(engine, "TIBBER_API_TOKEN", "bad-tibber-token")
|
||||
|
||||
with patch(
|
||||
"app.api.routes.api.energy.fetch_current_price",
|
||||
side_effect=TibberAuthError("auth failed"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/energy/tibber/test",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 502
|
||||
body = resp.json()
|
||||
assert body["result"] == "failed"
|
||||
assert "bad-tibber-token" not in str(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test — mock TibberError → 502
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tibber_test_network_error_returns_502(energy_client):
|
||||
"""Token in DB + mocked TibberError (network timeout) → 502 failed."""
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
|
||||
_set_app_config(engine, "TIBBER_API_TOKEN", "some-tibber-token")
|
||||
|
||||
with patch(
|
||||
"app.api.routes.api.energy.fetch_current_price",
|
||||
side_effect=TibberError("network timeout"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/energy/tibber/test",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 502
|
||||
body = resp.json()
|
||||
assert body["result"] == "failed"
|
||||
assert "some-tibber-token" not in str(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/energy/tibber/test — token never in response (extra check)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tibber_test_token_not_in_response(energy_client):
|
||||
"""Even in the success response, the Tibber token must not appear."""
|
||||
client, engine, _app = energy_client
|
||||
_login(client)
|
||||
|
||||
secret_token = "super-secret-tibber-token-xyz"
|
||||
_set_app_config(engine, "TIBBER_API_TOKEN", secret_token)
|
||||
|
||||
mock_price = PricePoint(
|
||||
starts_at=datetime(2026, 6, 23, 14, 0, 0, tzinfo=UTC),
|
||||
total=0.30,
|
||||
energy=0.22,
|
||||
tax=0.08,
|
||||
currency="EUR",
|
||||
level="CHEAP",
|
||||
resolution="QUARTER_HOURLY",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.api.routes.api.energy.fetch_current_price",
|
||||
return_value=mock_price,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/energy/tibber/test",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert secret_token not in resp.text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user