"""Service layer for fetching and persisting Tibber 15-minute electricity prices. ``refresh_prices`` is the main entry point. It is designed to be called from a scheduled background job (see ``app/main.py``) and from tests. Design decisions ---------------- - **Guard clause**: if no active contract with ``kind="tibber"`` exists, or if the Tibber API token is empty in the runtime settings, the function is a complete no-op (returns 0) and does not raise. This means the scheduler can call ``refresh_prices`` unconditionally; the service itself decides whether to do anything based on current configuration. - **Upsert idempotency**: rows are matched by ``starts_at`` (the unique constraint on ``tibber_price``). If a row already exists, its price fields and ``fetched_at`` are updated in-place; if it does not exist, a new row is inserted. Running ``refresh_prices`` twice in a row must not double-insert. - **No destructive operations**: this service never deletes ``tibber_price`` rows. Only INSERT or UPDATE. - **Exception propagation**: exceptions from the Tibber client are *not* swallowed here. The scheduled job wrapper in ``main.py`` is responsible for catching and logging errors so that a single fetch failure does not crash the scheduler. - **SQLite timezone note**: ``DateTime(timezone=True)`` columns come back as timezone-naive UTC datetimes on read. We store UTC-aware datetimes on write (``starts_at`` from ``PricePoint`` is already UTC-aware; ``fetched_at`` is ``datetime.now(UTC)``). Reads elsewhere that compare against these values must normalise accordingly. """ from __future__ import annotations import logging from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.orm import Session from app.integrations.tibber.client import fetch_price_range from app.models.energy import EnergyContract, TibberPrice logger = logging.getLogger(__name__) def _active_tibber_contract_exists(session: Session) -> bool: """Return True if there is an active contract with kind='tibber'.""" row = session.execute( select(EnergyContract).where( EnergyContract.active.is_(True), EnergyContract.kind == "tibber", ).limit(1) ).scalar_one_or_none() return row is not None def refresh_prices(session: Session, settings: object) -> int: """Fetch today-and-tomorrow Tibber prices and upsert them into ``tibber_price``. Parameters ---------- session: An active SQLAlchemy session. The caller is responsible for closing it; this function commits each upserted row individually to keep transactions short. settings: A runtime settings object that exposes ``tibber_api_token`` and ``tibber_home_id`` attributes (typically a ``Settings`` or merged runtime-settings instance from ``build_runtime_settings``). Returns ------- int Number of rows upserted (inserted or updated). Returns 0 for a no-op. Notes ----- - No-op (returns 0) when: * No active contract exists with ``kind="tibber"``, OR * ``settings.tibber_api_token`` is empty or whitespace. - Exceptions from the Tibber client are *not* caught here; they propagate to the caller (the scheduled job wrapper swallows them and logs). """ token: str = getattr(settings, "tibber_api_token", "") or "" if not token.strip(): logger.debug("refresh_prices: tibber_api_token is empty — no-op") return 0 if not _active_tibber_contract_exists(session): logger.debug("refresh_prices: no active tibber contract — no-op") return 0 home_id: str = getattr(settings, "tibber_home_id", "") or "" home_id_or_none: str | None = home_id.strip() or None logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none) # May raise TibberError or TibberAuthError — let them propagate. price_points = fetch_price_range(token, home_id_or_none) if not price_points: logger.info("refresh_prices: API returned zero price points — nothing to upsert") return 0 fetched_at = datetime.now(UTC) upserted = 0 for point in price_points: existing = session.execute( select(TibberPrice).where(TibberPrice.starts_at == point.starts_at) ).scalar_one_or_none() if existing is not None: # Update in-place — price fields may change (e.g. Tibber corrects a # forecast), but starts_at and resolution stay the same. existing.total = point.total existing.energy = point.energy existing.tax = point.tax existing.level = point.level existing.currency = point.currency existing.fetched_at = fetched_at else: session.add( TibberPrice( starts_at=point.starts_at, resolution=point.resolution, total=point.total, energy=point.energy, tax=point.tax, level=point.level, currency=point.currency, fetched_at=fetched_at, ) ) upserted += 1 session.commit() logger.info("refresh_prices: upserted %d price points", upserted) return upserted