Lotto2PY/6aus49APP/app.py
2025-10-05 10:53:22 +02:00

303 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)