# app.py import os from datetime import date 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 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")) # Standardlimit # ------------------------------------------------------ # 1) App & DB initialisieren # ------------------------------------------------------ engine: Engine = create_engine(DATABASE_URL, poolclass=NullPool, future=True) app = FastAPI(title="Lotto-Auswertung (6aus49 / Eurojackpot)") app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") # ------------------------------------------------------ # 2) Pydantic-Schemas # ------------------------------------------------------ class Draw(BaseModel): datum: date # gemeinsame Felder; bei Euro bleiben z6/sz/super6/spiel77 leer z1: int; z2: int; z3: int; z4: int; z5: int z6: Optional[int] = None sz: Optional[int] = None sz1: Optional[int] = None sz2: Optional[int] = None super6: Optional[str] = None spiel77: Optional[str] = None class DrawList(BaseModel): total: int items: List[Draw] # ------------------------------------------------------ # 3) Datumsparser (Template – Tabellename variabel) # ------------------------------------------------------ DATE_EXPR_TMPL = ( "COALESCE(" " (CASE WHEN {tbl}.datum REGEXP '^[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}$' THEN DATE({tbl}.datum) END)," " STR_TO_DATE({tbl}.datum, '%d.%m.%Y')," " STR_TO_DATE({tbl}.datum, '%d.%m.%y')," " STR_TO_DATE(TRIM(SUBSTRING_INDEX({tbl}.datum, '/', -1)), '%d.%m.%Y')," " STR_TO_DATE(TRIM(SUBSTRING_INDEX({tbl}.datum, '/', -1)), '%d.%m.%y')" ")" ) # ------------------------------------------------------ # 4) Helferfunktionen # ------------------------------------------------------ def _normalize_game(s: str) -> str: return "euro" if (s or "").lower() == "euro" else "6aus49" def _to_date(s: Optional[str]): if not s or s.strip() == "": return None try: return date.fromisoformat(s.strip()) except Exception: return None def _rewrite_where_for_t_simple(where_parts): if not where_parts: return "" return " WHERE " + " AND ".join(where_parts) def _build_base_select(game: str) -> dict: """ Konfiguriert Spalten & Layout je nach Spiel. Liefert: - tbl: Tabellenname (mit Backticks) - dx: Datumsausdruck - base_select: Subquery-SELECT (liefert t.*) - headers: Tabellenkopf (HTML) - row_tpl: Zeilen-Template (HTML) - link_prefix: Pfadpräfix für Detail-Link """ if game == "euro": tbl = "`euro`" dx = DATE_EXPR_TMPL.format(tbl=tbl) base_select = """ SELECT {dx} AS datum, z1, z2, z3, z4, z5, sz1, sz2 FROM {tbl} """.format(dx=dx, tbl=tbl) # 👇 Hier neue Spaltennamen headers = "DatumZahlen (5)Super 1Super 2" row_tpl = ( '' '{datum}' '' '{z1} {z2} {z3} ' '{z4} {z5}' '' '{sz1}' '{sz2}' '' ) link_prefix = "/api/draw/euro" else: tbl = "`6aus49`" dx = DATE_EXPR_TMPL.format(tbl=tbl) # super6/spiel77 als CHAR casten → Pydantic erwartet str base_select = """ SELECT {dx} AS datum, z1, z2, z3, z4, z5, z6, sz, CAST(super6 AS CHAR) AS super6, CAST(spiel77 AS CHAR) AS spiel77 FROM {tbl} """.format(dx=dx, tbl=tbl) headers = "DatumZahlen (6)SuperzahlSuper 6Spiel 77" row_tpl = ( '' '{datum}' '' '{z1} {z2} {z3} ' '{z4} {z5} {z6}' '' '{sz}' '{super6}' '{spiel77}' '' ) link_prefix = "/api/draw/6aus49" return { "tbl": tbl, "dx": dx, "base_select": base_select, "headers": headers, "row_tpl": row_tpl, "link_prefix": link_prefix, } # ------------------------------------------------------ # 5) Startseite # ------------------------------------------------------ @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse("index.html", {"request": request, "page_size": PAGE_SIZE}) # ------------------------------------------------------ # 6) Gemeinsamer HTML-Endpoint (HTMX) # ------------------------------------------------------ @app.get("/ui/draws", response_class=HTMLResponse) async def ui_draws( request: Request, game: str = Query("6aus49"), date_from: Optional[str] = Query(None), date_to: Optional[str] = Query(None), limit: int = Query(10, ge=1, le=500), offset: int = Query(0, ge=0), order: str = Query("desc", pattern="^(asc|desc)$"), ): game = _normalize_game(game) cfg = _build_base_select(game) d_from = _to_date(date_from) d_to = _to_date(date_to) where_parts = [] params = {} if d_from: where_parts.append("t.datum >= :date_from") params["date_from"] = d_from if d_to: where_parts.append("t.datum <= :date_to") params["date_to"] = d_to where_sql = _rewrite_where_for_t_simple(where_parts) if where_sql == "": where_sql = " WHERE 1=1" where_sql = where_sql + " AND t.datum IS NOT NULL" order_sql = " ORDER BY t.datum DESC" if order.lower() == "desc" else " ORDER BY t.datum ASC" count_sql = """ SELECT COUNT(*) AS cnt FROM ( {base} ) AS t {where_sql} """.format(base=cfg["base_select"], where_sql=where_sql) data_sql = """ SELECT * FROM ( {base} ) AS t {where_sql} {order_sql} LIMIT :limit OFFSET :offset """.format(base=cfg["base_select"], where_sql=where_sql, order_sql=order_sql) with engine.begin() as conn: total = conn.execute(text(count_sql), params).scalar_one() rows = conn.execute(text(data_sql), dict(params, limit=limit, offset=offset)).mappings() body_rows = [] for r in rows: d = dict(r) d["link"] = cfg["link_prefix"] body_rows.append(cfg["row_tpl"].format(**d)) html = """

