Files
qr-tabellen/app/engine.py

188 lines
6.2 KiB
Python

"""Kern-Engine: Schema + CSV -> validierte Daten -> gerenderte HTML-Seite.
Bewusst framework-frei gehalten, damit sie ohne Flask testbar bleibt.
"""
from __future__ import annotations
import csv
import io
import re
from datetime import datetime
from pathlib import Path
import segno
from jinja2 import Environment, FileSystemLoader, select_autoescape
COLUMN_TYPES = ("text", "text_long", "number", "date", "url", "rating_5")
TYPE_LABELS = {
"text": "Text (kurz)",
"text_long": "Text (lang)",
"number": "Zahl",
"date": "Datum",
"url": "Link (mit QR-Code)",
"rating_5": "Bewertung (1-5 Sterne)",
}
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
def slugify(text: str) -> str:
"""Titel -> URL-tauglicher Slug (Umlaute transliteriert)."""
text = text.strip().lower()
for src, dst in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
text = text.replace(src, dst)
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
def column_key(label: str) -> str:
return slugify(label).replace("-", "_") or "spalte"
def _norm_label(label: str) -> str:
return " ".join(label.strip().lower().split())
def _decode(raw: bytes) -> str:
# Excel (Windows) liefert oft cp1252, "CSV UTF-8" liefert BOM.
for encoding in ("utf-8-sig", "cp1252"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace")
def _sniff_delimiter(text: str) -> str:
first_line = text.splitlines()[0] if text.splitlines() else ""
counts = {d: first_line.count(d) for d in (";", ",", "\t")}
best = max(counts, key=counts.get) # deutsches Excel: Semikolon
return best if counts[best] > 0 else ";"
def parse_csv(raw: bytes) -> tuple[list[str], list[list[str]]]:
"""Rohbytes -> (Kopfzeile, Datenzeilen). Leerzeilen werden verworfen."""
text = _decode(raw)
reader = csv.reader(io.StringIO(text), delimiter=_sniff_delimiter(text))
rows = [row for row in reader if any(cell.strip() for cell in row)]
if not rows:
return [], []
return [cell.strip() for cell in rows[0]], rows[1:]
def _check_cell(col: dict, value: str) -> str | None:
if not value:
return None # leere Zellen sind erlaubt
if col["type"] == "url" and not re.fullmatch(r"https?://\S+", value):
return "keine gueltige URL (muss mit http:// oder https:// beginnen)"
if col["type"] == "rating_5" and (not value.isdigit() or not 1 <= int(value) <= 5):
return "Bewertung muss eine ganze Zahl von 1 bis 5 sein"
if col["type"] == "number":
try:
float(value.replace(",", "."))
except ValueError:
return "keine Zahl"
return None
def validate_and_map(schema: dict, header: list[str], rows: list[list[str]]):
"""CSV-Inhalt gegen das Schema pruefen.
Rueckgabe: (records, errors, warnings) — records ist eine Liste
von Dicts key -> Zellwert, nur gefuellt wenn errors leer ist.
"""
errors: list[str] = []
warnings: list[str] = []
norm_header = [_norm_label(h) for h in header]
col_index: dict[str, int] = {}
for col in schema["columns"]:
try:
col_index[col["key"]] = norm_header.index(_norm_label(col["label"]))
except ValueError:
errors.append(f'Spalte "{col["label"]}" fehlt in der CSV.')
known = {_norm_label(c["label"]) for c in schema["columns"]}
for h in header:
if _norm_label(h) not in known:
warnings.append(f'Unbekannte Spalte "{h}" wird ignoriert.')
if errors:
return [], errors, warnings
records: list[dict] = []
for lineno, row in enumerate(rows, start=2):
record = {}
for col in schema["columns"]:
idx = col_index[col["key"]]
value = row[idx].strip() if idx < len(row) else ""
problem = _check_cell(col, value)
if problem:
errors.append(f'Zeile {lineno}, Spalte "{col["label"]}": {problem}')
record[col["key"]] = value
records.append(record)
if errors:
return [], errors, warnings
return records, errors, warnings
def qr_svg(data: str) -> str:
"""QR-Code als Inline-SVG (skaliert per CSS, kein width/height-Attribut)."""
buf = io.BytesIO()
segno.make(data, error="m").save(
buf, kind="svg", xmldecl=False, omitsize=True, border=1, dark="#26221c"
)
return buf.getvalue().decode("utf-8")
def _shorten_url(url: str, max_len: int = 42) -> str:
display = re.sub(r"^https?://(www\.)?", "", url).rstrip("/")
return display if len(display) <= max_len else display[: max_len - 1] + ""
def _build_cells(schema: dict, records: list[dict]) -> list[list[dict]]:
rows = []
for record in records:
cells = []
for col in schema["columns"]:
value = record[col["key"]]
cell = {"type": col["type"], "text": value}
if col["type"] == "url" and value:
cell["qr"] = qr_svg(value)
cell["short"] = _shorten_url(value)
if col["type"] == "rating_5" and value:
n = int(value)
cell["stars"] = "" * n + "" * (5 - n)
cells.append(cell)
rows.append(cells)
return rows
def render_page(schema: dict, records: list[dict], version_label: str = "") -> str:
"""Fertige, in sich geschlossene HTML-Seite (CSS eingebettet)."""
css = (STATIC_DIR / "table.css").read_text(encoding="utf-8")
return _env.get_template("table.html").render(
schema=schema,
columns=schema["columns"],
rows=_build_cells(schema, records),
css=css,
version_label=version_label,
generated=datetime.now().strftime("%d.%m.%Y %H:%M"),
)
def template_csv(schema: dict) -> bytes:
"""Leere CSV-Vorlage: Semikolon + UTF-8-BOM, damit Excel sie direkt oeffnet."""
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";", lineterminator="\r\n")
writer.writerow([col["label"] for col in schema["columns"]])
return buf.getvalue().encode("utf-8-sig")