303 lines
10 KiB
Python
303 lines
10 KiB
Python
# 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 = "<tr><th>Datum</th><th>Zahlen (5)</th><th>Super 1</th><th>Super 2</th></tr>"
|
||
row_tpl = (
|
||
'<tr>'
|
||
'<td class="nowrap"><a href="{link}/{datum}" target="_blank">{datum}</a></td>'
|
||
'<td class="numbers">'
|
||
'<span class="badge">{z1}</span> <span class="badge">{z2}</span> <span class="badge">{z3}</span> '
|
||
'<span class="badge">{z4}</span> <span class="badge">{z5}</span>'
|
||
'</td>'
|
||
'<td class="nowrap"><strong>{sz1}</strong></td>'
|
||
'<td class="nowrap"><strong>{sz2}</strong></td>'
|
||
'</tr>'
|
||
)
|
||
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 = "<tr><th>Datum</th><th>Zahlen (6)</th><th>Superzahl</th><th>Super 6</th><th>Spiel 77</th></tr>"
|
||
row_tpl = (
|
||
'<tr>'
|
||
'<td class="nowrap"><a href="{link}/{datum}" target="_blank">{datum}</a></td>'
|
||
'<td class="numbers">'
|
||
'<span class="badge">{z1}</span> <span class="badge">{z2}</span> <span class="badge">{z3}</span> '
|
||
'<span class="badge">{z4}</span> <span class="badge">{z5}</span> <span class="badge">{z6}</span>'
|
||
'</td>'
|
||
'<td class="nowrap"><strong>{sz}</strong></td>'
|
||
'<td class="nowrap">{super6}</td>'
|
||
'<td class="nowrap">{spiel77}</td>'
|
||
'</tr>'
|
||
)
|
||
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 = """
|
||
<p class="muted">Treffer: {total}</p>
|
||
<table role="grid">
|
||
<thead>{headers}</thead>
|
||
<tbody>{rows}</tbody>
|
||
</table>
|
||
""".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)
|