PummelHummel/Alph in 3D-Druck-AG integriert
Quelle: D:/Claude-Projekte/PummelHummel/feedback_modul/ (Standard-Pattern, auch in arduino.lehrstun.de + meta.lehrstun.de erprobt). 1:1 nach 3d.lehrstun.de/feedback_modul/ kopiert. app.py erweitert: - init_feedback_modul-Aufruf mit unseren Adaptern - _alph_user_kontext: Lehrer = admin, SuS = profil mit Vorname+Tier, Anonym = kein Widget - _alph_widget_aktiv: liest einstellung.feedback_widget_aktiv - _alph_admin_unauthorized: Redirect zu admin.admin_login Hart-Aus via ENV ALPH_DEAKTIVIERT=1 weiterhin moeglich. Soft-Aus via einstellung.feedback_widget_aktiv='0' (per Admin-Inline-Edit). DB: - feedback-Tabelle via feedback_modul.schema.DDL angelegt (idempotent) - einstellung.feedback_widget_aktiv auf '1' gesetzt (Alpha-Test-Modus) Test: - Eingeloggter SuS sieht das Alph-Widget auf /lektion/1 - Anonym (kein Login) sieht es nicht (user_kontext gibt None) - Admin sieht es mit Rolle 'admin' Naechster Schritt: Deploy auf Hetzner (3d.lehrstun.de), Markus loggt sich als Test-Schueler ein und gibt Feedback zu den 14 Lektionen.
This commit is contained in:
488
feedback_modul/flask_app.py
Normal file
488
feedback_modul/flask_app.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Alph-Feedback-Modul — schwebende PummelHummel sammelt strukturiertes
|
||||
Schueler-Feedback und liefert Claude-Code-freundliche Exports.
|
||||
|
||||
Schnittstellen-Dokumentation: siehe README.md im selben Verzeichnis.
|
||||
|
||||
Einbindung in eine Flask-App:
|
||||
|
||||
from feedback_modul import init_feedback_modul
|
||||
init_feedback_modul(
|
||||
app,
|
||||
get_db=get_db, # Funktion: () -> sqlite3.Connection
|
||||
app_name="arduino-werkstatt", # Identifikator dieser App
|
||||
ist_admin=lambda: session.get("admin"),
|
||||
user_kontext=lambda: {...}, # Optional, wird zu jedem Feedback gespeichert
|
||||
widget_aktiv=lambda: True, # Optional, kann pro Request entscheiden
|
||||
)
|
||||
|
||||
Im Layout-Template:
|
||||
{% if alph_widget_aktiv %}
|
||||
{{ alph_widget_snippet|safe }}
|
||||
{% endif %}
|
||||
|
||||
Die ganze JS+CSS+SVG ist in static/alph.js self-contained — andere Sites
|
||||
binden einfach <script src=".../alph.js" data-endpoint="..." data-app="..."></script> ein.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, OrderedDict
|
||||
|
||||
from flask import (Blueprint, request, jsonify, render_template, abort,
|
||||
redirect, url_for, Response, current_app)
|
||||
from markupsafe import escape as _html_escape
|
||||
|
||||
from .schema import DDL, KATEGORIEN
|
||||
|
||||
|
||||
# Konfiguration wird beim init_feedback_modul gesetzt:
|
||||
_config = {
|
||||
"get_db": None,
|
||||
"app_name": "unknown",
|
||||
"ist_admin": lambda: False,
|
||||
"user_kontext": lambda: None,
|
||||
"widget_aktiv": lambda: True,
|
||||
# Optional: Callable () -> Response, wenn Admin-Routen ohne Login
|
||||
# aufgerufen werden. Default: 403. Arduino-App nutzt z.B. einen
|
||||
# Redirect zur /admin/login.
|
||||
"admin_unauthorized": None,
|
||||
# Optional: Name eines Parent-Templates fuer admin_liste.html.
|
||||
# Wenn gesetzt, macht das Template {% extends parent_template %}.
|
||||
# Default: None -> standalone HTML mit eigenem minimalen CSS.
|
||||
"parent_template": None,
|
||||
}
|
||||
|
||||
|
||||
# Rate-Limit pro IP: max N pro Minute
|
||||
_rate_buckets = {} # ip -> list[timestamp]
|
||||
_RATE_LIMIT_PER_MIN = 10
|
||||
|
||||
|
||||
def _rate_limited(ip):
|
||||
now = time.time()
|
||||
bucket = _rate_buckets.setdefault(ip, [])
|
||||
bucket[:] = [t for t in bucket if now - t < 60]
|
||||
if len(bucket) >= _RATE_LIMIT_PER_MIN:
|
||||
return True
|
||||
bucket.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _cluster_hash(url_pfad, kategorie, text):
|
||||
"""Hash fuer Duplikat-Erkennung. Erste 30 Zeichen normalisierten Texts."""
|
||||
norm = (text or "").lower().strip()[:30]
|
||||
raw = f"{url_pfad}|{kategorie}|{norm}"
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# Blueprint
|
||||
bp = Blueprint(
|
||||
"feedback_modul", __name__,
|
||||
static_folder="static",
|
||||
static_url_path="/static/feedback",
|
||||
template_folder="templates",
|
||||
)
|
||||
|
||||
|
||||
# === API: Feedback empfangen =========================================
|
||||
|
||||
@bp.route("/api/feedback", methods=["POST"])
|
||||
def api_feedback_post():
|
||||
"""Nimmt Alph-Feedback entgegen.
|
||||
|
||||
Erwartet JSON:
|
||||
{
|
||||
"kategorie": "code"|"link"|"verstehen"|"fehlt"|"sonstiges",
|
||||
"text": "...",
|
||||
"url_pfad": "/lektion/2",
|
||||
"seitentitel": "Lektion 2: Ampel",
|
||||
"element_selector": "main > .card:nth-child(3) pre" (optional),
|
||||
"element_text": "digitalWrite(LED, HIGH);" (optional),
|
||||
"viewport": "375x800" (optional),
|
||||
"sprache": "de" (optional)
|
||||
}
|
||||
|
||||
user_kontext (Tier/Kasten) wird vom Server gesetzt aus user_kontext-Callback.
|
||||
"""
|
||||
if _rate_limited(request.remote_addr or "unknown"):
|
||||
return jsonify({"ok": False, "error": "rate_limit"}), 429
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
kategorie = (data.get("kategorie") or "").strip().lower()
|
||||
text = (data.get("text") or "").strip()
|
||||
url_pfad = (data.get("url_pfad") or "").strip()[:500]
|
||||
|
||||
valid_kats = {k[0] for k in KATEGORIEN}
|
||||
if kategorie not in valid_kats:
|
||||
return jsonify({"ok": False, "error": "kategorie_ungueltig"}), 400
|
||||
if len(text) < 3:
|
||||
return jsonify({"ok": False, "error": "text_zu_kurz"}), 400
|
||||
if len(text) > 2000:
|
||||
text = text[:2000]
|
||||
if not url_pfad:
|
||||
return jsonify({"ok": False, "error": "url_pfad_fehlt"}), 400
|
||||
|
||||
seitentitel = (data.get("seitentitel") or "")[:200]
|
||||
element_selector = (data.get("element_selector") or "")[:300]
|
||||
element_text = (data.get("element_text") or "")[:500]
|
||||
|
||||
# Server-Kontext hat Vorrang. Im Drop-In-Modus (kein Server-Callback)
|
||||
# darf der Client einen user_kontext mitschicken (data-user am Script-Tag).
|
||||
user_kontext = _config["user_kontext"]() or {}
|
||||
if not user_kontext:
|
||||
client_user = data.get("user_kontext")
|
||||
if isinstance(client_user, dict):
|
||||
# Auf simple Werte beschneiden, kein verschachteltes JSON erlauben
|
||||
user_kontext = {
|
||||
str(k)[:40]: (str(v)[:120] if not isinstance(v, (int, float, bool)) else v)
|
||||
for k, v in list(client_user.items())[:12]
|
||||
}
|
||||
technik_kontext = {
|
||||
"viewport": (data.get("viewport") or "")[:20],
|
||||
"sprache": (data.get("sprache") or "")[:10],
|
||||
"ua": (request.headers.get("User-Agent") or "")[:300],
|
||||
"referer": (request.headers.get("Referer") or "")[:500],
|
||||
}
|
||||
|
||||
cluster = _cluster_hash(url_pfad, kategorie, text)
|
||||
|
||||
conn = _config["get_db"]()
|
||||
conn.execute(
|
||||
"""INSERT INTO feedback
|
||||
(app_name, kategorie, text, url_pfad, seitentitel,
|
||||
user_kontext, technik_kontext, element_selector, element_text,
|
||||
cluster_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(_config["app_name"], kategorie, text, url_pfad, seitentitel,
|
||||
json.dumps(user_kontext, ensure_ascii=False),
|
||||
json.dumps(technik_kontext, ensure_ascii=False),
|
||||
element_selector, element_text, cluster),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# === Admin-Views =====================================================
|
||||
|
||||
def _admin_required():
|
||||
"""Prueft Admin-Status. Liefert ggf. eine Response (Redirect),
|
||||
sonst None. Aufrufer muessen das Ergebnis weiterreichen."""
|
||||
if _config["ist_admin"]():
|
||||
return None
|
||||
handler = _config.get("admin_unauthorized")
|
||||
if callable(handler):
|
||||
return handler()
|
||||
abort(403)
|
||||
|
||||
|
||||
@bp.route("/admin/feedback")
|
||||
def admin_feedback_liste():
|
||||
_resp = _admin_required()
|
||||
if _resp is not None:
|
||||
return _resp
|
||||
status_filter = request.args.get("status", "offen")
|
||||
kategorie_filter = request.args.get("kategorie", "")
|
||||
|
||||
where = ["app_name = ?"]
|
||||
params = [_config["app_name"]]
|
||||
|
||||
if status_filter == "offen":
|
||||
where.append("status NOT IN ('erledigt', 'wontfix', 'spam')")
|
||||
elif status_filter and status_filter != "alle":
|
||||
where.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if kategorie_filter:
|
||||
where.append("kategorie = ?")
|
||||
params.append(kategorie_filter)
|
||||
|
||||
sql = (
|
||||
"SELECT * FROM feedback WHERE " + " AND ".join(where)
|
||||
+ " ORDER BY status='neu' DESC, erstellt_am DESC"
|
||||
)
|
||||
conn = _config["get_db"]()
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
eintraege = [dict(r) for r in rows]
|
||||
for e in eintraege:
|
||||
try:
|
||||
e["user"] = json.loads(e["user_kontext"] or "{}")
|
||||
except Exception:
|
||||
e["user"] = {}
|
||||
try:
|
||||
e["technik"] = json.loads(e["technik_kontext"] or "{}")
|
||||
except Exception:
|
||||
e["technik"] = {}
|
||||
|
||||
# Statistik
|
||||
stats = OrderedDict()
|
||||
for status in ["neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"]:
|
||||
stats[status] = conn.execute(
|
||||
"SELECT COUNT(*) FROM feedback WHERE app_name=? AND status=?",
|
||||
(_config["app_name"], status),
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
return render_template(
|
||||
"feedback_modul/admin_liste.html",
|
||||
eintraege=eintraege,
|
||||
kategorien=KATEGORIEN,
|
||||
stats=stats,
|
||||
status_filter=status_filter,
|
||||
kategorie_filter=kategorie_filter,
|
||||
parent_template=_config.get("parent_template"),
|
||||
app_name=_config["app_name"],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/admin/feedback/<int:feedback_id>/status", methods=["POST"])
|
||||
def admin_feedback_status(feedback_id):
|
||||
_resp = _admin_required()
|
||||
if _resp is not None:
|
||||
return _resp
|
||||
neuer_status = request.form.get("status", "")
|
||||
notiz = request.form.get("notiz", "")
|
||||
erlaubt = {"neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"}
|
||||
if neuer_status not in erlaubt:
|
||||
abort(400)
|
||||
conn = _config["get_db"]()
|
||||
conn.execute(
|
||||
"""UPDATE feedback SET status = ?, bearbeitet_notiz = ?,
|
||||
aktualisiert_am = CURRENT_TIMESTAMP WHERE id = ?""",
|
||||
(neuer_status, notiz or None, feedback_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return redirect(request.referrer or url_for("feedback_modul.admin_feedback_liste"))
|
||||
|
||||
|
||||
# === Exports =========================================================
|
||||
|
||||
def _hole_offene_feedbacks():
|
||||
conn = _config["get_db"]()
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM feedback
|
||||
WHERE app_name = ? AND status NOT IN ('erledigt', 'wontfix', 'spam')
|
||||
ORDER BY url_pfad, kategorie, erstellt_am""",
|
||||
(_config["app_name"],),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@bp.route("/admin/feedback/export.json")
|
||||
def admin_feedback_export_json():
|
||||
_resp = _admin_required()
|
||||
if _resp is not None:
|
||||
return _resp
|
||||
eintraege = _hole_offene_feedbacks()
|
||||
for e in eintraege:
|
||||
try:
|
||||
e["user_kontext"] = json.loads(e["user_kontext"] or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
e["technik_kontext"] = json.loads(e["technik_kontext"] or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({
|
||||
"app": _config["app_name"],
|
||||
"exportiert_am": datetime.utcnow().isoformat() + "Z",
|
||||
"anzahl": len(eintraege),
|
||||
"feedbacks": eintraege,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/admin/feedback/export.md")
|
||||
def admin_feedback_export_md():
|
||||
_resp = _admin_required()
|
||||
if _resp is not None:
|
||||
return _resp
|
||||
eintraege = _hole_offene_feedbacks()
|
||||
|
||||
cluster = defaultdict(list)
|
||||
for e in eintraege:
|
||||
cluster[(e["url_pfad"], e["kategorie"], e["cluster_hash"])].append(e)
|
||||
|
||||
kat_label = {k[0]: k[1] for k in KATEGORIEN}
|
||||
kat_emoji = {"code": "🐛", "link": "🔗", "verstehen": "❓", "fehlt": "❌", "sonstiges": "💬"}
|
||||
|
||||
nach_kategorie = defaultdict(list)
|
||||
for key, eintrags in cluster.items():
|
||||
nach_kategorie[key[1]].append((key, eintrags))
|
||||
|
||||
out = []
|
||||
out.append(f"# Alph-Feedback {_config['app_name']} — Stand {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||
out.append("")
|
||||
out.append(f"**{len(eintraege)} offene Feedbacks** in {len(cluster)} Clustern.")
|
||||
out.append("")
|
||||
|
||||
for kat in ["code", "link", "verstehen", "fehlt", "sonstiges"]:
|
||||
clusterliste = nach_kategorie.get(kat, [])
|
||||
if not clusterliste:
|
||||
continue
|
||||
emoji = kat_emoji.get(kat, "•")
|
||||
out.append(f"## {emoji} {kat_label.get(kat, kat)} ({sum(len(c[1]) for c in clusterliste)})")
|
||||
out.append("")
|
||||
for key, eintrags in clusterliste:
|
||||
url, _, _ = key
|
||||
n = len(eintrags)
|
||||
mehrfach = f" — **{n}× gemeldet**" if n > 1 else ""
|
||||
out.append(f"### `{url}`{mehrfach}")
|
||||
out.append("")
|
||||
for e in eintrags:
|
||||
user = {}
|
||||
try:
|
||||
user = json.loads(e["user_kontext"] or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
tech = {}
|
||||
try:
|
||||
tech = json.loads(e["technik_kontext"] or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
team = user.get("tier", "?")
|
||||
kasten = user.get("kasten", "?")
|
||||
rolle = user.get("rolle", "")
|
||||
rolle_str = f" ({rolle})" if rolle else ""
|
||||
viewport = tech.get("viewport", "")
|
||||
out.append(f"- **{team} / Kasten {kasten}**{rolle_str} — *\"{e['text']}\"*")
|
||||
meta = [e["erstellt_am"]]
|
||||
if viewport:
|
||||
meta.append(f"viewport {viewport}")
|
||||
if e.get("element_selector"):
|
||||
meta.append(f"@ `{e['element_selector']}`")
|
||||
out.append(f" <sub>{' · '.join(meta)}</sub>")
|
||||
if e.get("element_text"):
|
||||
out.append(f" <sub>Element-Text: `{e['element_text'][:120]}`</sub>")
|
||||
if e.get("seitentitel"):
|
||||
out.append(f" <sub>Seitentitel: {e['seitentitel']}</sub>")
|
||||
out.append("")
|
||||
return Response("\n".join(out), mimetype="text/markdown; charset=utf-8")
|
||||
|
||||
|
||||
@bp.route("/admin/feedback/claude-prompt.md")
|
||||
def admin_feedback_claude_prompt():
|
||||
"""Direkt-an-Claude-Code-Brief: Anweisung + strukturierte Feedbacks."""
|
||||
_resp = _admin_required()
|
||||
if _resp is not None:
|
||||
return _resp
|
||||
eintraege = _hole_offene_feedbacks()
|
||||
# Wir rufen den Markdown-Builder inline auf — gleiche Request-Lifecycle
|
||||
md_response = admin_feedback_export_md()
|
||||
md_body = md_response.get_data(as_text=True)
|
||||
|
||||
prompt = []
|
||||
prompt.append(f"# Aufgabe fuer Claude Code: Alphatest-Feedback auswerten")
|
||||
prompt.append("")
|
||||
prompt.append(f"Du arbeitest im Repo der App **{_config['app_name']}**. Unten "
|
||||
f"findest du {len(eintraege)} offene Feedbacks von Schuelern aus "
|
||||
f"dem Alphatest, gesammelt vom Alph-PummelHummel-Widget.")
|
||||
prompt.append("")
|
||||
prompt.append("## Bitte arbeite das so ab:")
|
||||
prompt.append("")
|
||||
prompt.append("1. **Cluster lesen.** Mehrfach-Reports am gleichen Pfad sind Prioritaet.")
|
||||
prompt.append("2. **Quelldateien anschauen.** URL `/lektion/2` -> `templates/lektion_detail.html` mit Lektion-Inhalt aus DB. URL `/bauteile` -> `templates/bauteile.html`. Etc.")
|
||||
prompt.append("3. **Symptom vs. Ursache.** Schueler beschreiben oft nur das, was sie sehen. Identifiziere die Ursache im Code, nicht nur den Schein.")
|
||||
prompt.append("4. **Pro Cluster ein Vorschlag:** Datei + Zeile + konkrete Aenderung. Bei Inhalts-Texten: konkreter Re-Wording-Vorschlag.")
|
||||
prompt.append("5. **Unklare Reports markieren.** Lieber zum Nachfragen flaggen als raten.")
|
||||
prompt.append("6. **Spam erkennen.** Quatsch-Reports markiere mit STATUS:spam fuer den Lehrer zum Wegklicken.")
|
||||
prompt.append("")
|
||||
prompt.append("## Antwortformat:")
|
||||
prompt.append("")
|
||||
prompt.append("```")
|
||||
prompt.append("[ID|Cluster] Kategorie · Pfad")
|
||||
prompt.append(" Diagnose: ...")
|
||||
prompt.append(" Fix: Datei foo.html Zeile 42 — alt: '...' neu: '...'")
|
||||
prompt.append(" Status-Vorschlag: in_arbeit | wontfix | spam | nachfragen")
|
||||
prompt.append("```")
|
||||
prompt.append("")
|
||||
prompt.append("---")
|
||||
prompt.append("")
|
||||
prompt.append(md_body)
|
||||
|
||||
return Response("\n".join(prompt), mimetype="text/markdown; charset=utf-8")
|
||||
|
||||
|
||||
# === Widget-Snippet fuer base.html ===================================
|
||||
|
||||
WIDGET_SNIPPET = """\
|
||||
<script src="{static_url}" data-endpoint="{endpoint}" data-app="{app_name}" data-user="{user_json}" defer></script>
|
||||
"""
|
||||
|
||||
|
||||
def render_widget_snippet():
|
||||
"""Liefert den HTML-Snippet, der ins Layout-Template eingebunden wird.
|
||||
|
||||
Wird als Context-Processor jedem Template als alph_widget_snippet zur Verfuegung gestellt.
|
||||
"""
|
||||
if not _config["widget_aktiv"]():
|
||||
return ""
|
||||
user = _config["user_kontext"]() or {}
|
||||
if not user:
|
||||
# Widget nur zeigen wenn ein User-Kontext da ist (eingeloggt oder Admin)
|
||||
return ""
|
||||
user_json_raw = json.dumps(user, ensure_ascii=False)
|
||||
# Volle HTML-Attribut-Escape-Behandlung (auch <, >, &, ')
|
||||
user_json = str(_html_escape(user_json_raw))
|
||||
app_name = str(_html_escape(_config["app_name"]))
|
||||
static_url = url_for("feedback_modul.static", filename="alph.js")
|
||||
endpoint = url_for("feedback_modul.api_feedback_post")
|
||||
return WIDGET_SNIPPET.format(
|
||||
static_url=static_url,
|
||||
endpoint=endpoint,
|
||||
app_name=app_name,
|
||||
user_json=user_json,
|
||||
)
|
||||
|
||||
|
||||
# === Init ============================================================
|
||||
|
||||
def init_feedback_modul(app, *, get_db, app_name, ist_admin=None,
|
||||
user_kontext=None, widget_aktiv=None,
|
||||
admin_unauthorized=None, parent_template=None):
|
||||
"""Registriert das Modul in einer Flask-App.
|
||||
|
||||
Args:
|
||||
app: Flask-Instanz
|
||||
get_db: Callable () -> sqlite3.Connection (mit row_factory!)
|
||||
app_name: Identifier (z.B. "arduino-werkstatt")
|
||||
ist_admin: Callable () -> bool (default: False)
|
||||
user_kontext: Callable () -> dict | None
|
||||
Beispiel: {"tier": "otter", "kasten": 7, "kurs": "2026-T1", "rolle": "schueler"}
|
||||
Widget wird nur angezeigt, wenn dies einen Wert liefert.
|
||||
widget_aktiv: Callable () -> bool (default: True)
|
||||
admin_unauthorized: Callable () -> Response (default: 403)
|
||||
Wird gerufen, wenn ein Admin-Endpoint ohne Login getroffen
|
||||
wird. Typisch: redirect zu /admin/login der Host-App.
|
||||
parent_template: Optional[str] (default: None)
|
||||
Wenn gesetzt, macht das Admin-Template
|
||||
`{% extends parent_template %}`. Das Parent-Template muss
|
||||
einen `{% block content %}` definieren. Sonst standalone.
|
||||
"""
|
||||
_config["get_db"] = get_db
|
||||
_config["app_name"] = app_name
|
||||
if ist_admin is not None:
|
||||
_config["ist_admin"] = ist_admin
|
||||
if user_kontext is not None:
|
||||
_config["user_kontext"] = user_kontext
|
||||
if widget_aktiv is not None:
|
||||
_config["widget_aktiv"] = widget_aktiv
|
||||
if admin_unauthorized is not None:
|
||||
_config["admin_unauthorized"] = admin_unauthorized
|
||||
if parent_template is not None:
|
||||
_config["parent_template"] = parent_template
|
||||
|
||||
# Schema sicherstellen (idempotent)
|
||||
conn = get_db()
|
||||
conn.executescript(DDL)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
||||
# Context-Processor: alph_widget_snippet im Template verfuegbar machen
|
||||
@app.context_processor
|
||||
def _alph_ctx():
|
||||
return {"alph_widget_snippet": render_widget_snippet()}
|
||||
Reference in New Issue
Block a user