diff --git a/Scraper/TGE_RDNScraper.py b/Scraper/TGE_RDNScraper.py index 7bce4f6..9d50285 100644 --- a/Scraper/TGE_RDNScraper.py +++ b/Scraper/TGE_RDNScraper.py @@ -69,7 +69,6 @@ def get_month(): start = start + timedelta(days=1) -@dataclass class TGE_RDNScraper(EnergyPriceScraperBase, HasLogger): """ Scraper TGE RDN 'Notowania w systemie kursu jednolitego / fixing' z pliku Excel. diff --git a/os/energy-price-scrapers-update.sh b/os/energy-price-scrapers-update.sh index c0fb6a3..bda6e36 100644 --- a/os/energy-price-scrapers-update.sh +++ b/os/energy-price-scrapers-update.sh @@ -12,5 +12,10 @@ if [ -f "$APP_DIR/requirements.txt" ]; then sudo -u energy "$APP_DIR/.venv/bin/pip" install -r "$APP_DIR/requirements.txt" fi +sudo install -m 0755 $APP_DIR/os/energy-price-scrapers-update.sh /usr/local/bin/energy-price-scrapers-update +sudo install -m 0755 $APP_DIR/os/energy-price-scrapers.service /etc/systemd/system/energy-price-scrapers.service + +sudo systemctl daemon-reload sudo systemctl restart energy-price-scrapers.service + echo "Updated & restarted." diff --git a/rest.py b/rest.py new file mode 100644 index 0000000..2a1d70d --- /dev/null +++ b/rest.py @@ -0,0 +1,181 @@ +# 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 \ No newline at end of file