"""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 threading import Lock, Thread 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__) _background_refresh_lock = Lock() 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 # Neither the API token nor the selected home identifier is safe to emit in # diagnostics. The fetch client receives them, but logs only describe the # operation itself. logger.info("refresh_prices: fetching Tibber price range") # 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 def run_tibber_refresh_best_effort() -> bool: """Run one refresh with an isolated session, skipping concurrent requests. This is shared by the hourly scheduler and immediate post-commit triggers. It intentionally catches all failures: refresh is advisory and must never make app startup or a successfully committed configuration/contract update appear to have failed. The boolean reports whether this invocation owned the work; it is primarily useful for tests and diagnostics. """ if not _background_refresh_lock.acquire(blocking=False): logger.debug("Tibber refresh already running; skipping duplicate request") return False session: Session | None = None try: # Local imports keep the pure refresh service free of app startup import # cycles, while every background invocation gets a fresh DB session. from app.config import get_settings from app.db import get_session_local from app.services.config_page import build_runtime_settings session = get_session_local()() refresh_prices(session, build_runtime_settings(session, get_settings())) except Exception as exc: # Exception text can contain remote request details. Keep diagnostics # useful without allowing a token or home id to escape through logging. logger.warning("Tibber price refresh failed (%s)", type(exc).__name__) if session is not None: try: session.rollback() except Exception: logger.warning("Tibber price refresh rollback failed") finally: if session is not None: try: session.close() except Exception: logger.warning("Tibber price refresh session close failed") _background_refresh_lock.release() return True def trigger_tibber_refresh() -> None: """Request a non-blocking, best-effort Tibber refresh after a DB commit.""" try: Thread( target=run_tibber_refresh_best_effort, name="tibber-price-refresh", daemon=True, ).start() except Exception as exc: # Starting the optional worker must not turn an already committed API # operation into a failure; avoid logging exception text for secrecy. logger.warning("Unable to start Tibber price refresh (%s)", type(exc).__name__)