ranczo-energy-price-scrapers/rest.py
Bartosz Wieczorek 6e6ea85f3c fixes
2025-10-14 20:29:09 +02:00

181 lines
5.8 KiB
Python

# 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_format: Literal["(,]"] = "[ts_utc,ts_utc)"
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