diff --git a/app/api/routes/api/energy_contracts.py b/app/api/routes/api/energy_contracts.py new file mode 100644 index 0000000..924e5ee --- /dev/null +++ b/app/api/routes/api/energy_contracts.py @@ -0,0 +1,305 @@ +"""EnergyContract CRUD, versioning, and pricing-profile API (M6-T04). + +All endpoints are under /api/energy, require an authenticated session, and +write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token +header. + +There is deliberately no DELETE endpoint for contracts: the FK RESTRICT +constraint on energy_contract_version.contract_id and +energy_cost_period.contract_version_id prevents accidental deletion of +contracts that have billing history. Users should instead deactivate a +contract (PATCH active=false) to stop it from being used. + +Route ordering note +------------------- +GET /api/energy/profiles and GET /api/energy/contracts/{id} are on separate +path prefixes (/energy/profiles vs /energy/contracts/{id}) so there is no +ambiguity even without extra ordering gymnastics. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.dependencies import get_db +from app.integrations.pricing.profiles import ( + ProfileNotFoundError, + ProfileValidationError, + list_profiles, +) +from app.schemas.energy_contract import ( + ContractCreate, + ContractDetailResponse, + ContractListResponse, + ContractPatch, + ContractResponse, + ContractVersionResponse, + ProfilesResponse, + VersionCreate, +) +from app.services.auth import AuthenticatedSession +from app.services.contracts import ( + ContractVersionError, + activate_contract, + add_version, + create_contract, + deactivate_contract, + get_contract_or_none, + list_contracts, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/energy", tags=["api-energy-contracts"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_contract_or_404(db: Session, contract_id: int): + """Return the contract with the given id or raise 404.""" + contract = get_contract_or_none(db, contract_id) + if contract is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Energy contract {contract_id!r} not found.", + ) + return contract + + +def _contract_detail(db: Session, contract) -> ContractDetailResponse: + """Build a ContractDetailResponse for *contract*, loading versions.""" + # Eager-load versions ordered by effective_from for consistent display. + from sqlalchemy import select + + from app.models.energy import EnergyContractVersion + + versions = list( + db.execute( + select(EnergyContractVersion) + .where(EnergyContractVersion.contract_id == contract.id) + .order_by(EnergyContractVersion.effective_from) + ) + .scalars() + .all() + ) + + version_schemas = [ContractVersionResponse.model_validate(v) for v in versions] + return ContractDetailResponse( + id=contract.id, + name=contract.name, + kind=contract.kind, + active=contract.active, + currency=contract.currency, + created_at=contract.created_at, + updated_at=contract.updated_at, + versions=version_schemas, + ) + + +def _raise_422_for_profile_error(exc: Exception) -> Any: + """Convert a ProfileValidationError or ProfileNotFoundError into HTTP 422.""" + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) + + +# --------------------------------------------------------------------------- +# GET /api/energy/profiles +# --------------------------------------------------------------------------- + + +@router.get("/profiles", response_model=ProfilesResponse) +def get_profiles( + _auth: AuthenticatedSession = Depends(require_session), +) -> ProfilesResponse: + """List all available pricing profile structures. + + Returns the full profile structure for each supported contract kind + (``manual`` and ``tibber``). The front-end uses this to dynamically + render the correct fields and labels for the contract creation/editing form. + """ + return ProfilesResponse(profiles=list_profiles()) + + +# --------------------------------------------------------------------------- +# GET /api/energy/contracts +# --------------------------------------------------------------------------- + + +@router.get("/contracts", response_model=ContractListResponse) +def list_energy_contracts( + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> ContractListResponse: + """List all energy contracts with their active status. + + Returns a flat list (no embedded version history); use + GET /api/energy/contracts/{id} to fetch the full version history for a + specific contract. + """ + contracts = list_contracts(db) + items = [ContractResponse.model_validate(c) for c in contracts] + return ContractListResponse(items=items, total=len(items)) + + +# --------------------------------------------------------------------------- +# POST /api/energy/contracts +# --------------------------------------------------------------------------- + + +@router.post( + "/contracts", + response_model=ContractDetailResponse, + status_code=status.HTTP_201_CREATED, +) +def create_energy_contract( + body: ContractCreate, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> ContractDetailResponse: + """Create a new energy contract with an initial pricing version. + + The ``values`` dict is validated against the YAML profile for the given + ``kind``; non-conforming values result in 422 Unprocessable Entity. + The new contract is created with ``active=False``; use + PATCH /api/energy/contracts/{id} with ``active=true`` to activate it. + """ + effective_from = body.effective_from or datetime.now(UTC) + + try: + contract = create_contract( + db, + name=body.name, + kind=body.kind, + currency=body.currency, + values=body.values, + effective_from=effective_from, + ) + except (ProfileNotFoundError, ProfileValidationError) as exc: + _raise_422_for_profile_error(exc) + + db.commit() + db.refresh(contract) + logger.info("Created energy contract id=%d name=%r kind=%s", contract.id, contract.name, contract.kind) + return _contract_detail(db, contract) + + +# --------------------------------------------------------------------------- +# GET /api/energy/contracts/{id} +# --------------------------------------------------------------------------- + + +@router.get("/contracts/{contract_id}", response_model=ContractDetailResponse) +def get_energy_contract( + contract_id: int, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> ContractDetailResponse: + """Return a single energy contract with its full version history. + + Versions are ordered by ``effective_from`` ascending so the caller can + easily inspect the pricing timeline. + """ + contract = _get_contract_or_404(db, contract_id) + return _contract_detail(db, contract) + + +# --------------------------------------------------------------------------- +# PATCH /api/energy/contracts/{id} +# --------------------------------------------------------------------------- + + +@router.patch("/contracts/{contract_id}", response_model=ContractDetailResponse) +def patch_energy_contract( + contract_id: int, + body: ContractPatch, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> ContractDetailResponse: + """Partially update a contract: rename or change activation status. + + - ``name``: updates the human-readable label. + - ``active=true``: activates this contract (all others are deactivated). + - ``active=false``: deactivates this contract (no effect on others). + + At most one contract may be active at any time; the service layer enforces + mutual exclusion. + """ + contract = _get_contract_or_404(db, contract_id) + + if body.name is not None: + contract.name = body.name + contract.updated_at = datetime.now(UTC) + + if body.active is True: + activate_contract(db, contract) + elif body.active is False: + deactivate_contract(db, contract) + + db.commit() + db.refresh(contract) + return _contract_detail(db, contract) + + +# --------------------------------------------------------------------------- +# POST /api/energy/contracts/{id}/versions +# --------------------------------------------------------------------------- + + +@router.post( + "/contracts/{contract_id}/versions", + response_model=ContractDetailResponse, + status_code=status.HTTP_201_CREATED, +) +def add_contract_version( + contract_id: int, + body: VersionCreate, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> ContractDetailResponse: + """Add a new pricing version to an existing contract. + + This is how price changes are recorded: the current open version is + automatically closed (its ``effective_to`` is set to ``body.effective_from``) + and a new version is created starting at ``body.effective_from``. + + The ``values`` dict must conform to the contract's pricing profile. + Non-conforming values return 422. If ``effective_from`` is not strictly + after the previous version's ``effective_from``, 422 is returned without + writing any rows. + + Historical versions are never modified; this endpoint is append-only. + """ + contract = _get_contract_or_404(db, contract_id) + + try: + add_version( + db, + contract, + effective_from=body.effective_from, + values=body.values, + ) + except ContractVersionError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) + except (ProfileNotFoundError, ProfileValidationError) as exc: + _raise_422_for_profile_error(exc) + + db.commit() + db.refresh(contract) + return _contract_detail(db, contract) diff --git a/app/main.py b/app/main.py index ffa919c..f507fbc 100644 --- a/app/main.py +++ b/app/main.py @@ -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_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 from app.api.routes.api.session import router as api_session_router @@ -188,6 +189,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_contracts_router) app.include_router(api_expose_router) app.include_router(api_modbus_router) app.include_router(api_session_router) diff --git a/app/schemas/energy_contract.py b/app/schemas/energy_contract.py new file mode 100644 index 0000000..8dd63d4 --- /dev/null +++ b/app/schemas/energy_contract.py @@ -0,0 +1,148 @@ +"""Pydantic schemas for the EnergyContract CRUD + versioning API (M6-T04). + +Schema hierarchy +---------------- +ContractVersionResponse — single version row (id, dates, values, created_at) +ContractResponse — contract head (id, name, kind, active, currency, timestamps) +ContractDetailResponse — contract head + embedded versions list (for GET{id}) +ContractListResponse — paginated list of ContractResponse items +ContractCreate — POST /api/energy/contracts body +ContractPatch — PATCH /api/energy/contracts/{id} body (all fields optional) +VersionCreate — POST /api/energy/contracts/{id}/versions body +ProfilesResponse — GET /api/energy/profiles response +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Version schemas +# --------------------------------------------------------------------------- + + +class ContractVersionResponse(BaseModel): + """Response schema for a single EnergyContractVersion row.""" + + id: int + effective_from: datetime + effective_to: datetime | None + values: dict[str, Any] + created_at: datetime + + model_config = {"from_attributes": True} + + +# --------------------------------------------------------------------------- +# Contract schemas +# --------------------------------------------------------------------------- + + +class ContractResponse(BaseModel): + """Response schema for a single EnergyContract (without embedded versions). + + Used for list responses where embedding all versions would be expensive. + """ + + id: int + name: str + kind: str + active: bool + currency: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ContractDetailResponse(BaseModel): + """Response schema for a single EnergyContract with full version history. + + Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH + operations where the caller needs to see all version data. + """ + + id: int + name: str + kind: str + active: bool + currency: str + created_at: datetime + updated_at: datetime + versions: list[ContractVersionResponse] + + model_config = {"from_attributes": True} + + +class ContractListResponse(BaseModel): + """Response schema for GET /api/energy/contracts.""" + + items: list[ContractResponse] + total: int + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + + +class ContractCreate(BaseModel): + """Request body for POST /api/energy/contracts. + + ``effective_from`` defaults to the current UTC time if not provided, + giving the first version an open-ended start from "now". + ``kind`` is validated at the application layer against the profile registry; + clients should send ``"manual"`` or ``"tibber"``. + """ + + name: str = Field(..., min_length=1, max_length=255) + kind: str = Field(..., min_length=1, max_length=32) + currency: str = Field(default="EUR", min_length=1, max_length=8) + values: dict[str, Any] + effective_from: datetime | None = Field( + default=None, + description=( + "UTC datetime from which the first pricing version is effective. " + "Defaults to the current UTC time when omitted." + ), + ) + + +class ContractPatch(BaseModel): + """Request body for PATCH /api/energy/contracts/{id}. + + All fields are optional. Sending ``active=true`` activates this contract + (deactivating all others); ``active=false`` deactivates it without affecting + other contracts. + """ + + name: str | None = Field(default=None, min_length=1, max_length=255) + active: bool | None = None + + +class VersionCreate(BaseModel): + """Request body for POST /api/energy/contracts/{id}/versions.""" + + effective_from: datetime + values: dict[str, Any] + + +# --------------------------------------------------------------------------- +# Profile response schemas +# --------------------------------------------------------------------------- + + +class ProfilesResponse(BaseModel): + """Response schema for GET /api/energy/profiles. + + ``profiles`` is a list of raw profile dicts as produced by + ``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum + ``kind`` and ``label``; the full nested structure allows the front-end to + render a type-appropriate form for each pricing profile. + """ + + profiles: list[dict[str, Any]] diff --git a/app/services/contracts.py b/app/services/contracts.py new file mode 100644 index 0000000..b0f7006 --- /dev/null +++ b/app/services/contracts.py @@ -0,0 +1,338 @@ +"""Service layer for EnergyContract CRUD, versioning, and activation. + +All functions accept an explicit SQLAlchemy Session; callers are responsible +for committing or rolling back the transaction. + +Design decisions +---------------- +- ``create_contract``: validates values against the pricing profile *before* + writing any rows; raises ``ProfileValidationError`` on non-compliance. +- ``add_version``: append-only; closes the previous open version by + setting its ``effective_to`` to the new version's ``effective_from``; raises + ``ContractVersionError`` if the new date is strictly earlier than the previous + version's ``effective_from``. +- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active`` + to False, then sets the given contract's ``active`` to True. +- ``active_contract_version_at``: returns the single version of the currently + active contract that covers *ts* (``effective_from ≤ ts < effective_to``, + or open-ended when ``effective_to`` is None). + +SQLite timezone note +-------------------- +SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on +read-back they come out as **timezone-naive** datetimes. Wherever this code +compares timestamps from the DB against timezone-aware values (e.g. from +Pydantic or ``datetime.now(UTC)``), it calls ``_as_utc()`` to make both sides +comparable without tripping on "offset-naive vs offset-aware" TypeErrors. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.integrations.pricing.profiles import validate_values +from app.models.energy import EnergyContract, EnergyContractVersion + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _as_utc(dt: datetime) -> datetime: + """Return *dt* as a timezone-aware UTC datetime. + + SQLite's DateTime(timezone=True) column type stores datetimes as naive UTC + strings and gives them back as naive datetimes on read. This helper + re-attaches the UTC timezone info when it is missing, making cross-origin + comparisons safe. + """ + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + +# --------------------------------------------------------------------------- +# Custom exception +# --------------------------------------------------------------------------- + + +class ContractVersionError(ValueError): + """Raised when a new contract version has an invalid effective date. + + Specifically: the new version's ``effective_from`` must be greater than or + equal to the previous open version's ``effective_from``. Allowing equal + timestamps would cause ambiguous overlap; the service therefore also rejects + strictly-equal values (same second) to avoid silent data loss. + """ + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract | None: + """Return the contract with the given id, or None if not found.""" + return session.execute( + select(EnergyContract).where(EnergyContract.id == contract_id) + ).scalar_one_or_none() + + +def list_contracts(session: Session) -> list[EnergyContract]: + """Return all contracts ordered by id (ascending).""" + return list( + session.execute(select(EnergyContract).order_by(EnergyContract.id)).scalars().all() + ) + + +def _open_version(session: Session, contract: EnergyContract) -> EnergyContractVersion | None: + """Return the current open version (effective_to IS NULL) for *contract*, or None.""" + return session.execute( + select(EnergyContractVersion) + .where( + EnergyContractVersion.contract_id == contract.id, + EnergyContractVersion.effective_to.is_(None), + ) + .order_by(EnergyContractVersion.effective_from.desc()) + .limit(1) + ).scalar_one_or_none() + + +# --------------------------------------------------------------------------- +# Core service functions +# --------------------------------------------------------------------------- + + +def create_contract( + session: Session, + *, + name: str, + kind: str, + currency: str = "EUR", + values: dict[str, Any], + effective_from: datetime, +) -> EnergyContract: + """Create a new energy contract with its first pricing version. + + Parameters + ---------- + session: + Active SQLAlchemy session. Caller must commit after this returns. + name: + Human-readable label for the contract. + kind: + Pricing strategy identifier (``"manual"`` or ``"tibber"``). + currency: + ISO 4217 currency code (default ``"EUR"``). + values: + Pricing values dict conforming to the named profile's structure. + Validated via ``validate_values(kind, values)`` before any writes. + effective_from: + UTC datetime at which the first pricing version takes effect. + + Returns + ------- + EnergyContract + The newly created contract (not yet committed). + + Raises + ------ + ProfileNotFoundError + If no YAML profile exists for *kind*. + ProfileValidationError + If *values* does not conform to the profile structure. + """ + # Validate (and fill defaults) before any DB write. + filled_values = validate_values(kind, values) + + now = datetime.now(UTC) + contract = EnergyContract( + name=name, + kind=kind, + currency=currency, + active=False, # New contracts are inactive; caller must explicitly activate. + created_at=now, + updated_at=now, + ) + session.add(contract) + session.flush() # Assign contract.id so we can reference it in the version FK. + + version = EnergyContractVersion( + contract_id=contract.id, + effective_from=effective_from, + effective_to=None, + values=filled_values, + created_at=now, + ) + session.add(version) + + logger.info("Created energy contract %r (kind=%s, id=%d)", name, kind, contract.id) + return contract + + +def add_version( + session: Session, + contract: EnergyContract, + *, + effective_from: datetime, + values: dict[str, Any], +) -> EnergyContractVersion: + """Add a new pricing version to an existing contract (append-only). + + The previous open version's ``effective_to`` is automatically set to the + new version's ``effective_from`` (version closure), ensuring there is never + a gap or overlap between consecutive versions. + + The new ``effective_from`` **must be strictly greater than** the previous + open version's ``effective_from``. Equal timestamps are rejected because + they would produce two versions starting at the same instant, making it + impossible to determine which is current. If this constraint is not met, + ``ContractVersionError`` is raised and **no rows are written**. + + Parameters + ---------- + session: + Active SQLAlchemy session. Caller must commit after this returns. + contract: + The parent ``EnergyContract`` to append a version to. + effective_from: + UTC datetime at which this version's pricing takes effect. + values: + New pricing values dict conforming to the contract's profile. + + Returns + ------- + EnergyContractVersion + The newly created version (not yet committed). + + Raises + ------ + ContractVersionError + If *effective_from* is not strictly after the previous open version's + ``effective_from``. + ProfileNotFoundError + If no YAML profile exists for the contract's kind. + ProfileValidationError + If *values* does not conform to the profile structure. + """ + # Validate (and fill defaults) before any DB write. + filled_values = validate_values(contract.kind, values) + + prev = _open_version(session, contract) + if prev is not None: + # Enforce strictly-after constraint to prevent ambiguous overlap. + # Use _as_utc() on both sides: the incoming value may be tz-aware + # while the DB-read value is tz-naive (SQLite limitation). + if _as_utc(effective_from) <= _as_utc(prev.effective_from): + raise ContractVersionError( + f"New version effective_from ({effective_from.isoformat()}) must be strictly " + f"after the previous open version's effective_from " + f"({prev.effective_from.isoformat()})." + ) + # Close the previous open version. + prev.effective_to = effective_from + + now = datetime.now(UTC) + new_version = EnergyContractVersion( + contract_id=contract.id, + effective_from=effective_from, + effective_to=None, + values=filled_values, + created_at=now, + ) + session.add(new_version) + + logger.info( + "Added version to contract id=%d (kind=%s, effective_from=%s)", + contract.id, + contract.kind, + effective_from.isoformat(), + ) + return new_version + + +def activate_contract(session: Session, contract: EnergyContract) -> None: + """Activate a contract with mutual exclusion. + + Sets every other contract's ``active`` flag to False, then sets the given + contract's ``active`` to True. This guarantees at most one active contract + at any time. + + Caller must commit after this returns. + """ + # Deactivate all contracts (including the target; we re-activate below). + for other in session.execute(select(EnergyContract)).scalars().all(): + other.active = False + contract.active = True + contract.updated_at = datetime.now(UTC) + logger.info("Activated contract %r (id=%d)", contract.name, contract.id) + + +def deactivate_contract(session: Session, contract: EnergyContract) -> None: + """Deactivate a contract without touching other contracts. + + Caller must commit after this returns. + """ + contract.active = False + contract.updated_at = datetime.now(UTC) + logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id) + + +def active_contract_version_at( + session: Session, ts: datetime +) -> EnergyContractVersion | None: + """Return the active contract's version that covers *ts*. + + A version covers *ts* when: + ``effective_from ≤ ts`` AND (``effective_to IS NULL`` OR ``ts < effective_to``) + + Returns None when: + - There is no active contract. + - The active contract has no version covering *ts*. + + Parameters + ---------- + session: + Active SQLAlchemy session (read-only usage). + ts: + UTC datetime to look up. + + Returns + ------- + EnergyContractVersion | None + """ + active = session.execute( + select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1) + ).scalar_one_or_none() + + if active is None: + return None + + # Build a query for all versions of the active contract covering ts. + stmt = ( + select(EnergyContractVersion) + .where( + EnergyContractVersion.contract_id == active.id, + EnergyContractVersion.effective_from <= ts, + ) + .order_by(EnergyContractVersion.effective_from.desc()) + .limit(1) + ) + version = session.execute(stmt).scalar_one_or_none() + + if version is None: + return None + + # Exclude versions whose effective window has already closed before ts. + if version.effective_to is not None and _as_utc(ts) >= _as_utc(version.effective_to): + return None + + return version diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index 0b4e2f0..b0a5b54 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -333,7 +333,7 @@ Phase D(API + 前端) - **Reviewer checklist**: profile 纯结构、无具体数值;策略用 Decimal;进出口分开(不相抵);tibber 价匹配用 `starts_at ≤ t0` 最近一条;无 OOP 过度抽象(数据+函数,仿 M5 profiles.py)。 ### M6-T04 — EnergyContract CRUD + 版本 + 校验(API) -- **Status**: `todo` · **Depends**: M6-T01, M6-T03 +- **Status**: `done` · **Depends**: M6-T01, M6-T03 - **Context**: 合同增删改、版本(改价加新版本、生效日期)、激活(一次一个)、按 profile 校验数值。 - **Files**: `create app/api/routes/api/energy_contracts.py`、`app/schemas/energy_contract.py`、`app/services/contracts.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy_contracts.py` - **Steps**: diff --git a/openapi/openapi.json b/openapi/openapi.json index c8d7c6f..582b06f 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -695,6 +695,287 @@ } } }, + "/api/energy/profiles": { + "get": { + "tags": [ + "api-energy-contracts" + ], + "summary": "Get Profiles", + "description": "List all available pricing profile structures.\n\nReturns the full profile structure for each supported contract kind\n(``manual`` and ``tibber``). The front-end uses this to dynamically\nrender the correct fields and labels for the contract creation/editing form.", + "operationId": "get_profiles_api_energy_profiles_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfilesResponse" + } + } + } + } + } + } + }, + "/api/energy/contracts": { + "get": { + "tags": [ + "api-energy-contracts" + ], + "summary": "List Energy Contracts", + "description": "List all energy contracts with their active status.\n\nReturns a flat list (no embedded version history); use\nGET /api/energy/contracts/{id} to fetch the full version history for a\nspecific contract.", + "operationId": "list_energy_contracts_api_energy_contracts_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-energy-contracts" + ], + "summary": "Create Energy Contract", + "description": "Create a new energy contract with an initial pricing version.\n\nThe ``values`` dict is validated against the YAML profile for the given\n``kind``; non-conforming values result in 422 Unprocessable Entity.\nThe new contract is created with ``active=False``; use\nPATCH /api/energy/contracts/{id} with ``active=true`` to activate it.", + "operationId": "create_energy_contract_api_energy_contracts_post", + "parameters": [ + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractDetailResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/contracts/{contract_id}": { + "get": { + "tags": [ + "api-energy-contracts" + ], + "summary": "Get Energy Contract", + "description": "Return a single energy contract with its full version history.\n\nVersions are ordered by ``effective_from`` ascending so the caller can\neasily inspect the pricing timeline.", + "operationId": "get_energy_contract_api_energy_contracts__contract_id__get", + "parameters": [ + { + "name": "contract_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Contract Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractDetailResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "api-energy-contracts" + ], + "summary": "Patch Energy Contract", + "description": "Partially update a contract: rename or change activation status.\n\n- ``name``: updates the human-readable label.\n- ``active=true``: activates this contract (all others are deactivated).\n- ``active=false``: deactivates this contract (no effect on others).\n\nAt most one contract may be active at any time; the service layer enforces\nmutual exclusion.", + "operationId": "patch_energy_contract_api_energy_contracts__contract_id__patch", + "parameters": [ + { + "name": "contract_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Contract Id" + } + }, + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractPatch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractDetailResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/contracts/{contract_id}/versions": { + "post": { + "tags": [ + "api-energy-contracts" + ], + "summary": "Add Contract Version", + "description": "Add a new pricing version to an existing contract.\n\nThis is how price changes are recorded: the current open version is\nautomatically closed (its ``effective_to`` is set to ``body.effective_from``)\nand a new version is created starting at ``body.effective_from``.\n\nThe ``values`` dict must conform to the contract's pricing profile.\nNon-conforming values return 422. If ``effective_from`` is not strictly\nafter the previous version's ``effective_from``, 422 is returned without\nwriting any rows.\n\nHistorical versions are never modified; this endpoint is append-only.", + "operationId": "add_contract_version_api_energy_contracts__contract_id__versions_post", + "parameters": [ + { + "name": "contract_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Contract Id" + } + }, + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContractDetailResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/expose": { "get": { "tags": [ @@ -1933,6 +2214,253 @@ ], "title": "ConfigUpdateResponse" }, + "ContractCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "maxLength": 32, + "minLength": 1, + "title": "Kind" + }, + "currency": { + "type": "string", + "maxLength": 8, + "minLength": 1, + "title": "Currency", + "default": "EUR" + }, + "values": { + "additionalProperties": true, + "type": "object", + "title": "Values" + }, + "effective_from": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Effective From", + "description": "UTC datetime from which the first pricing version is effective. Defaults to the current UTC time when omitted." + } + }, + "type": "object", + "required": [ + "name", + "kind", + "values" + ], + "title": "ContractCreate", + "description": "Request body for POST /api/energy/contracts.\n\n``effective_from`` defaults to the current UTC time if not provided,\ngiving the first version an open-ended start from \"now\".\n``kind`` is validated at the application layer against the profile registry;\nclients should send ``\"manual\"`` or ``\"tibber\"``." + }, + "ContractDetailResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "active": { + "type": "boolean", + "title": "Active" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + }, + "versions": { + "items": { + "$ref": "#/components/schemas/ContractVersionResponse" + }, + "type": "array", + "title": "Versions" + } + }, + "type": "object", + "required": [ + "id", + "name", + "kind", + "active", + "currency", + "created_at", + "updated_at", + "versions" + ], + "title": "ContractDetailResponse", + "description": "Response schema for a single EnergyContract with full version history.\n\nReturned by GET /api/energy/contracts/{id} and by successful POST / PATCH\noperations where the caller needs to see all version data." + }, + "ContractListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ContractResponse" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "ContractListResponse", + "description": "Response schema for GET /api/energy/contracts." + }, + "ContractPatch": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active" + } + }, + "type": "object", + "title": "ContractPatch", + "description": "Request body for PATCH /api/energy/contracts/{id}.\n\nAll fields are optional. Sending ``active=true`` activates this contract\n(deactivating all others); ``active=false`` deactivates it without affecting\nother contracts." + }, + "ContractResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "active": { + "type": "boolean", + "title": "Active" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "kind", + "active", + "currency", + "created_at", + "updated_at" + ], + "title": "ContractResponse", + "description": "Response schema for a single EnergyContract (without embedded versions).\n\nUsed for list responses where embedding all versions would be expensive." + }, + "ContractVersionResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "effective_from": { + "type": "string", + "format": "date-time", + "title": "Effective From" + }, + "effective_to": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Effective To" + }, + "values": { + "additionalProperties": true, + "type": "object", + "title": "Values" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "effective_from", + "effective_to", + "values", + "created_at" + ], + "title": "ContractVersionResponse", + "description": "Response schema for a single EnergyContractVersion row." + }, "DeviceInfoSchema": { "properties": { "identifiers": { @@ -2868,6 +3396,24 @@ "title": "ProfileSummary", "description": "One entry in the GET /api/modbus/profiles response." }, + "ProfilesResponse": { + "properties": { + "profiles": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Profiles" + } + }, + "type": "object", + "required": [ + "profiles" + ], + "title": "ProfilesResponse", + "description": "Response schema for GET /api/energy/profiles.\n\n``profiles`` is a list of raw profile dicts as produced by\n``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum\n``kind`` and ``label``; the full nested structure allows the front-end to\nrender a type-appropriate form for each pricing profile." + }, "PublicIPCheckResponse": { "properties": { "status": { @@ -3257,6 +3803,27 @@ "type" ], "title": "ValidationError" + }, + "VersionCreate": { + "properties": { + "effective_from": { + "type": "string", + "format": "date-time", + "title": "Effective From" + }, + "values": { + "additionalProperties": true, + "type": "object", + "title": "Values" + } + }, + "type": "object", + "required": [ + "effective_from", + "values" + ], + "title": "VersionCreate", + "description": "Request body for POST /api/energy/contracts/{id}/versions." } } } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index d20e2b0..a11277e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -516,6 +516,235 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/energy/profiles: + get: + tags: + - api-energy-contracts + summary: Get Profiles + description: 'List all available pricing profile structures. + + + Returns the full profile structure for each supported contract kind + + (``manual`` and ``tibber``). The front-end uses this to dynamically + + render the correct fields and labels for the contract creation/editing form.' + operationId: get_profiles_api_energy_profiles_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ProfilesResponse' + /api/energy/contracts: + get: + tags: + - api-energy-contracts + summary: List Energy Contracts + description: 'List all energy contracts with their active status. + + + Returns a flat list (no embedded version history); use + + GET /api/energy/contracts/{id} to fetch the full version history for a + + specific contract.' + operationId: list_energy_contracts_api_energy_contracts_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ContractListResponse' + post: + tags: + - api-energy-contracts + summary: Create Energy Contract + description: 'Create a new energy contract with an initial pricing version. + + + The ``values`` dict is validated against the YAML profile for the given + + ``kind``; non-conforming values result in 422 Unprocessable Entity. + + The new contract is created with ``active=False``; use + + PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.' + operationId: create_energy_contract_api_energy_contracts_post + parameters: + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContractCreate' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ContractDetailResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/contracts/{contract_id}: + get: + tags: + - api-energy-contracts + summary: Get Energy Contract + description: 'Return a single energy contract with its full version history. + + + Versions are ordered by ``effective_from`` ascending so the caller can + + easily inspect the pricing timeline.' + operationId: get_energy_contract_api_energy_contracts__contract_id__get + parameters: + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ContractDetailResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + patch: + tags: + - api-energy-contracts + summary: Patch Energy Contract + description: 'Partially update a contract: rename or change activation status. + + + - ``name``: updates the human-readable label. + + - ``active=true``: activates this contract (all others are deactivated). + + - ``active=false``: deactivates this contract (no effect on others). + + + At most one contract may be active at any time; the service layer enforces + + mutual exclusion.' + operationId: patch_energy_contract_api_energy_contracts__contract_id__patch + parameters: + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContractPatch' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ContractDetailResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/contracts/{contract_id}/versions: + post: + tags: + - api-energy-contracts + summary: Add Contract Version + description: 'Add a new pricing version to an existing contract. + + + This is how price changes are recorded: the current open version is + + automatically closed (its ``effective_to`` is set to ``body.effective_from``) + + and a new version is created starting at ``body.effective_from``. + + + The ``values`` dict must conform to the contract''s pricing profile. + + Non-conforming values return 422. If ``effective_from`` is not strictly + + after the previous version''s ``effective_from``, 422 is returned without + + writing any rows. + + + Historical versions are never modified; this endpoint is append-only.' + operationId: add_contract_version_api_energy_contracts__contract_id__versions_post + parameters: + - name: contract_id + in: path + required: true + schema: + type: integer + title: Contract Id + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VersionCreate' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ContractDetailResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/expose: get: tags: @@ -1464,6 +1693,212 @@ components: required: - sections title: ConfigUpdateResponse + ContractCreate: + properties: + name: + type: string + maxLength: 255 + minLength: 1 + title: Name + kind: + type: string + maxLength: 32 + minLength: 1 + title: Kind + currency: + type: string + maxLength: 8 + minLength: 1 + title: Currency + default: EUR + values: + additionalProperties: true + type: object + title: Values + effective_from: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Effective From + description: UTC datetime from which the first pricing version is effective. + Defaults to the current UTC time when omitted. + type: object + required: + - name + - kind + - values + title: ContractCreate + description: 'Request body for POST /api/energy/contracts. + + + ``effective_from`` defaults to the current UTC time if not provided, + + giving the first version an open-ended start from "now". + + ``kind`` is validated at the application layer against the profile registry; + + clients should send ``"manual"`` or ``"tibber"``.' + ContractDetailResponse: + properties: + id: + type: integer + title: Id + name: + type: string + title: Name + kind: + type: string + title: Kind + active: + type: boolean + title: Active + currency: + type: string + title: Currency + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + versions: + items: + $ref: '#/components/schemas/ContractVersionResponse' + type: array + title: Versions + type: object + required: + - id + - name + - kind + - active + - currency + - created_at + - updated_at + - versions + title: ContractDetailResponse + description: 'Response schema for a single EnergyContract with full version + history. + + + Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH + + operations where the caller needs to see all version data.' + ContractListResponse: + properties: + items: + items: + $ref: '#/components/schemas/ContractResponse' + type: array + title: Items + total: + type: integer + title: Total + type: object + required: + - items + - total + title: ContractListResponse + description: Response schema for GET /api/energy/contracts. + ContractPatch: + properties: + name: + anyOf: + - type: string + maxLength: 255 + minLength: 1 + - type: 'null' + title: Name + active: + anyOf: + - type: boolean + - type: 'null' + title: Active + type: object + title: ContractPatch + description: 'Request body for PATCH /api/energy/contracts/{id}. + + + All fields are optional. Sending ``active=true`` activates this contract + + (deactivating all others); ``active=false`` deactivates it without affecting + + other contracts.' + ContractResponse: + properties: + id: + type: integer + title: Id + name: + type: string + title: Name + kind: + type: string + title: Kind + active: + type: boolean + title: Active + currency: + type: string + title: Currency + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + type: object + required: + - id + - name + - kind + - active + - currency + - created_at + - updated_at + title: ContractResponse + description: 'Response schema for a single EnergyContract (without embedded + versions). + + + Used for list responses where embedding all versions would be expensive.' + ContractVersionResponse: + properties: + id: + type: integer + title: Id + effective_from: + type: string + format: date-time + title: Effective From + effective_to: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Effective To + values: + additionalProperties: true + type: object + title: Values + created_at: + type: string + format: date-time + title: Created At + type: object + required: + - id + - effective_from + - effective_to + - values + - created_at + title: ContractVersionResponse + description: Response schema for a single EnergyContractVersion row. DeviceInfoSchema: properties: identifiers: @@ -2113,6 +2548,28 @@ components: - description title: ProfileSummary description: One entry in the GET /api/modbus/profiles response. + ProfilesResponse: + properties: + profiles: + items: + additionalProperties: true + type: object + type: array + title: Profiles + type: object + required: + - profiles + title: ProfilesResponse + description: 'Response schema for GET /api/energy/profiles. + + + ``profiles`` is a list of raw profile dicts as produced by + + ``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum + + ``kind`` and ``label``; the full nested structure allows the front-end to + + render a type-appropriate form for each pricing profile.' PublicIPCheckResponse: properties: status: @@ -2384,3 +2841,19 @@ components: - msg - type title: ValidationError + VersionCreate: + properties: + effective_from: + type: string + format: date-time + title: Effective From + values: + additionalProperties: true + type: object + title: Values + type: object + required: + - effective_from + - values + title: VersionCreate + description: Request body for POST /api/energy/contracts/{id}/versions. diff --git a/tests/test_api_energy_contracts.py b/tests/test_api_energy_contracts.py new file mode 100644 index 0000000..a2c8116 --- /dev/null +++ b/tests/test_api_energy_contracts.py @@ -0,0 +1,750 @@ +"""Tests for M6-T04: EnergyContract CRUD + versioning + pricing-profile API. + +Coverage matrix +--------------- +GET /api/energy/profiles + - unauthenticated → 401 + - authenticated → 200, both 'manual' and 'tibber' profiles present + +GET /api/energy/contracts + - unauthenticated → 401 + - authenticated empty → 200, items=[] + - after creating two contracts → items with active flag correct + +POST /api/energy/contracts + - unauthenticated → 401 + - missing CSRF → 403 + - invalid values (missing required field) → 422, DB unchanged + - unknown kind → 422 + - valid manual → 201, ContractDetailResponse with versions + - valid tibber → 201 + +GET /api/energy/contracts/{id} + - unauthenticated → 401 + - not found → 404 + - success → versions ordered by effective_from + +PATCH /api/energy/contracts/{id} + - unauthenticated → 401 + - missing CSRF → 403 + - not found → 404 + - rename → name updated + - activate → mutual exclusion (A active → activate B → A.active=False) + - deactivate → active=False, others unchanged + +POST /api/energy/contracts/{id}/versions + - unauthenticated → 401 + - missing CSRF → 403 + - not found → 404 + - invalid values → 422, no rows written, old version unchanged + - effective_from ≤ previous → 422 + - success → old version closed (effective_to set), new open version added, + old version values unchanged (only-append semantics) + +Auth/CSRF matrix tested for all write endpoints. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from app.models.energy import EnergyContract, EnergyContractVersion + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +_CSRF = "test-csrf-token" + + +def _login(client: TestClient) -> None: + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-password"}, + ) + assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}" + + +# Minimal valid values for each kind. + +_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 _manual_payload(**overrides) -> dict[str, Any]: + base: dict[str, Any] = { + "name": "Test Manual Contract", + "kind": "manual", + "currency": "EUR", + "values": _MANUAL_VALUES, + } + base.update(overrides) + return base + + +def _tibber_payload(**overrides) -> dict[str, Any]: + base: dict[str, Any] = { + "name": "Test Tibber Contract", + "kind": "tibber", + "currency": "EUR", + "values": _TIBBER_VALUES, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def contracts_client(auth_database): + """TestClient + SQLAlchemy engine for EnergyContract API tests.""" + from app.main import create_app + + app_url = auth_database["app_url"] + engine = create_engine(app_url, connect_args={"check_same_thread": False}) + fastapi_app = create_app() + with TestClient(fastapi_app) as test_client: + yield test_client, engine + engine.dispose() + + +# --------------------------------------------------------------------------- +# GET /api/energy/profiles +# --------------------------------------------------------------------------- + + +def test_profiles_unauthenticated_returns_401(contracts_client): + client, _ = contracts_client + resp = client.get("/api/energy/profiles") + assert resp.status_code == 401 + + +def test_profiles_returns_both_kinds(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.get("/api/energy/profiles") + assert resp.status_code == 200 + body = resp.json() + assert "profiles" in body + kinds = {p["kind"] for p in body["profiles"]} + assert "manual" in kinds + assert "tibber" in kinds + + +def test_profiles_contain_structure(contracts_client): + """Each profile entry must have the nested structure the front-end needs.""" + client, _ = contracts_client + _login(client) + resp = client.get("/api/energy/profiles") + body = resp.json() + for profile in body["profiles"]: + assert "kind" in profile + assert "label" in profile + assert "energy" in profile + assert "standing" in profile + assert "credits" in profile + + +# --------------------------------------------------------------------------- +# GET /api/energy/contracts +# --------------------------------------------------------------------------- + + +def test_list_contracts_unauthenticated_returns_401(contracts_client): + client, _ = contracts_client + resp = client.get("/api/energy/contracts") + assert resp.status_code == 401 + + +def test_list_contracts_empty(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.get("/api/energy/contracts") + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + assert body["total"] == 0 + + +def test_list_contracts_shows_active_flag(contracts_client): + """After creating two contracts and activating one, active flag is correct.""" + client, _ = contracts_client + _login(client) + + # Create contract A + resp_a = client.post( + "/api/energy/contracts", + json=_manual_payload(name="Contract A"), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp_a.status_code == 201 + id_a = resp_a.json()["id"] + + # Create contract B + resp_b = client.post( + "/api/energy/contracts", + json=_manual_payload(name="Contract B"), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp_b.status_code == 201 + id_b = resp_b.json()["id"] + + # Activate A + client.patch( + f"/api/energy/contracts/{id_a}", + json={"active": True}, + headers={"X-CSRF-Token": _CSRF}, + ) + + resp = client.get("/api/energy/contracts") + items = {c["id"]: c for c in resp.json()["items"]} + assert items[id_a]["active"] is True + assert items[id_b]["active"] is False + + +# --------------------------------------------------------------------------- +# POST /api/energy/contracts +# --------------------------------------------------------------------------- + + +def test_create_contract_unauthenticated_returns_401(contracts_client): + client, _ = contracts_client + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 401 + + +def test_create_contract_missing_csrf_returns_403(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.post("/api/energy/contracts", json=_manual_payload()) + assert resp.status_code == 403 + + +def test_create_contract_invalid_values_returns_422_no_db_write(contracts_client): + """Non-conforming values must return 422 without writing any rows.""" + client, engine = contracts_client + _login(client) + + # Missing required field energy.buy.normal + bad_values = { + "energy": { + "buy": {"dal": 0.30}, # missing "normal" + "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}, + } + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(values=bad_values), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 422 + + # Confirm no rows were written. + with Session(engine) as s: + count = s.execute(select(EnergyContract)).scalars().all() + assert len(count) == 0 + + +def test_create_contract_unknown_kind_returns_422(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.post( + "/api/energy/contracts", + json={ + "name": "Bad Kind", + "kind": "nonexistent_kind_xyz", + "values": {}, + }, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 422 + + +def test_create_manual_contract_success(contracts_client): + client, engine = contracts_client + _login(client) + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["name"] == "Test Manual Contract" + assert body["kind"] == "manual" + assert body["active"] is False # new contracts are inactive by default + assert "versions" in body + assert len(body["versions"]) == 1 + v = body["versions"][0] + assert v["effective_to"] is None # open-ended first version + + # DB check + with Session(engine) as s: + contracts = s.execute(select(EnergyContract)).scalars().all() + assert len(contracts) == 1 + assert contracts[0].name == "Test Manual Contract" + versions = s.execute(select(EnergyContractVersion)).scalars().all() + assert len(versions) == 1 + + +def test_create_tibber_contract_success(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.post( + "/api/energy/contracts", + json=_tibber_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["kind"] == "tibber" + assert len(body["versions"]) == 1 + + +def test_create_contract_defaults_effective_from(contracts_client): + """When effective_from is omitted, the version is created with a recent timestamp. + + SQLite stores datetimes as naive UTC strings; we compare both sides as naive UTC. + """ + client, _ = contracts_client + _login(client) + # Use naive UTC (no tzinfo) to match what SQLite gives back via Pydantic. + # Remove tzinfo from datetime.now(UTC) to get a comparable naive datetime. + before = datetime.now(UTC).replace(tzinfo=None) + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 201 + v = resp.json()["versions"][0] + # Parse as naive (SQLite round-trip strips tz) + raw_eff = datetime.fromisoformat(v["effective_from"]) + # Strip tzinfo from raw_eff if present (shouldn't be, but guard anyway) + eff_naive = raw_eff.replace(tzinfo=None) + assert eff_naive >= before + + +# --------------------------------------------------------------------------- +# GET /api/energy/contracts/{id} +# --------------------------------------------------------------------------- + + +def test_get_contract_unauthenticated_returns_401(contracts_client): + client, _ = contracts_client + resp = client.get("/api/energy/contracts/1") + assert resp.status_code == 401 + + +def test_get_contract_not_found_returns_404(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.get("/api/energy/contracts/99999") + assert resp.status_code == 404 + + +def test_get_contract_returns_versions_ordered(contracts_client): + """GET {id} must return versions ordered by effective_from.""" + client, _ = contracts_client + _login(client) + + t0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(effective_from=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 201 + contract_id = resp.json()["id"] + + t1 = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC) + new_values = dict(_MANUAL_VALUES) + new_values = { + **_MANUAL_VALUES, + "energy": {**_MANUAL_VALUES["energy"], "buy": {"normal": 0.50, "dal": 0.40}}, + } + client.post( + f"/api/energy/contracts/{contract_id}/versions", + json={"effective_from": t1.isoformat(), "values": new_values}, + headers={"X-CSRF-Token": _CSRF}, + ) + + resp = client.get(f"/api/energy/contracts/{contract_id}") + assert resp.status_code == 200 + versions = resp.json()["versions"] + assert len(versions) == 2 + # Ordered by effective_from ascending + eff0 = datetime.fromisoformat(versions[0]["effective_from"]) + eff1 = datetime.fromisoformat(versions[1]["effective_from"]) + assert eff0 < eff1 + + +# --------------------------------------------------------------------------- +# PATCH /api/energy/contracts/{id} +# --------------------------------------------------------------------------- + + +def test_patch_contract_unauthenticated_returns_401(contracts_client): + client, _ = contracts_client + resp = client.patch( + "/api/energy/contracts/1", + json={"name": "Renamed"}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 401 + + +def test_patch_contract_missing_csrf_returns_403(contracts_client): + client, _ = contracts_client + _login(client) + # Create a contract first + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + contract_id = resp.json()["id"] + # PATCH without CSRF + resp = client.patch(f"/api/energy/contracts/{contract_id}", json={"name": "Renamed"}) + assert resp.status_code == 403 + + +def test_patch_contract_not_found_returns_404(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.patch( + "/api/energy/contracts/99999", + json={"name": "Does Not Exist"}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 404 + + +def test_patch_contract_rename(contracts_client): + client, _ = contracts_client + _login(client) + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(name="Original Name"), + headers={"X-CSRF-Token": _CSRF}, + ) + contract_id = resp.json()["id"] + + resp = client.patch( + f"/api/energy/contracts/{contract_id}", + json={"name": "Renamed Contract"}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Renamed Contract" + + +def test_activate_contract_mutual_exclusion(contracts_client): + """Activating B deactivates A; at most one contract is active.""" + client, engine = contracts_client + _login(client) + + # Create A and B + resp_a = client.post( + "/api/energy/contracts", + json=_manual_payload(name="A"), + headers={"X-CSRF-Token": _CSRF}, + ) + id_a = resp_a.json()["id"] + + resp_b = client.post( + "/api/energy/contracts", + json=_manual_payload(name="B"), + headers={"X-CSRF-Token": _CSRF}, + ) + id_b = resp_b.json()["id"] + + # Activate A + resp = client.patch( + f"/api/energy/contracts/{id_a}", + json={"active": True}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is True + + # Now activate B — A must become inactive + resp = client.patch( + f"/api/energy/contracts/{id_b}", + json={"active": True}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is True + + # Verify A is now inactive via list + list_resp = client.get("/api/energy/contracts") + items = {c["id"]: c for c in list_resp.json()["items"]} + assert items[id_a]["active"] is False + assert items[id_b]["active"] is True + + # DB check: only one active + with Session(engine) as s: + active_contracts = ( + s.execute(select(EnergyContract).where(EnergyContract.active.is_(True))) + .scalars() + .all() + ) + assert len(active_contracts) == 1 + assert active_contracts[0].id == id_b + + +def test_deactivate_contract(contracts_client): + """PATCH active=false deactivates the contract without touching others.""" + client, _ = contracts_client + _login(client) + + resp = client.post( + "/api/energy/contracts", + json=_manual_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + contract_id = resp.json()["id"] + + # Activate it first + client.patch( + f"/api/energy/contracts/{contract_id}", + json={"active": True}, + headers={"X-CSRF-Token": _CSRF}, + ) + + # Now deactivate + resp = client.patch( + f"/api/energy/contracts/{contract_id}", + json={"active": False}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is False + + +# --------------------------------------------------------------------------- +# POST /api/energy/contracts/{id}/versions +# --------------------------------------------------------------------------- + + +def test_add_version_unauthenticated_returns_401(contracts_client): + client, _ = contracts_client + resp = client.post( + "/api/energy/contracts/1/versions", + json={"effective_from": datetime.now(UTC).isoformat(), "values": _MANUAL_VALUES}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 401 + + +def test_add_version_missing_csrf_returns_403(contracts_client): + client, _ = contracts_client + _login(client) + resp_c = client.post( + "/api/energy/contracts", + json=_manual_payload(), + headers={"X-CSRF-Token": _CSRF}, + ) + cid = resp_c.json()["id"] + # POST version without CSRF + t1 = (datetime.now(UTC) + timedelta(days=1)).isoformat() + resp = client.post( + f"/api/energy/contracts/{cid}/versions", + json={"effective_from": t1, "values": _MANUAL_VALUES}, + ) + assert resp.status_code == 403 + + +def test_add_version_not_found_returns_404(contracts_client): + client, _ = contracts_client + _login(client) + t1 = (datetime.now(UTC) + timedelta(days=1)).isoformat() + resp = client.post( + "/api/energy/contracts/99999/versions", + json={"effective_from": t1, "values": _MANUAL_VALUES}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 404 + + +def test_add_version_invalid_values_returns_422_no_db_write(contracts_client): + """Non-conforming values for a new version must return 422; DB unchanged.""" + client, engine = contracts_client + _login(client) + + t0 = datetime(2026, 1, 1, tzinfo=UTC) + resp_c = client.post( + "/api/energy/contracts", + json=_manual_payload(effective_from=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + cid = resp_c.json()["id"] + + bad_values = {"energy": {}} # missing almost everything + t1 = datetime(2026, 6, 1, tzinfo=UTC) + resp = client.post( + f"/api/energy/contracts/{cid}/versions", + json={"effective_from": t1.isoformat(), "values": bad_values}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 422 + + # Only the original version should exist in DB. + with Session(engine) as s: + versions = ( + s.execute( + select(EnergyContractVersion).where(EnergyContractVersion.contract_id == cid) + ) + .scalars() + .all() + ) + assert len(versions) == 1 + + +def test_add_version_effective_from_not_after_previous_returns_422(contracts_client): + """effective_from ≤ previous open version's effective_from must return 422.""" + client, _ = contracts_client + _login(client) + + t0 = datetime(2026, 6, 1, tzinfo=UTC) + resp_c = client.post( + "/api/energy/contracts", + json=_manual_payload(effective_from=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + cid = resp_c.json()["id"] + + # Attempt to add a version with t1 == t0 (not strictly after) + resp = client.post( + f"/api/energy/contracts/{cid}/versions", + json={"effective_from": t0.isoformat(), "values": _MANUAL_VALUES}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 422 + + # Attempt to add a version with t1 < t0 + t_before = datetime(2026, 1, 1, tzinfo=UTC) + resp = client.post( + f"/api/energy/contracts/{cid}/versions", + json={"effective_from": t_before.isoformat(), "values": _MANUAL_VALUES}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 422 + + +def test_add_version_closes_previous_and_appends(contracts_client): + """Adding a new version must close the previous one and create a new open version. + + Verification: + - Old version's effective_to == new version's effective_from. + - Old version's values are unchanged (append-only; never overwritten). + - New version has effective_to=None (open-ended). + """ + client, engine = contracts_client + _login(client) + + t0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + old_values = _MANUAL_VALUES + + resp_c = client.post( + "/api/energy/contracts", + json=_manual_payload(effective_from=t0.isoformat(), values=old_values), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp_c.status_code == 201 + cid = resp_c.json()["id"] + v1_id = resp_c.json()["versions"][0]["id"] + + t1 = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC) + new_values = { + **old_values, + "energy": {**old_values["energy"], "buy": {"normal": 0.50, "dal": 0.40}}, + } + + resp = client.post( + f"/api/energy/contracts/{cid}/versions", + json={"effective_from": t1.isoformat(), "values": new_values}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 201 + versions = resp.json()["versions"] + assert len(versions) == 2 + + # Ordered ascending — first is the old version + ver_old = versions[0] + ver_new = versions[1] + + # Old version must be closed at t1. + # SQLite round-trips datetimes as naive UTC strings; compare without tzinfo. + assert ver_old["id"] == v1_id + assert ver_old["effective_to"] is not None + eff_to = datetime.fromisoformat(ver_old["effective_to"]).replace(tzinfo=None) + assert eff_to == t1.replace(tzinfo=None) + + # Old version values must be unchanged + assert ver_old["values"]["energy"]["buy"]["normal"] == old_values["energy"]["buy"]["normal"] + + # New version must be open-ended + assert ver_new["effective_to"] is None + assert ver_new["values"]["energy"]["buy"]["normal"] == 0.50 + + # Double-check via DB + with Session(engine) as s: + v1 = s.get(EnergyContractVersion, v1_id) + assert v1 is not None + assert v1.effective_to is not None + assert v1.values["energy"]["buy"]["normal"] == old_values["energy"]["buy"]["normal"] + + all_versions = ( + s.execute( + select(EnergyContractVersion).where(EnergyContractVersion.contract_id == cid) + ) + .scalars() + .all() + ) + assert len(all_versions) == 2