Files
3d.lehrstun.de/feedback_modul/flask_app.py
Hans Puettmann 66c52cc8b1 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.
2026-05-25 15:29:39 +02:00

489 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()}