Admin-Panel: Schema-Designer, Dropzone mit Auto-Erkennung, Linkliste, Versionen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 11:00:40 +02:00
parent e9b59fe4c8
commit 4e0ba397be
7 changed files with 645 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@@ -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
}
]
}

173
app/main.py Normal file
View File

@@ -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/<slug>")
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/<slug>/<filename>")
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/<slug>")
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("/<slug>/")
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)

155
app/static/admin.css Normal file
View File

@@ -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; }

131
app/storage.py Normal file
View File

@@ -0,0 +1,131 @@
"""Ablage im Dateisystem: Schemas, CSV-Versionen, veroeffentlichte Seiten.
Layout:
data/tables/<slug>/schema.json
data/tables/<slug>/versions/<zeitstempel>.csv
public/<slug>/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)

View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}QR-Tabellen{% endblock %} · Admin</title>
<link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}">
</head>
<body>
<header class="topbar">
<span class="brand">QR-Tabellen</span>
<nav>
<a href="{{ url_for('admin_index') }}">Übersicht</a>
<a href="{{ url_for('admin_new') }}">Neue Tabelle</a>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="flash">
{% for category, message in messages %}
<li class="flash-{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,87 @@
{% extends "admin/base.html" %}
{% block title %}Übersicht{% endblock %}
{% block content %}
<section class="dropzone-section">
<h2>CSV hochladen</h2>
<form method="post" action="{{ url_for('admin_upload') }}" enctype="multipart/form-data" id="upload-form">
<label class="dropzone" id="dropzone">
<input type="file" name="csv" accept=".csv,text/csv" id="csv-input">
<span>Befüllte CSV hier ablegen oder klicken —<br>
die Tabelle wird am Dateinamen bzw. an den Spalten erkannt.</span>
</label>
<div class="upload-row">
<label for="slug-select">Zieltabelle:</label>
<select name="slug" id="slug-select">
<option value="">automatisch erkennen</option>
{% for t in tables %}
<option value="{{ t.slug }}">{{ t.title }}</option>
{% endfor %}
</select>
<button type="submit">Hochladen &amp; veröffentlichen</button>
</div>
</form>
</section>
<section>
<h2>Gehostete Tabellen</h2>
{% if not tables %}
<p class="empty">Noch keine Tabellen — <a href="{{ url_for('admin_new') }}">lege die erste an</a>.</p>
{% endif %}
{% for t in tables %}
<article class="table-card">
<div class="card-qr">{{ qr_svg(t.url) | safe }}</div>
<div class="card-body">
<h3>{{ t.title }}</h3>
<p class="card-url">
{% if t.published %}
<a href="{{ t.url }}">{{ t.url }}</a>
{% else %}
<span class="muted">{{ t.url }} — noch keine CSV hochgeladen</span>
{% endif %}
</p>
<p class="card-meta">
{{ t.versions | length }} Version(en)
{% if t.last_upload %}· letzte: {{ t.last_upload[:-4] }}{% endif %}
</p>
<p class="card-actions">
<a class="btn" href="{{ url_for('admin_template', slug=t.slug) }}">CSV-Vorlage</a>
{% if t.versions %}
<details class="versions">
<summary class="btn">Versionen (CSV-Download)</summary>
<ul>
{% for v in t.versions %}
<li><a href="{{ url_for('admin_download', slug=t.slug, filename=v) }}">{{ v }}</a></li>
{% endfor %}
</ul>
</details>
{% endif %}
<form method="post" action="{{ url_for('admin_delete', slug=t.slug) }}"
onsubmit="return confirm('Tabelle {{ t.slug }} samt aller Versionen löschen?')">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
</p>
</div>
</article>
{% endfor %}
</section>
<script>
const zone = document.getElementById("dropzone");
const input = document.getElementById("csv-input");
const form = document.getElementById("upload-form");
["dragover", "dragenter"].forEach(ev =>
zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev =>
zone.addEventListener(ev, () => zone.classList.remove("drag")));
zone.addEventListener("drop", e => {
e.preventDefault();
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
form.submit();
}
});
input.addEventListener("change", () => { if (input.files.length) form.submit(); });
</script>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "admin/base.html" %}
{% block title %}Neue Tabelle{% endblock %}
{% block content %}
<section>
<h2>Neue Tabelle anlegen</h2>
<form method="post" class="schema-form">
<label class="field">
<span>Titel</span>
<input type="text" name="title" required placeholder="z. B. Projekttage Streetfotografie Stationen"
value="{{ title or '' }}">
</label>
<h3>Spalten</h3>
<div id="columns">
{% 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 %}
<div class="column-row">
<input type="text" name="label" placeholder="Spaltenname" value="{{ col.label }}">
<select name="type">
{% for value, label in types.items() %}
<option value="{{ value }}" {% if col.type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-danger remove-row"></button>
</div>
{% endfor %}
</div>
<p><button type="button" class="btn" id="add-row">+ Spalte</button></p>
<p class="hint">Leere Spaltennamen werden übersprungen. Link-Spalten bekommen
automatisch QR-Codes; mehrere Link-Spalten sind möglich.</p>
<button type="submit" class="btn btn-primary">Tabelle anlegen</button>
</form>
</section>
<script>
const columns = document.getElementById("columns");
document.getElementById("add-row").addEventListener("click", () => {
const row = columns.querySelector(".column-row").cloneNode(true);
row.querySelector("input").value = "";
row.querySelector("select").selectedIndex = 0;
columns.appendChild(row);
});
columns.addEventListener("click", e => {
if (e.target.classList.contains("remove-row") && columns.children.length > 1) {
e.target.closest(".column-row").remove();
}
});
</script>
{% endblock %}