From 4e0ba397bee0e23765bf020eb3910561c1f1427b 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] Admin-Panel: Schema-Designer, Dropzone mit Auto-Erkennung, Linkliste, Versionen Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 11 +++ app/main.py | 173 +++++++++++++++++++++++++++++++++ app/static/admin.css | 155 +++++++++++++++++++++++++++++ app/storage.py | 131 +++++++++++++++++++++++++ app/templates/admin/base.html | 30 ++++++ app/templates/admin/index.html | 87 +++++++++++++++++ app/templates/admin/new.html | 58 +++++++++++ 7 files changed, 645 insertions(+) create mode 100644 .claude/launch.json create mode 100644 app/main.py create mode 100644 app/static/admin.css create mode 100644 app/storage.py create mode 100644 app/templates/admin/base.html create mode 100644 app/templates/admin/index.html create mode 100644 app/templates/admin/new.html diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..0682abd --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "qr-tabellen", + "runtimeExecutable": "cmd", + "runtimeArgs": ["/c", "cd /d D:\\Claude-Projekte\\qr-tabellen && venv\\Scripts\\python.exe -m flask --app app.main run --port 5005"], + "port": 5005 + } + ] +} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a92bf37 --- /dev/null +++ b/app/main.py @@ -0,0 +1,173 @@ +"""Admin-Panel (Flask): Schema-Designer, Dropzone, Linkliste, Versionen. + +In Produktion liegt das Panel hinter Nginx Basic Auth; die +oeffentlichen Seiten liefert Nginx direkt aus public/ aus. +""" +from __future__ import annotations + +import io +import os + +from flask import ( + Flask, + abort, + flash, + redirect, + render_template, + request, + send_file, + url_for, +) + +from . import engine, storage + +app = Flask(__name__) +# Secret nur fuer Flash-Messages; bei Neustart verfallende Session ist ok. +app.secret_key = os.environ.get("QT_SECRET_KEY") or os.urandom(16) + + +@app.get("/") +def root(): + return redirect(url_for("admin_index")) + + +@app.get("/admin/") +def admin_index(): + return render_template( + "admin/index.html", + tables=storage.list_tables(), + qr_svg=engine.qr_svg, + ) + + +@app.route("/admin/neu", methods=["GET", "POST"]) +def admin_new(): + if request.method == "GET": + return render_template("admin/new.html", types=engine.TYPE_LABELS) + + title = request.form.get("title", "").strip() + labels = [l.strip() for l in request.form.getlist("label")] + types = request.form.getlist("type") + + columns = [] + seen_keys = set() + for label, ctype in zip(labels, types): + if not label: + continue + if ctype not in engine.COLUMN_TYPES: + ctype = "text" + key = engine.column_key(label) + while key in seen_keys: + key += "_2" + seen_keys.add(key) + columns.append({"key": key, "label": label, "type": ctype}) + + error = None + if not title: + error = "Bitte einen Titel angeben." + elif not columns: + error = "Mindestens eine Spalte anlegen." + else: + slug = engine.slugify(title) + if storage.load_schema(slug) is not None: + error = f'Es gibt schon eine Tabelle mit dem Slug "{slug}".' + + if error: + flash(error, "error") + return render_template( + "admin/new.html", + types=engine.TYPE_LABELS, + title=title, + columns=columns, + ) + + storage.save_schema({"slug": slug, "title": title, "columns": columns}) + flash(f'Tabelle "{title}" angelegt. Jetzt die CSV-Vorlage herunterladen.', "ok") + return redirect(url_for("admin_index")) + + +@app.get("/admin/vorlage/") +def admin_template(slug): + schema = storage.load_schema(slug) + if schema is None: + abort(404) + return send_file( + io.BytesIO(engine.template_csv(schema)), + mimetype="text/csv", + as_attachment=True, + download_name=f"{slug}.csv", + ) + + +@app.post("/admin/upload") +def admin_upload(): + file = request.files.get("csv") + if file is None or not file.filename: + flash("Keine Datei ausgewaehlt.", "error") + return redirect(url_for("admin_index")) + + raw = file.read() + header, _ = engine.parse_csv(raw) + + slug = request.form.get("slug", "").strip() + if not slug: # automatisch erkennen + slug = storage.find_table(file.filename, header) + if not slug: + flash( + "Tabelle nicht erkannt — bitte im Auswahlfeld die Zieltabelle " + "waehlen und erneut hochladen.", + "error", + ) + return redirect(url_for("admin_index")) + + ok, errors, warnings, version = storage.publish_upload(slug, raw) + for w in warnings: + flash(w, "warn") + if not ok: + for e in errors[:10]: + flash(e, "error") + if len(errors) > 10: + flash(f"... und {len(errors) - 10} weitere Fehler.", "error") + flash("Nichts veroeffentlicht — bitte CSV korrigieren und erneut hochladen.", "error") + else: + flash( + f'"{slug}" aktualisiert (Version {version}) und veroeffentlicht: ' + f"{storage.public_url(slug)}", + "ok", + ) + return redirect(url_for("admin_index")) + + +@app.get("/admin/csv//") +def admin_download(slug, filename): + raw = storage.read_version(slug, filename) + if raw is None: + abort(404) + return send_file( + io.BytesIO(raw), + mimetype="text/csv", + as_attachment=True, + download_name=f"{slug}_{filename}", + ) + + +@app.post("/admin/loeschen/") +def admin_delete(slug): + if storage.load_schema(slug) is None: + abort(404) + storage.delete_table(slug) + flash(f'Tabelle "{slug}" samt Versionen geloescht.', "ok") + return redirect(url_for("admin_index")) + + +@app.get("//") +def public_page(slug): + """Nur fuer die lokale Entwicklung — in Produktion macht das Nginx.""" + path = storage.PUBLIC_DIR / slug / "index.html" + if not path.exists(): + abort(404) + return send_file(path.resolve()) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/app/static/admin.css b/app/static/admin.css new file mode 100644 index 0000000..f055c29 --- /dev/null +++ b/app/static/admin.css @@ -0,0 +1,155 @@ +/* Admin-Panel — nutzt dieselben Design-Tokens wie die Tabellen-Seiten. */ +: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-border: #ddd8cc; + --color-danger: #a33a2a; + --color-ok: #2c6e49; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--color-paper); + color: var(--color-ink); + font-family: var(--font-body); + line-height: 1.45; +} + +.topbar { + display: flex; + align-items: baseline; + gap: 1.5rem; + padding: 0.7rem 1.2rem; + background: var(--color-ink); + color: var(--color-paper); +} + +.brand { font-family: var(--font-heading); font-size: 1.15rem; } + +.topbar nav a { + color: var(--color-paper); + opacity: 0.85; + text-decoration: none; + margin-right: 1rem; +} +.topbar nav a:hover { opacity: 1; text-decoration: underline; } + +main { max-width: 900px; margin: 0 auto; padding: 1rem 1.2rem 3rem; } + +h2, h3 { font-family: var(--font-heading); } + +.flash { list-style: none; padding: 0; margin: 1rem 0; } +.flash li { + padding: 0.5rem 0.8rem; + border-left: 4px solid var(--color-muted); + background: #fff; + margin-bottom: 0.3rem; + font-size: 0.9rem; +} +.flash-ok { border-color: var(--color-ok); } +.flash-warn { border-color: #c9931f; } +.flash-error { border-color: var(--color-danger); } + +/* Dropzone */ +.dropzone { + display: block; + border: 2px dashed var(--color-border); + border-radius: 8px; + background: #fff; + padding: 2rem 1rem; + text-align: center; + color: var(--color-muted); + cursor: pointer; +} +.dropzone.drag { border-color: var(--color-accent); color: var(--color-accent); } +.dropzone input { display: none; } + +.upload-row { + display: flex; + align-items: center; + gap: 0.6rem; + margin-top: 0.6rem; + flex-wrap: wrap; +} + +/* Tabellen-Karten (Linkliste) */ +.table-card { + display: flex; + gap: 1rem; + background: #fff; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1rem; + margin-bottom: 0.8rem; +} +.card-qr svg { width: 84px; height: 84px; } +.card-body { flex: 1; min-width: 0; } +.card-body h3 { margin: 0 0 0.2rem; } +.card-url a { color: var(--color-accent); word-break: break-all; } +.card-url, .card-meta { margin: 0.15rem 0; font-size: 0.88rem; } +.card-meta, .muted { color: var(--color-muted); } +.card-actions { + display: flex; + align-items: flex-start; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.6rem; +} +.card-actions form { margin: 0; } + +.versions ul { margin: 0.4rem 0 0; padding-left: 1.2rem; font-size: 0.85rem; } +.versions summary { list-style: none; } + +/* Buttons & Formulare */ +.btn, button { + font-family: var(--font-body); + font-size: 0.88rem; + padding: 0.35rem 0.8rem; + border: 1px solid var(--color-border); + border-radius: 5px; + background: #fff; + color: var(--color-ink); + text-decoration: none; + cursor: pointer; + display: inline-block; +} +.btn:hover, button:hover { border-color: var(--color-accent); color: var(--color-accent); } +.btn-primary, button[type="submit"] { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} +.btn-primary:hover, button[type="submit"]:hover { color: #fff; opacity: 0.92; } +.btn-danger, button.btn-danger[type="submit"] { + background: #fff; + border-color: var(--color-border); + color: var(--color-danger); +} +.btn-danger:hover, button.btn-danger[type="submit"]:hover { + border-color: var(--color-danger); + color: var(--color-danger); + opacity: 1; +} + +.schema-form .field { display: block; margin-bottom: 1rem; } +.schema-form .field span { display: block; font-size: 0.85rem; color: var(--color-muted); } +.schema-form input[type="text"], .schema-form select, .upload-row select { + font-family: var(--font-body); + font-size: 0.95rem; + padding: 0.35rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 5px; + background: #fff; +} +.schema-form .field input { width: 100%; max-width: 34rem; } + +.column-row { display: flex; gap: 0.5rem; margin-bottom: 0.4rem; } +.column-row input { flex: 1; max-width: 20rem; } + +.hint, .empty { color: var(--color-muted); font-size: 0.88rem; } diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..38f7ff2 --- /dev/null +++ b/app/storage.py @@ -0,0 +1,131 @@ +"""Ablage im Dateisystem: Schemas, CSV-Versionen, veroeffentlichte Seiten. + +Layout: + data/tables//schema.json + data/tables//versions/.csv + public//index.html (statisch, liefert Nginx aus) +""" +from __future__ import annotations + +import json +import os +import shutil +from datetime import datetime +from pathlib import Path + +from . import engine + +DATA_DIR = Path(os.environ.get("QT_DATA_DIR", "data")) +PUBLIC_DIR = Path(os.environ.get("QT_PUBLIC_DIR", "public")) +BASE_URL = os.environ.get("QT_BASE_URL", "http://localhost:5000").rstrip("/") + + +def _table_dir(slug: str) -> Path: + return DATA_DIR / "tables" / slug + + +def public_url(slug: str) -> str: + return f"{BASE_URL}/{slug}/" + + +def load_schema(slug: str) -> dict | None: + path = _table_dir(slug) / "schema.json" + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def save_schema(schema: dict) -> None: + table_dir = _table_dir(schema["slug"]) + (table_dir / "versions").mkdir(parents=True, exist_ok=True) + (table_dir / "schema.json").write_text( + json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +def list_tables() -> list[dict]: + """Alle Tabellen mit Versions-Info, neueste zuerst.""" + tables_dir = DATA_DIR / "tables" + if not tables_dir.exists(): + return [] + tables = [] + for table_dir in sorted(tables_dir.iterdir()): + schema = load_schema(table_dir.name) + if schema is None: + continue + versions = list_versions(schema["slug"]) + tables.append( + { + "schema": schema, + "slug": schema["slug"], + "title": schema["title"], + "url": public_url(schema["slug"]), + "versions": versions, + "last_upload": versions[0] if versions else None, + "published": (PUBLIC_DIR / schema["slug"] / "index.html").exists(), + } + ) + tables.sort(key=lambda t: t["last_upload"] or "", reverse=True) + return tables + + +def list_versions(slug: str) -> list[str]: + versions_dir = _table_dir(slug) / "versions" + if not versions_dir.exists(): + return [] + return sorted((p.name for p in versions_dir.glob("*.csv")), reverse=True) + + +def read_version(slug: str, filename: str) -> bytes | None: + path = _table_dir(slug) / "versions" / Path(filename).name + return path.read_bytes() if path.exists() else None + + +def find_table(filename: str, header: list[str]) -> str | None: + """Upload einer Tabelle zuordnen: erst Dateiname, dann Header-Signatur.""" + tables = list_tables() + stem = engine.slugify(Path(filename).stem) + for t in tables: + if stem == t["slug"] or stem.startswith(t["slug"]): + return t["slug"] + norm_header = {" ".join(h.strip().lower().split()) for h in header if h.strip()} + matches = [ + t["slug"] + for t in tables + if {" ".join(c["label"].strip().lower().split()) for c in t["schema"]["columns"]} + <= norm_header + ] + return matches[0] if len(matches) == 1 else None + + +def publish_upload(slug: str, raw: bytes) -> tuple[bool, list[str], list[str], str]: + """CSV validieren, als Version ablegen und die Seite neu veroeffentlichen. + + Rueckgabe: (ok, errors, warnings, version_name). Bei Fehlern wird + nichts gespeichert und nichts veroeffentlicht. + """ + schema = load_schema(slug) + if schema is None: + return False, [f'Tabelle "{slug}" existiert nicht.'], [], "" + + header, rows = engine.parse_csv(raw) + records, errors, warnings = engine.validate_and_map(schema, header, rows) + if errors: + return False, errors, warnings, "" + + stamp = datetime.now() + version_name = stamp.strftime("%Y-%m-%d_%H-%M-%S") + ".csv" + (_table_dir(slug) / "versions" / version_name).write_bytes(raw) + + html = engine.render_page( + schema, records, version_label=stamp.strftime("%d.%m.%Y, %H:%M Uhr") + ) + page_dir = PUBLIC_DIR / slug + page_dir.mkdir(parents=True, exist_ok=True) + (page_dir / "index.html").write_text(html, encoding="utf-8") + return True, [], warnings, version_name + + +def delete_table(slug: str) -> None: + shutil.rmtree(_table_dir(slug), ignore_errors=True) + shutil.rmtree(PUBLIC_DIR / slug, ignore_errors=True) diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html new file mode 100644 index 0000000..fc7e008 --- /dev/null +++ b/app/templates/admin/base.html @@ -0,0 +1,30 @@ + + + + + +{% block title %}QR-Tabellen{% endblock %} · Admin + + + +
+ QR-Tabellen + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html new file mode 100644 index 0000000..a04cbda --- /dev/null +++ b/app/templates/admin/index.html @@ -0,0 +1,87 @@ +{% extends "admin/base.html" %} +{% block title %}Übersicht{% endblock %} +{% block content %} + +
+

