Admin-Panel: Schema-Designer, Dropzone mit Auto-Erkennung, Linkliste, Versionen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
173
app/main.py
Normal file
173
app/main.py
Normal 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)
|
||||
Reference in New Issue
Block a user