From e9b59fe4c858e20e1265dd629e5f871f824412de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herbert=20P=C3=BCttmann?= Date: Sun, 5 Jul 2026 11:00:40 +0200 Subject: [PATCH] Kern-Engine: CSV parsen/validieren, QR-SVGs, HTML-Rendering + Selbsttest Co-Authored-By: Claude Fable 5 --- app/__init__.py | 0 app/engine.py | 187 +++++++++++++++++++++++++++++++++++++++ app/static/table.css | 117 ++++++++++++++++++++++++ app/templates/table.html | 45 ++++++++++ tests/test_engine.py | 95 ++++++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/engine.py create mode 100644 app/static/table.css create mode 100644 app/templates/table.html create mode 100644 tests/test_engine.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/engine.py b/app/engine.py new file mode 100644 index 0000000..7954564 --- /dev/null +++ b/app/engine.py @@ -0,0 +1,187 @@ +"""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") diff --git a/app/static/table.css b/app/static/table.css new file mode 100644 index 0000000..4460dff --- /dev/null +++ b/app/static/table.css @@ -0,0 +1,117 @@ +/* Design-Tokens — zentrale Stellschrauben, Feintuning spaeter. + Fonts: Systemstack als Fallback; Source Serif 4 / Fira Sans bei Bedarf + lokal hosten (DSGVO/Schulkontext — kein Google-CDN). */ +:root { + --font-heading: "Source Serif 4", Georgia, "Times New Roman", serif; + --font-body: "Fira Sans", "Segoe UI", system-ui, sans-serif; + --color-paper: #fbfaf7; + --color-ink: #26221c; + --color-muted: #6f6a60; + --color-accent: #1f5e79; + --color-stripe: #f2efe8; + --color-border: #ddd8cc; + --color-stars: #c9931f; + --qr-size: 96px; + --content-max: 1100px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--color-paper); + color: var(--color-ink); + font-family: var(--font-body); + line-height: 1.45; +} + +main { + max-width: var(--content-max); + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +.page-head h1 { + font-family: var(--font-heading); + font-size: clamp(1.5rem, 4vw, 2.2rem); + margin: 0.5rem 0 0.2rem; +} + +.meta { + color: var(--color-muted); + font-size: 0.85rem; + margin: 0 0 1rem; +} + +.table-wrap { overflow-x: auto; } + +table { + border-collapse: collapse; + width: 100%; + background: #fff; + border: 1px solid var(--color-border); +} + +th { + font-family: var(--font-heading); + text-align: left; + font-size: 0.95rem; + background: var(--color-ink); + color: var(--color-paper); + position: sticky; + top: 0; +} + +th, td { + padding: 0.6rem 0.75rem; + vertical-align: top; + border-bottom: 1px solid var(--color-border); +} + +tbody tr:nth-child(even) { background: var(--color-stripe); } + +.t-number { text-align: right; white-space: nowrap; width: 1%; } +.t-date { white-space: nowrap; } +.t-text_long { white-space: pre-line; max-width: 46ch; } +td.t-url { text-align: center; width: calc(var(--qr-size) + 1.5rem); } + +.qr-cell { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + text-decoration: none; +} + +.qr { display: block; } +.qr svg { width: var(--qr-size); height: var(--qr-size); display: block; } + +.qr-link { + font-size: 0.72rem; + color: var(--color-accent); + word-break: break-all; + max-width: calc(var(--qr-size) + 2rem); +} + +.stars { + color: var(--color-stars); + font-size: 1.1rem; + letter-spacing: 0.1em; + white-space: nowrap; +} + +.page-foot { + margin-top: 1.5rem; + color: var(--color-muted); + font-size: 0.78rem; +} + +@media (max-width: 700px) { + th, td { padding: 0.45rem 0.5rem; font-size: 0.9rem; } +} + +@media print { + body { background: #fff; } + th { background: #fff; color: var(--color-ink); border-bottom: 2px solid var(--color-ink); } + .page-foot { display: none; } +} diff --git a/app/templates/table.html b/app/templates/table.html new file mode 100644 index 0000000..1bd1ada --- /dev/null +++ b/app/templates/table.html @@ -0,0 +1,45 @@ + + + + + +{{ schema.title }} + + + +
+
+

{{ schema.title }}

+ {% if version_label %}

Stand: {{ version_label }}

{% endif %} +
+
+ + + + {% for col in columns %}{% endfor %} + + + + {% for row in rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ col.label }}
+ {%- if cell.type == "url" and cell.text -%} + + {{ cell.qr | safe }} + {{ cell.short }} + + {%- elif cell.type == "rating_5" and cell.stars -%} + {{ cell.stars }} + {%- else -%}{{ cell.text }}{%- endif -%} +
+
+ +
+ + diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..81a110d --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,95 @@ +"""Schneller Selbsttest der Kern-Engine (ohne Framework, ohne pytest). + +Aufruf: python tests/test_engine.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app import engine + +SCHEMA = { + "slug": "test-stationen", + "title": "Test-Stationen", + "columns": [ + {"key": "nr", "label": "Nr.", "type": "number"}, + {"key": "titel", "label": "Titel", "type": "text"}, + {"key": "link", "label": "Link", "type": "url"}, + {"key": "bewertung", "label": "Bewertung", "type": "rating_5"}, + ], +} + + +def test_slugify(): + assert engine.slugify("Projekttage Straßenfotografie 2026!") == "projekttage-strassenfotografie-2026" + assert engine.column_key("Nr.") == "nr" + + +def test_parse_semikolon_bom(): + raw = "Nr.;Titel;Link;Bewertung\r\n1;Kamera hält;https://example.org/a;4\r\n".encode("utf-8-sig") + header, rows = engine.parse_csv(raw) + assert header == ["Nr.", "Titel", "Link", "Bewertung"] + assert rows == [["1", "Kamera hält", "https://example.org/a", "4"]] + + +def test_parse_cp1252_komma(): + raw = "Nr.,Titel,Link,Bewertung\n2,Straße üben,https://example.org/b,5\n".encode("cp1252") + header, rows = engine.parse_csv(raw) + assert rows[0][1] == "Straße üben" + + +def test_validate_ok_und_fehler(): + header = ["Nr.", "Titel", "Link", "Bewertung"] + records, errors, warnings = engine.validate_and_map( + SCHEMA, header, [["1", "A", "https://example.org", "3"], ["2", "B", "", ""]] + ) + assert not errors and len(records) == 2 + + _, errors, _ = engine.validate_and_map( + SCHEMA, header, [["x", "A", "example.org", "7"]] + ) + assert len(errors) == 3 # Zahl, URL, Bewertung + + _, errors, _ = engine.validate_and_map(SCHEMA, ["Nr.", "Titel"], []) + assert any("fehlt" in e for e in errors) + + +def test_warnung_unbekannte_spalte(): + header = ["Nr.", "Titel", "Link", "Bewertung", "Notizen"] + _, errors, warnings = engine.validate_and_map(SCHEMA, header, [["1", "A", "", ""]]) + assert not errors + assert any("Notizen" in w for w in warnings) + + +def test_render(): + records, errors, _ = engine.validate_and_map( + SCHEMA, + ["Nr.", "Titel", "Link", "Bewertung"], + [["1", "Goldener Schnitt", "https://youtu.be/abc123", "4"]], + ) + assert not errors + html = engine.render_page(SCHEMA, records, version_label="2026-07-05 12:00") + assert "