Treffer: {total}

{headers}{rows}
""".format(total=total, headers=cfg["headers"], rows="".join(body_rows)) return HTMLResponse(html) # ------------------------------------------------------ # 7) Detail-Endpoint je Spiel (None-Felder ausblenden) # ------------------------------------------------------ @app.get( "/api/draw/{game}/{draw_date}", response_model=Draw, response_model_exclude_none=True ) async def get_draw_game(game: str, draw_date: date): game = _normalize_game(game) if game == "euro": dx = DATE_EXPR_TMPL.format(tbl="`euro`") sql = text(""" SELECT t.datum, t.z1, t.z2, t.z3, t.z4, t.z5, NULL AS z6, NULL AS sz, t.sz1, t.sz2, NULL AS super6, NULL AS spiel77 FROM ( SELECT {dx} AS datum, z1, z2, z3, z4, z5, sz1, sz2 FROM `euro` ) AS t WHERE t.datum = :d AND t.datum IS NOT NULL """.format(dx=dx)) else: dx = DATE_EXPR_TMPL.format(tbl="`6aus49`") sql = text(""" SELECT t.datum, t.z1, t.z2, t.z3, t.z4, t.z5, t.z6, t.sz, NULL AS sz1, NULL AS sz2, t.super6, t.spiel77 FROM ( SELECT {dx} AS datum, z1, z2, z3, z4, z5, z6, sz, CAST(super6 AS CHAR) AS super6, CAST(spiel77 AS CHAR) AS spiel77 FROM `6aus49` ) AS t WHERE t.datum = :d AND t.datum IS NOT NULL """.format(dx=dx)) 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 Draw(**row) # ------------------------------------------------------ # 8) Healthcheck # ------------------------------------------------------ @app.get("/health") async def health(): try: with engine.begin() as conn: conn.execute(text("SELECT 1")) return {"status": "ok"} except Exception as e: return {"status": "error", "message": str(e)} # ------------------------------------------------------ # 9) Lokaler Start # ------------------------------------------------------ if __name__ == "__main__": import uvicorn uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)