# /opt/lotto/app.py import os from datetime import date, datetime from typing import List, Optional from fastapi import FastAPI, Query, Request, HTTPException from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine, Row from sqlalchemy.pool import NullPool from dotenv import load_dotenv # -------------------------------------------------------- # 0) Konfiguration laden # -------------------------------------------------------- load_dotenv() DATABASE_URL = os.getenv("DATABASE_URL") if not DATABASE_URL: raise RuntimeError("DATABASE_URL nicht gesetzt (.env prüfen)") PAGE_SIZE = int(os.getenv("PAGE_SIZE", "10")) # -------------------------------------------------------- # 1) App und DB initialisieren # -------------------------------------------------------- engine: Engine = create_engine(DATABASE_URL, poolclass=NullPool, future=True) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.path.join(BASE_DIR, "static") TEMPLATE_DIR = os.path.join(BASE_DIR, "templates") app = FastAPI(title="Lotto Ziehungen API") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") templates = Jinja2Templates(directory=TEMPLATE_DIR) # -------------------------------------------------------- # 2) Modelle (Pydantic) # -------------------------------------------------------- class Draw(BaseModel): datum: Optional[date] z1: Optional[int] z2: Optional[int] z3: Optional[int] z4: Optional[int] z5: Optional[int] z6: Optional[int] sz: Optional[int] sz1: Optional[int] sz2: Optional[int] super6: Optional[str] spiel77: Optional[str] class DrawList(BaseModel): total: int items: List[Draw] # -------------------------------------------------------- # 3) Hilfsfunktionen # -------------------------------------------------------- def normalize_date_sql(column: str) -> str: return ( f"COALESCE(" f" (CASE WHEN {column} REGEXP '^[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}$' THEN DATE({column}) END)," f" STR_TO_DATE({column}, '%d.%m.%Y')," f" STR_TO_DATE(SUBSTRING_INDEX({column}, '/', -1), '%d.%m.%y')" f")" ) def row_to_draw(row: Row) -> Draw: return Draw(**row) def _to_date(s: Optional[str]) -> Optional[date]: if not s: return None t = s.strip() if not t: return None try: return date.fromisoformat(t) except ValueError: pass for fmt in ("%d.%m.%Y", "%d.%m.%y"): try: return datetime.strptime(t, fmt).date() except ValueError: continue return None # -------------------------------------------------------- # 4) Routen # -------------------------------------------------------- @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse( "index.html", {"request": request, "page_size": PAGE_SIZE} ) # -------------------- API: Ziehungen (JSON) --------------------- @app.get("/api/draws", response_model=DrawList) async def list_draws( game: str = Query("6aus49", pattern="^(6aus49|euro)$"), date_from: Optional[date] = Query(None), date_to: Optional[date] = Query(None), limit: int = Query(PAGE_SIZE, ge=1, le=500), offset: int = Query(0, ge=0), order: str = Query("desc", pattern="^(asc|desc)$"), ): tbl = "`6aus49`" if game == "6aus49" else "`euro`" dx = normalize_date_sql(f"{tbl}.datum") if game == "6aus49": base_select = f""" SELECT {dx} AS datum, z1, z2, z3, z4, z5, z6, sz, CAST(super6 AS CHAR) AS super6, CAST(spiel77 AS CHAR) AS spiel77, NULL AS sz1, NULL AS sz2 FROM {tbl} """ else: base_select = f""" SELECT {dx} AS datum, z1, z2, z3, z4, z5, NULL AS z6, NULL AS sz, NULL AS super6, NULL AS spiel77, sz1, sz2 FROM {tbl} """ where_parts = [] params = {} if date_from: where_parts.append("datum >= :date_from") params["date_from"] = date_from if date_to: where_parts.append("datum <= :date_to") params["date_to"] = date_to where_sql = "WHERE " + " AND ".join(where_parts) if where_parts else "" order_sql = "ORDER BY datum DESC" if order == "desc" else "ORDER BY datum ASC" count_sql = f"SELECT COUNT(*) AS cnt FROM ({base_select}) AS t {where_sql}" data_sql = f"SELECT * FROM ({base_select}) AS t {where_sql} {order_sql} LIMIT :limit OFFSET :offset" with engine.begin() as conn: total = conn.execute(text(count_sql), params).scalar_one() rows = conn.execute(text(data_sql), {**params, "limit": limit, "offset": offset}).mappings().all() items = [row_to_draw(r) for r in rows] return DrawList(total=total, items=items) # -------------------- API: Detail (JSON) ------------------------ @app.get("/api/draw/{game}/{draw_date}", response_model=Draw) async def get_draw(game: str, draw_date: date): tbl = "`6aus49`" if game == "6aus49" else "`euro`" dx = normalize_date_sql(f"{tbl}.datum") if game == "6aus49": sql = text(f""" SELECT {dx} AS datum, z1, z2, z3, z4, z5, z6, sz, CAST(super6 AS CHAR) AS super6, CAST(spiel77 AS CHAR) AS spiel77, NULL AS sz1, NULL AS sz2 FROM {tbl} WHERE {dx} = :d """) else: sql = text(f""" SELECT {dx} AS datum, z1, z2, z3, z4, z5, NULL AS z6, NULL AS sz, NULL AS super6, NULL AS spiel77, sz1, sz2 FROM {tbl} WHERE {dx} = :d """) with engine.begin() as conn: row = conn.execute(sql, {"d": draw_date}).mappings().first() if not row: raise HTTPException(status_code=404, detail="Ziehung nicht gefunden") return row_to_draw(row) # -------------------- UI / HTMX (HTML) ------------------------- @app.get("/ui/draws", response_class=HTMLResponse) async def ui_draws( request: Request, game: str = Query("6aus49", pattern="^(6aus49|euro)$"), date_from: Optional[str] = Query(None), date_to: Optional[str] = Query(None), limit: int = Query(PAGE_SIZE, ge=1, le=500), offset: int = Query(0, ge=0), order: str = Query("desc", pattern="^(asc|desc)$"), ): d_from = _to_date(date_from) d_to = _to_date(date_to) result = await list_draws(game, d_from, d_to, limit, offset, order) # type: ignore rows_html = [] if game == "6aus49": header = ( "
Treffer: {result.total}
" f"