# main.py # FastAPI app exposing /today, /tomorrow, and /day/{YYYY-MM-DD} endpoints. # No auth, no cache. Each request opens a short-lived psycopg3 connection (no pool). # Returns JSON with items as "(start,end]" ranges and NETTO PLN/kWh. from __future__ import annotations from functools import lru_cache import EnergyPriceScraperFactory import os from datetime import datetime, date, timedelta from typing import List, Literal import psycopg from psycopg.rows import dict_row from fastapi import FastAPI, HTTPException, Query, Path, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from utils.time import WARSAW_TZ, UTC PROVIDER = "TGE" KIND = "market_price" SIDE = "buy" BUYER = "end_user" SELLER = "market_index" # ------------------------- Models ------------------------- class PricePoint(BaseModel): from_utc: str # ISO8601 w UTC to_utc: str # ISO8601 w UTC price: float class Range(BaseModel): from_utc: str # ISO8601 w UTC to_utc: str # ISO8601 w UTC class PriceEnvelope(BaseModel): range: Range interval: Literal["(,]"] = "(ts_utc,ts_utc]" # left-open, right-closed data: List[PricePoint] = Field(default_factory=list) # ------------------------- Utilities ------------------------- def _connect(): return EnergyPriceScraperFactory.setup_db() def _local_day_bounds(day: date) -> tuple[datetime, datetime]: """Return (start, end) of local Warsaw calendar day as aware datetimes.""" start_local = datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=WARSAW_TZ) end_local = start_local + timedelta(days=1) return start_local, end_local @lru_cache(maxsize=100, typed=False) def _fetch_prices(start_dt: datetime, end_dt: datetime): if end_dt <= start_dt: raise HTTPException(status_code=400, detail="end must be > start") sql = """ SELECT ts_start, ts_end, price_pln_net FROM energy_prices WHERE provider = %(provider)s AND kind = %(kind)s AND side = %(side)s AND buyer = %(buyer)s AND seller = %(seller)s AND ts_start >= %(start)s AND ts_start < %(end)s ORDER BY ts_start """ with _connect() as conn: with conn.cursor() as cur: cur.execute(sql, { "provider": PROVIDER, "kind": KIND, "side": SIDE, "buyer": BUYER, "seller": SELLER, "start": start_dt, "end": end_dt, }) rows = cur.fetchall() points: List[dict] = [] for r in rows: points.append({ "from_utc": r[0].astimezone(UTC).isoformat(), "to_utc": r[1].astimezone(UTC).isoformat(), "price": float(r[2]), }) return points # ------------------------- App ------------------------- app = FastAPI(title="Energy Prices API", version="1.1.0") @app.get("/healthz") def healthz(): return {"ok": True} @app.get("/today", response_model=PriceEnvelope) def prices_today(): today_local = datetime.now(WARSAW_TZ).date() start_dt, end_dt = _local_day_bounds(today_local) data = _fetch_prices(start_dt, end_dt) envelope = PriceEnvelope( range=Range( from_utc=start_dt.astimezone(UTC).isoformat(), to_utc=end_dt.astimezone(UTC).isoformat(), ), data=[PricePoint.model_validate(p) for p in data], ) return JSONResponse(content=envelope.model_dump(by_alias=True)) @app.get("/tomorrow", response_model=PriceEnvelope) def prices_tomorrow(): tomorrow_local = (datetime.now(WARSAW_TZ) + timedelta(days=1)).date() start_dt, end_dt = _local_day_bounds(tomorrow_local) data = _fetch_prices(start_dt, end_dt) envelope = PriceEnvelope( range=Range( from_utc=start_dt.astimezone(UTC).isoformat(), to_utc=end_dt.astimezone(UTC).isoformat(), ), data=[PricePoint.model_validate(p) for p in data], ) return JSONResponse(content=envelope.model_dump(by_alias=True)) @app.get("/day/{day}", response_model=PriceEnvelope) def prices_for_day( day: str = Path(..., description="YYYY-MM-DD w lokalnej strefie Europe/Warsaw"), ): try: y, m, d = map(int, day.split("-")) req_day = date(y, m, d) except Exception: raise HTTPException(status_code=400, detail="day must be in YYYY-MM-DD format") start_dt, end_dt = _local_day_bounds(req_day) data = _fetch_prices(start_dt, end_dt) envelope = PriceEnvelope( range=Range( from_utc=start_dt.astimezone(UTC).isoformat(), to_utc=end_dt.astimezone(UTC).isoformat(), ), data=[PricePoint.model_validate(p) for p in data], ) return JSONResponse(content=envelope.model_dump(by_alias=True)) import logging import time timelog = logging.getLogger("app.timing") # <= własny logger @app.middleware("http") async def timing_middleware(request: Request, call_next): start = time.perf_counter() try: response = await call_next(request) status = response.status_code except Exception: status = 500 raise finally: dur_s = time.perf_counter() - start dur_ms = dur_s * 1000.0 # Nagłówki dla klienta: # standard: Server-Timing (Chrome devtools ładnie pokazuje) response.headers["Server-Timing"] = f"app;dur={dur_ms:.2f}" # dodatkowy, „ludzki”: response.headers["X-Process-Time"] = f"{dur_s:.6f}s" # Log do stdout: timelog.info(f"{request.method} {request.url.path} -> {status} in {dur_ms} ms") return response # To run: uvicorn main:app --host 0.0.0.0 --port 8080