From 66c52cc8b1b17fb63a8f233f38ce535c895f2a9f Mon Sep 17 00:00:00 2001 From: Hans Puettmann Date: Mon, 25 May 2026 15:29:39 +0200 Subject: [PATCH] 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. --- app.py | 62 ++- feedback_modul/__init__.py | 31 ++ feedback_modul/fastapi_app.py | 450 ++++++++++++++++ feedback_modul/flask_app.py | 488 ++++++++++++++++++ feedback_modul/schema.py | 37 ++ feedback_modul/static/alph.js | 444 ++++++++++++++++ .../templates/feedback_modul/admin_liste.html | 163 ++++++ 7 files changed, 1674 insertions(+), 1 deletion(-) create mode 100644 feedback_modul/__init__.py create mode 100644 feedback_modul/fastapi_app.py create mode 100644 feedback_modul/flask_app.py create mode 100644 feedback_modul/schema.py create mode 100644 feedback_modul/static/alph.js create mode 100644 feedback_modul/templates/feedback_modul/admin_liste.html diff --git a/app.py b/app.py index be018e7..6f56932 100644 --- a/app.py +++ b/app.py @@ -15,8 +15,9 @@ import os from pathlib import Path from dotenv import load_dotenv -from flask import Flask, render_template, request +from flask import Flask, render_template, request, session, redirect, url_for +from database import get_db from tiere import tier_emoji, tier_name # Routes-Blueprints @@ -37,6 +38,65 @@ app.register_blueprint(bp_admin) app.register_blueprint(bp_api) +# =========================================================================== +# Alph-PummelHummel-Feedback-Modul (Pattern aus arduino.lehrstun.de) +# =========================================================================== +# Zwei-Stufen-Schalter: +# 1) Soft-Aus (Default-DB): einstellung.feedback_widget_aktiv = '0' +# → Hummel unsichtbar, Modul ist aber geladen (Routen aktiv). +# Im Admin-Panel umschaltbar, kein Restart noetig. +# 2) Hart-Aus: ENV ALPH_DEAKTIVIERT=1 → der ganze Init-Block uebersprungen, +# keine Routen, kein Context-Processor. Fuer Notfall. Restart noetig. + +def _alph_get_einstellung(schluessel: str, default: str = "0") -> str: + """Liefert einen Einstellungs-Wert aus der DB als String.""" + conn = get_db() + row = conn.execute( + "SELECT wert FROM einstellung WHERE schluessel = ?", (schluessel,) + ).fetchone() + conn.close() + return row["wert"] if row else default + + +def _alph_user_kontext(): + """Liefert den User-Kontext fuer das Alph-Widget — bestimmt zugleich, + OB das Widget angezeigt wird (None → kein Widget). + """ + if session.get("admin"): + return {"rolle": "admin", "tier": "lehrer", "kasten": "-"} + if not session.get("profil_id"): + return None + return { + "rolle": "schueler", + "tier": session.get("tier", "?"), + "kasten": session.get("vorname", "?"), # bei uns: Vorname statt Kasten-Nummer + "profil_id": session.get("profil_id"), + } + + +def _alph_widget_aktiv(): + return _alph_get_einstellung("feedback_widget_aktiv", "0") == "1" + + +def _alph_admin_unauthorized(): + return redirect(url_for("admin.admin_login")) + + +if os.environ.get("ALPH_DEAKTIVIERT", "0") != "1": + from feedback_modul import init_feedback_modul # noqa: E402 + + init_feedback_modul( + app, + get_db=get_db, + app_name="3d-druck-ag", + ist_admin=lambda: bool(session.get("admin")), + user_kontext=_alph_user_kontext, + widget_aktiv=_alph_widget_aktiv, + admin_unauthorized=_alph_admin_unauthorized, + parent_template="base.html", + ) + + # =========================================================================== # Markdown-Filter fuer Templates (Pattern wie arduino.lehrstun.de) # =========================================================================== diff --git a/feedback_modul/__init__.py b/feedback_modul/__init__.py new file mode 100644 index 0000000..54cc484 --- /dev/null +++ b/feedback_modul/__init__.py @@ -0,0 +1,31 @@ +"""Alph-Feedback-Modul (PummelHummel) — Lazy-Init. + +Damit das Modul sowohl in Flask- als auch in FastAPI-Projekten genutzt werden +kann (ohne dass die jeweils andere Dependency Pflicht ist), wird der Flask-Code +in `flask_app.py` und der FastAPI-Code in `fastapi_app.py` gehalten. + +Bestehender Importpfad bleibt erhalten: + from feedback_modul import init_feedback_modul # Flask-Variante (auto) + +FastAPI-Variante: + from feedback_modul.fastapi_app import init_feedback_modul + +Wenn Flask nicht installiert ist, schlaegt der Top-Level-Import nicht fehl — +nur der Versuch, `init_feedback_modul` ohne Submodul-Pfad zu nutzen. +""" + +from .schema import DDL, KATEGORIEN # immer verfuegbar, hat keine Dependencies + +try: + from .flask_app import init_feedback_modul, render_widget_snippet +except ImportError: + # Flask nicht installiert — das ist OK fuer FastAPI-only-Projekte. + # Wer `from feedback_modul import init_feedback_modul` aufruft, bekommt + # einen klaren Fehler beim Aufruf, nicht beim Import des Pakets. + def init_feedback_modul(*args, **kwargs): # type: ignore + raise ImportError( + "Flask ist nicht installiert. Fuer FastAPI nutze " + "`from feedback_modul.fastapi_app import init_feedback_modul`." + ) + +__all__ = ["init_feedback_modul", "render_widget_snippet", "DDL", "KATEGORIEN"] diff --git a/feedback_modul/fastapi_app.py b/feedback_modul/fastapi_app.py new file mode 100644 index 0000000..6630ff9 --- /dev/null +++ b/feedback_modul/fastapi_app.py @@ -0,0 +1,450 @@ +"""FastAPI-Adapter fuer das Alph-PummelHummel-Feedback-Modul. + +Bietet aequivalente Routen wie der Flask-Blueprint, fuer FastAPI-Projekte. + +Einbindung: + + from feedback_modul.fastapi_app import init_feedback_modul + + def get_db(): + return sqlite3.connect("/pfad/zu/feedback.db", check_same_thread=False) + + init_feedback_modul( + app, + get_db=get_db, + app_name="meta-lehrstun", + ist_admin=lambda request: request.cookies.get("admin_token") == ADMIN_TOKEN, + # user_kontext optional — fuer reine Drop-Ins kann das weg bleiben. + user_kontext=lambda request: {"site": "meta", "rolle": "user"}, + widget_aktiv=lambda: True, + ) + +In static/index.html vor : + + +""" + +import hashlib +import json +import sqlite3 +import time +from collections import defaultdict, OrderedDict +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request, Response, Form +from fastapi.responses import (HTMLResponse, JSONResponse, PlainTextResponse, + RedirectResponse, FileResponse) +from fastapi.staticfiles import StaticFiles + +from .schema import DDL, KATEGORIEN + + +_config = { + "get_db": None, + "app_name": "unknown", + "ist_admin": lambda request: False, + "user_kontext": lambda request: None, + "widget_aktiv": lambda: True, + "admin_unauthorized": None, # Callable(request) -> Response +} + +_rate_buckets = {} # ip -> list[timestamp] +_RATE_LIMIT_PER_MIN = 10 + +_STATIC_DIR = Path(__file__).parent / "static" + + +def _rate_limited(ip: str) -> bool: + 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: str, kategorie: str, text: str) -> str: + norm = (text or "").lower().strip()[:30] + raw = f"{url_pfad}|{kategorie}|{norm}" + return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] + + +def _admin_check(request: Request): + """Liefert None wenn Admin OK, sonst eine Response (Redirect/403).""" + if _config["ist_admin"](request): + return None + handler = _config.get("admin_unauthorized") + if callable(handler): + return handler(request) + raise HTTPException(status_code=403, detail="forbidden") + + +def _hole_offene_feedbacks() -> list: + conn = _config["get_db"]() + conn.row_factory = sqlite3.Row + 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] + + +def _build_md(eintraege: list) -> str: + 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") or user.get("site") or "?" + rolle = user.get("rolle", "") + rolle_str = f" ({rolle})" if rolle else "" + viewport = tech.get("viewport", "") + out.append(f"- **{team}**{rolle_str} — *\"{e['text']}\"*") + meta = [str(e["erstellt_am"])] + if viewport: + meta.append(f"viewport {viewport}") + if e.get("element_selector"): + meta.append(f"@ `{e['element_selector']}`") + out.append(f" {' · '.join(meta)}") + out.append("") + return "\n".join(out) + + +def _build_router() -> APIRouter: + r = APIRouter() + + @r.post("/api/feedback") + async def api_feedback_post(request: Request): + if _rate_limited(request.client.host if request.client else "unknown"): + return JSONResponse({"ok": False, "error": "rate_limit"}, status_code=429) + + try: + data = await request.json() + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + + 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 JSONResponse({"ok": False, "error": "kategorie_ungueltig"}, status_code=400) + if len(text) < 3: + return JSONResponse({"ok": False, "error": "text_zu_kurz"}, status_code=400) + if len(text) > 2000: + text = text[:2000] + if not url_pfad: + return JSONResponse({"ok": False, "error": "url_pfad_fehlt"}, status_code=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 akzeptieren wir Client-Kontext. + user_kontext = _config["user_kontext"](request) or {} + if not user_kontext: + client_user = data.get("user_kontext") + if isinstance(client_user, dict): + 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 JSONResponse({"ok": True}) + + @r.get("/admin/feedback", response_class=HTMLResponse) + async def admin_feedback_liste(request: Request, status: str = "offen", kategorie: str = ""): + unauth = _admin_check(request) + if unauth is not None: + return unauth + + where = ["app_name = ?"] + params = [_config["app_name"]] + if status == "offen": + where.append("status NOT IN ('erledigt', 'wontfix', 'spam')") + elif status and status != "alle": + where.append("status = ?") + params.append(status) + if kategorie: + where.append("kategorie = ?") + params.append(kategorie) + + sql = ("SELECT * FROM feedback WHERE " + " AND ".join(where) + + " ORDER BY status='neu' DESC, erstellt_am DESC") + conn = _config["get_db"]() + conn.row_factory = sqlite3.Row + 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"] = {} + + stats = OrderedDict() + for s in ["neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"]: + stats[s] = conn.execute( + "SELECT COUNT(*) FROM feedback WHERE app_name=? AND status=?", + (_config["app_name"], s), + ).fetchone()[0] + conn.close() + + return HTMLResponse(_render_admin_html(eintraege, stats, status, kategorie)) + + @r.post("/admin/feedback/{feedback_id}/status") + async def admin_feedback_status(feedback_id: int, request: Request, + status: str = Form(...), notiz: str = Form("")): + unauth = _admin_check(request) + if unauth is not None: + return unauth + erlaubt = {"neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"} + if status not in erlaubt: + raise HTTPException(status_code=400, detail="status_ungueltig") + conn = _config["get_db"]() + conn.execute( + """UPDATE feedback SET status = ?, bearbeitet_notiz = ?, + aktualisiert_am = CURRENT_TIMESTAMP WHERE id = ?""", + (status, notiz or None, feedback_id), + ) + conn.commit() + conn.close() + ref = request.headers.get("referer") or "/admin/feedback" + return RedirectResponse(ref, status_code=303) + + @r.get("/admin/feedback/export.json") + async def admin_feedback_export_json(request: Request): + unauth = _admin_check(request) + if unauth is not None: + return unauth + 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 JSONResponse({ + "app": _config["app_name"], + "exportiert_am": datetime.utcnow().isoformat() + "Z", + "anzahl": len(eintraege), + "feedbacks": eintraege, + }) + + @r.get("/admin/feedback/export.md") + async def admin_feedback_export_md(request: Request): + unauth = _admin_check(request) + if unauth is not None: + return unauth + return PlainTextResponse(_build_md(_hole_offene_feedbacks()), + media_type="text/markdown; charset=utf-8") + + @r.get("/admin/feedback/claude-prompt.md") + async def admin_feedback_claude_prompt(request: Request): + unauth = _admin_check(request) + if unauth is not None: + return unauth + eintraege = _hole_offene_feedbacks() + md_body = _build_md(eintraege) + prompt = [] + prompt.append("# 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 aus dem Alphatest, " + f"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 `/foo` -> templates/foo.html etc.") + prompt.append("3. **Symptom vs. Ursache.** Die Reports beschreiben oft nur was sichtbar ist.") + prompt.append("4. **Pro Cluster ein Vorschlag:** Datei + Zeile + konkrete Aenderung.") + prompt.append("5. **Unklare Reports markieren.** Lieber zum Nachfragen flaggen als raten.") + prompt.append("6. **Spam erkennen.** Quatsch markiere mit STATUS:spam 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 PlainTextResponse("\n".join(prompt), + media_type="text/markdown; charset=utf-8") + + return r + + +# Minimaler HTML-Renderer (kein Jinja-Zwang fuer FastAPI) +def _render_admin_html(eintraege, stats, status_filter, kategorie_filter): + from html import escape as h + kat_emoji = {"code": "🐛", "link": "🔗", "verstehen": "❓", "fehlt": "❌", "sonstiges": "💬"} + status_label = {"neu": "neu", "triagiert": "triagiert", "in_arbeit": "in Arbeit", + "erledigt": "erledigt", "wontfix": "wontfix", "spam": "spam"} + css = """body{font-family:-apple-system,sans-serif;max-width:1100px;margin:1rem auto;padding:0 1rem;color:#2C3E50} +.fb-stats{display:flex;flex-wrap:wrap;gap:.5rem;margin:.5rem 0 1rem} +.fb-stat{background:#F8F9FA;padding:.4rem .8rem;border-radius:6px;border:1px solid #DEE2E6;font-size:.88rem} +.fb-stat strong{color:#00979D} +.fb-card{border:1px solid #DEE2E6;border-radius:8px;padding:.8rem 1rem;margin-bottom:.8rem;background:white} +.fb-card.fb-neu{border-left:4px solid #E67E22} +.fb-head{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap;margin-bottom:.4rem;font-size:.85rem;color:#7F8C8D} +.fb-kat{background:#EBF8F9;color:#00979D;padding:.15rem .5rem;border-radius:4px;font-weight:bold} +.fb-pfad{font-family:Consolas,monospace;background:#F4F6F7;padding:.1rem .4rem;border-radius:3px} +.fb-text{font-size:.95rem;line-height:1.4;margin:.4rem 0} +.fb-statusform{display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.6rem;padding-top:.5rem;border-top:1px solid #ECEFF1} +.fb-statusform select,.fb-statusform input,.fb-statusform button{padding:.3rem .5rem;border:1px solid #DEE2E6;border-radius:4px;font-size:.85rem} +.fb-statusform input{flex:1;min-width:180px} +.fb-statusform button{background:#00979D;color:white;cursor:pointer} +.fb-exports a{margin-right:.5rem}""" + parts = [f"Alph-Feedback {h(_config['app_name'])}"] + parts.append(f"

