"""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 import timezone as _tz_mod 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), ) def _localize_effective_from(dt: datetime | None) -> datetime: """Resolve *dt* to an aware UTC datetime for storage. Rules (Principle A): - If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before). - If *dt* is timezone-aware → convert to UTC as-is. - If *dt* is timezone-naive → interpret as server local wall-clock time, localize with ``local_tz()``, then convert to UTC. This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z), not as UTC midnight. """ if dt is None: return datetime.now(UTC) if dt.tzinfo is not None: # Already aware: convert to UTC. return dt.astimezone(UTC) # Naive: assume server local wall-clock. tz = _tz_mod.local_tz() local_dt = dt.replace(tzinfo=tz) return local_dt.astimezone(UTC) # --------------------------------------------------------------------------- # 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 = _localize_effective_from(body.effective_from) 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) effective_from = _localize_effective_from(body.effective_from) try: add_version( db, contract, effective_from=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)