"""OTE CZ price import – Python dělá pouze HTTP fetch, logika je v PostgreSQL.""" from __future__ import annotations import asyncio import json import logging from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo import httpx from app.config import get_settings logger = logging.getLogger(__name__) OTE_URL = ( "https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/" "@@chart-data?report_date={date}&time_resolution=PT15M" ) def _is_retryable_status(status_code: int) -> bool: return status_code in {408, 425, 429, 500, 502, 503, 504} async def _fetch_ote_json(date_str: str) -> tuple[dict | None, str | None]: url = OTE_URL.format(date=date_str) timeout = httpx.Timeout(connect=10.0, read=45.0, write=10.0, pool=10.0) headers = { "User-Agent": "Mozilla/5.0 (compatible; EMS/1.0; +https://www.ote-cr.cz)", "Accept": "application/json, text/plain, */*", "Accept-Language": "cs-CZ,cs;q=0.9,en;q=0.8", "Connection": "keep-alive", } max_attempts = 4 backoff_s = 1.0 last_err: str | None = None async with httpx.AsyncClient( timeout=timeout, headers=headers, follow_redirects=True, ) as client: for attempt in range(1, max_attempts + 1): try: logger.info("OTE fetch %s attempt %s/%s", date_str, attempt, max_attempts) resp = await client.get(url) if _is_retryable_status(resp.status_code) and attempt < max_attempts: last_err = f"http_status:{resp.status_code}" logger.warning( "OTE temporary HTTP %s for %s (attempt %s/%s), retrying", resp.status_code, date_str, attempt, max_attempts, ) await asyncio.sleep(backoff_s) backoff_s *= 2.0 continue resp.raise_for_status() return resp.json(), None except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e: last_err = f"timeout_or_connect:{e.__class__.__name__}" if attempt < max_attempts: logger.warning( "OTE request failed for %s (%s), retrying %s/%s", date_str, e.__class__.__name__, attempt, max_attempts, ) await asyncio.sleep(backoff_s) backoff_s *= 2.0 continue logger.error("OTE fetch failed for %s after retries: %s", date_str, e) except httpx.HTTPStatusError as e: code = e.response.status_code if e.response is not None else "unknown" last_err = f"http_status:{code}" logger.error("OTE HTTP error for %s: %s", date_str, code) break except json.JSONDecodeError as e: last_err = f"invalid_json:{e.__class__.__name__}" logger.error("OTE invalid JSON for %s: %s", date_str, e) break except Exception as e: last_err = f"unexpected:{e.__class__.__name__}" logger.error("OTE fetch unexpected error for %s: %s", date_str, e) break return None, last_err async def import_ote_prices( site_id: int, db, target_date: date | None = None, ) -> tuple[int, str, float, str | None]: """ Stáhne OTE JSON a předá ho PostgreSQL funkci ems.fn_ote_import_from_json. Python nedělá žádné parsování ani přepočty – vše je v DB funkcích. Returns: (počet_slotů, datum_str, první_cena_kč_kwh, error_code) (-1, datum_str, 0.0, error_code) při chybě """ settings = get_settings() row = await db.fetchrow( "SELECT timezone FROM ems.site WHERE id = $1", site_id ) if row is None: logger.error("OTE import: site id=%s nenalezen", site_id) return -1, "", 0.0, "site_not_found" site_tz = ZoneInfo(row["timezone"] or "Europe/Prague") now_site = datetime.now(site_tz) today_site = now_site.date() tomorrow_site = today_site + timedelta(days=1) candidate_days = [target_date] if target_date is not None else [tomorrow_site, today_site] payload: dict | None = None fetch_error: str | None = None target_day = candidate_days[0] # Varování před 13:30 CET při implicitním (zítra) importu. if target_date is None: now_cet = datetime.now(ZoneInfo("Europe/Prague")) if now_cet.hour < 13 or (now_cet.hour == 13 and now_cet.minute < 30): logger.warning( "OTE: ceny pro %s nemusí být dostupné (před 13:30 CET), použiji fallback na dnešek", tomorrow_site.isoformat(), ) for day in candidate_days: day_str = day.isoformat() payload, fetch_error = await _fetch_ote_json(day_str) if payload is not None: target_day = day break logger.warning("OTE fetch selhal pro %s (err=%s)", day_str, fetch_error) if payload is None: return -1, candidate_days[0].isoformat(), 0.0, fetch_error or "fetch_failed" date_str = target_day.isoformat() # Vše ostatní řeší PostgreSQL funkce eur_czk = float(settings.eur_czk_rate) try: n = await db.fetchval( "SELECT ems.fn_ote_import_from_json($1::jsonb, $2)", json.dumps(payload), eur_czk, ) first_price = await db.fetchval( """ SELECT buy_raw_price_czk_kwh FROM ems.market_interval_price WHERE market_source = 'OTE_CZ' AND interval_start::date = $1::date ORDER BY interval_start LIMIT 1 """, target_day, ) n_imported = await db.fetchval( """ SELECT COUNT(*)::int FROM ems.market_interval_price WHERE market_source = 'OTE_CZ' AND interval_start::date = $1::date """, target_day, ) incomplete = (n_imported or 0) < 96 if incomplete: now_p = datetime.now(ZoneInfo("Europe/Prague")) tomorrow_p = (now_p + timedelta(days=1)).date() # Stejná logika jako dashboard: neúplný D+1 před 14:30 je očekávaný if not ( target_day == tomorrow_p and (now_p.hour, now_p.minute) < (14, 30) ): logger.warning("OTE: jen %s/96 slotů pro %s", n_imported, date_str) logger.info( "OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh", n, date_str, float(first_price or 0), ) return int(n), date_str, float(first_price or 0.0), None except Exception as e: logger.error("OTE import DB error: %s", e) return -1, date_str, 0.0, f"db_import:{e.__class__.__name__}"