🐝 Alph-Feedback — {h(_config['app_name'])}

") + parts.append("
") + for s, anz in stats.items(): + parts.append(f"
{anz} {h(status_label.get(s,s))}
") + parts.append("
") + parts.append("

" + "export.json · " + "export.md · " + "claude-prompt.md

") + if not eintraege: + parts.append("

Keine Feedbacks im aktuellen Filter.

") + for e in eintraege: + u = e.get("user", {}) + team = u.get("tier") or u.get("site") or "?" + kasten = u.get("kasten") + team_str = f"{team}" + (f" / Kasten {kasten}" if kasten else "") + rolle = u.get("rolle", "") + parts.append(f"
") + parts.append("
") + parts.append(f"{kat_emoji.get(e['kategorie'],'•')} {h(e['kategorie'])}") + parts.append(f"{h(team_str)}{(' ('+h(rolle)+')' if rolle else '')}") + parts.append(f"{h(e['url_pfad'])}") + parts.append(f"{h(str(e['erstellt_am']))}") + parts.append(f"· Status: {h(status_label.get(e['status'],e['status']))}") + parts.append("
") + parts.append(f"
{h(e['text'])}
") + parts.append(f"
") + parts.append("") + parts.append(f"") + parts.append("") + parts.append("
") + parts.append("
") + parts.append("") + return "".join(parts) + + +def init_feedback_modul(app, *, get_db, app_name, ist_admin=None, + user_kontext=None, widget_aktiv=None, + admin_unauthorized=None, + static_mount: str = "/static/feedback"): + """Registriert das Modul in einer FastAPI-App. + + Args: + app: FastAPI-Instanz + get_db: Callable () -> sqlite3.Connection + app_name: Identifier (z.B. "meta-lehrstun") + ist_admin: Callable(request) -> bool — pro Request, mit Cookies/Header + user_kontext: Callable(request) -> dict | None + widget_aktiv: Callable() -> bool (default: True). Drop-In-typisch: + einfach True, weil der serverseitige Widget-Snippet hier + nicht gerendert wird (siehe README). + admin_unauthorized: Callable(request) -> Response (z.B. RedirectResponse) + static_mount: URL-Prefix fuer alph.js (default /static/feedback). + Wird automatisch gemounted; falls schon belegt, mit + einem anderen Prefix aufrufen. + """ + _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 + + # Schema sicherstellen + conn = get_db() + conn.executescript(DDL) + conn.commit() + conn.close() + + # Statische alph.js mounten + app.mount(static_mount, StaticFiles(directory=str(_STATIC_DIR)), name="feedback_static") + + # Routen registrieren + app.include_router(_build_router()) diff --git a/feedback_modul/flask_app.py b/feedback_modul/flask_app.py new file mode 100644 index 0000000..b7c3a82 --- /dev/null +++ b/feedback_modul/flask_app.py @@ -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 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//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" {' · '.join(meta)}") + if e.get("element_text"): + out.append(f" Element-Text: `{e['element_text'][:120]}`") + if e.get("seitentitel"): + out.append(f" Seitentitel: {e['seitentitel']}") + 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 = """\ + +""" + + +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()} diff --git a/feedback_modul/schema.py b/feedback_modul/schema.py new file mode 100644 index 0000000..a659594 --- /dev/null +++ b/feedback_modul/schema.py @@ -0,0 +1,37 @@ +"""Schema-DDL fuer das Alph-Feedback-Modul. + +Idempotent — kann mehrfach ausgefuehrt werden. +""" + +DDL = """ +CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_name TEXT NOT NULL, + kategorie TEXT NOT NULL, + text TEXT NOT NULL, + url_pfad TEXT NOT NULL, + seitentitel TEXT, + user_kontext TEXT, -- JSON: tier, kasten, kurs, gruppe_id, rolle + technik_kontext TEXT, -- JSON: viewport, ua, sprache, referer + element_selector TEXT, + element_text TEXT, + status TEXT NOT NULL DEFAULT 'neu', -- neu|triagiert|in_arbeit|erledigt|wontfix|spam + cluster_hash TEXT, + bearbeitet_von TEXT, + bearbeitet_notiz TEXT, + erstellt_am DATETIME DEFAULT CURRENT_TIMESTAMP, + aktualisiert_am DATETIME +); + +CREATE INDEX IF NOT EXISTS feedback_status_idx ON feedback(status); +CREATE INDEX IF NOT EXISTS feedback_app_idx ON feedback(app_name); +CREATE INDEX IF NOT EXISTS feedback_cluster_idx ON feedback(cluster_hash); +""" + +KATEGORIEN = [ + ("code", "Codefehler", "Sketch tut nicht, Compiler-Error"), + ("link", "Link kaputt", "Knopf oder Link fuehrt ins Leere"), + ("verstehen", "Versteh ich nicht", "Erklaerung unklar oder zu schwer"), + ("fehlt", "Etwas fehlt", "Information, Bauteil, Schritt ist nicht da"), + ("sonstiges", "Sonstiges", "Anderes Feedback / Idee / Lob"), +] diff --git a/feedback_modul/static/alph.js b/feedback_modul/static/alph.js new file mode 100644 index 0000000..6d0e24f --- /dev/null +++ b/feedback_modul/static/alph.js @@ -0,0 +1,444 @@ +/* Alph der PummelHummel — Alphatest-Feedback-Widget + * ---------------------------------------------------- + * Self-contained: HTML + CSS + SVG + Logik in dieser einen Datei. + * Einbindung: + * + * + * + * Liest config aus data-Attributen vom