CSV hochladen

+
+ +
+ + + +
+
+
+ +
+

Gehostete Tabellen

+ {% if not tables %} +

Noch keine Tabellen — lege die erste an.

+ {% endif %} + + {% for t in tables %} +
+
{{ qr_svg(t.url) | safe }}
+
+

{{ t.title }}

+

+ {% if t.published %} + {{ t.url }} + {% else %} + {{ t.url }} — noch keine CSV hochgeladen + {% endif %} +

+

+ {{ t.versions | length }} Version(en) + {% if t.last_upload %}· letzte: {{ t.last_upload[:-4] }}{% endif %} +

+

+ CSV-Vorlage + {% if t.versions %} +

+ Versionen (CSV-Download) +
    + {% for v in t.versions %} +
  • {{ v }}
  • + {% endfor %} +
+
+ {% endif %} +
+ +
+

+
+
+ {% endfor %} +
+ + +{% endblock %} diff --git a/app/templates/admin/new.html b/app/templates/admin/new.html new file mode 100644 index 0000000..1bd0075 --- /dev/null +++ b/app/templates/admin/new.html @@ -0,0 +1,58 @@ +{% extends "admin/base.html" %} +{% block title %}Neue Tabelle{% endblock %} +{% block content %} + +
+

Neue Tabelle anlegen

+
+ + +

Spalten

+
+ {% set prefill = columns or [ + {"label": "Nr.", "type": "number"}, + {"label": "Titel", "type": "text"}, + {"label": "Beschreibung", "type": "text_long"}, + {"label": "Link", "type": "url"}, + {"label": "Bewertung", "type": "rating_5"}, + ] %} + {% for col in prefill %} +
+ + + +
+ {% endfor %} +
+

+ +

Leere Spaltennamen werden übersprungen. Link-Spalten bekommen + automatisch QR-Codes; mehrere Link-Spalten sind möglich.

+ + +
+
+ + +{% endblock %}