Kern-Engine: CSV parsen/validieren, QR-SVGs, HTML-Rendering + Selbsttest
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
187
app/engine.py
Normal file
187
app/engine.py
Normal file
@@ -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")
|
||||
117
app/static/table.css
Normal file
117
app/static/table.css
Normal file
@@ -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; }
|
||||
}
|
||||
45
app/templates/table.html
Normal file
45
app/templates/table.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ schema.title }}</title>
|
||||
<style>{{ css }}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="page-head">
|
||||
<h1>{{ schema.title }}</h1>
|
||||
{% if version_label %}<p class="meta">Stand: {{ version_label }}</p>{% endif %}
|
||||
</header>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{% for col in columns %}<th class="t-{{ col.type }}">{{ col.label }}</th>{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td class="t-{{ cell.type }}">
|
||||
{%- if cell.type == "url" and cell.text -%}
|
||||
<a href="{{ cell.text }}" class="qr-cell">
|
||||
<span class="qr">{{ cell.qr | safe }}</span>
|
||||
<span class="qr-link">{{ cell.short }}</span>
|
||||
</a>
|
||||
{%- elif cell.type == "rating_5" and cell.stars -%}
|
||||
<span class="stars" aria-label="{{ cell.text }} von 5 Sternen">{{ cell.stars }}</span>
|
||||
{%- else -%}{{ cell.text }}{%- endif -%}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer class="page-foot">Erzeugt am {{ generated }} · QR-Tabellen</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
95
tests/test_engine.py
Normal file
95
tests/test_engine.py
Normal file
@@ -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 "<svg" in html # QR-Code eingebettet
|
||||
assert "Test-Stationen" in html
|
||||
assert "★★★★☆" in html
|
||||
assert "https://youtu.be/abc123" in html
|
||||
|
||||
|
||||
def test_template_csv():
|
||||
raw = engine.template_csv(SCHEMA)
|
||||
assert raw.startswith("".encode("utf-8")) # BOM fuer Excel
|
||||
assert b"Nr.;Titel;Link;Bewertung" in raw
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failed = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"OK {name}")
|
||||
except AssertionError as exc:
|
||||
failed += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
sys.exit(1 if failed else 0)
|
||||
Reference in New Issue
Block a user