181 lines
5.8 KiB
Python
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 |