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:
2026-05-25 15:29:39 +02:00
parent 046fd56018
commit 66c52cc8b1
7 changed files with 1674 additions and 1 deletions

62
app.py
View File

@@ -15,8 +15,9 @@ import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv 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 from tiere import tier_emoji, tier_name
# Routes-Blueprints # Routes-Blueprints
@@ -37,6 +38,65 @@ app.register_blueprint(bp_admin)
app.register_blueprint(bp_api) 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) # Markdown-Filter fuer Templates (Pattern wie arduino.lehrstun.de)
# =========================================================================== # ===========================================================================

View File

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

View File

@@ -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 </body>:
<script src="/static/feedback/alph.js"
data-endpoint="/api/feedback"
data-app="meta-lehrstun"
data-user='{"site":"meta","rolle":"user"}'
defer></script>
"""
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" <sub>{' · '.join(meta)}</sub>")
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"<!doctype html><html><head><meta charset='utf-8'><title>Alph-Feedback {h(_config['app_name'])}</title><style>{css}</style></head><body>"]
parts.append(f"<h1>🐝 Alph-Feedback — {h(_config['app_name'])}</h1>")
parts.append("<div class='fb-stats'>")
for s, anz in stats.items():
parts.append(f"<div class='fb-stat'><strong>{anz}</strong> {h(status_label.get(s,s))}</div>")
parts.append("</div>")
parts.append("<p class='fb-exports'>"
"<a href='./feedback/export.json' target='_blank'>export.json</a> · "
"<a href='./feedback/export.md' target='_blank'>export.md</a> · "
"<a href='./feedback/claude-prompt.md' target='_blank'>claude-prompt.md</a></p>")
if not eintraege:
parts.append("<p>Keine Feedbacks im aktuellen Filter.</p>")
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"<div class='fb-card{(' fb-neu' if e['status']=='neu' else '')}'>")
parts.append("<div class='fb-head'>")
parts.append(f"<span class='fb-kat'>{kat_emoji.get(e['kategorie'],'')} {h(e['kategorie'])}</span>")
parts.append(f"<span><strong>{h(team_str)}</strong>{(' ('+h(rolle)+')' if rolle else '')}</span>")
parts.append(f"<span class='fb-pfad'>{h(e['url_pfad'])}</span>")
parts.append(f"<span style='margin-left:auto'>{h(str(e['erstellt_am']))}</span>")
parts.append(f"<span>· Status: {h(status_label.get(e['status'],e['status']))}</span>")
parts.append("</div>")
parts.append(f"<div class='fb-text'>{h(e['text'])}</div>")
parts.append(f"<form class='fb-statusform' method='post' action='./feedback/{e['id']}/status'>")
parts.append("<select name='status'>")
for s in ["neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"]:
sel = " selected" if e['status'] == s else ""
parts.append(f"<option value='{s}'{sel}>{h(status_label.get(s,s))}</option>")
parts.append("</select>")
parts.append(f"<input type='text' name='notiz' value='{h(e.get('bearbeitet_notiz') or '')}' placeholder='Notiz / Commit-SHA'>")
parts.append("<button type='submit'>Setzen</button>")
parts.append("</form>")
parts.append("</div>")
parts.append("</body></html>")
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())

488
feedback_modul/flask_app.py Normal file
View 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()}

37
feedback_modul/schema.py Normal file
View File

@@ -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"),
]

View File

@@ -0,0 +1,444 @@
/* Alph der PummelHummel — Alphatest-Feedback-Widget
* ----------------------------------------------------
* Self-contained: HTML + CSS + SVG + Logik in dieser einen Datei.
* Einbindung:
*
* <script src=".../alph.js"
* data-endpoint="/api/feedback"
* data-app="meine-app"
* data-user='{"tier":"otter","kasten":7}'
* defer></script>
*
* Liest config aus data-Attributen vom <script>-Tag:
* data-endpoint POST-URL fuer Feedback (Pflicht)
* data-app Identifier der App (Pflicht, z.B. "arduino-werkstatt")
* data-user JSON-String mit user_kontext (optional, Server kann auch selbst setzen)
* data-position "bottom-right" (Default) | "bottom-left"
*
* Schickt JSON mit:
* { kategorie, text, url_pfad, seitentitel, viewport, sprache,
* element_selector?, element_text? }
*/
(function () {
'use strict';
if (window.__alphMounted) return;
window.__alphMounted = true;
var script = document.currentScript ||
(function () {
var all = document.getElementsByTagName('script');
return all[all.length - 1];
})();
var ENDPOINT = script.getAttribute('data-endpoint') || '/api/feedback';
var APP_NAME = script.getAttribute('data-app') || 'unknown';
var POSITION = script.getAttribute('data-position') || 'bottom-right';
var USER_RAW = script.getAttribute('data-user') || '';
var USER_KONTEXT = null;
if (USER_RAW) {
try { USER_KONTEXT = JSON.parse(USER_RAW); }
catch (e) { USER_KONTEXT = null; }
}
// Cookie-Helper fuer "diese Stunde verstecken"
function getCookie(name) {
var m = document.cookie.match(new RegExp('(^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[2]) : null;
}
function setCookie(name, value, hours) {
var d = new Date();
d.setTime(d.getTime() + hours * 60 * 60 * 1000);
document.cookie = name + '=' + encodeURIComponent(value) +
';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
}
if (getCookie('alph_versteckt') === '1') return;
// === CSS injizieren ============================================
var css = `
.alph-host { all: initial; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.alph-host * { box-sizing: border-box; }
.alph-fab {
position: fixed;
${POSITION === 'bottom-left' ? 'left: 1.5rem' : 'right: 1.5rem'};
bottom: 1.5rem;
width: 110px; height: 110px;
z-index: 99998;
cursor: pointer;
user-select: none;
animation: alph-float 6s ease-in-out infinite;
transition: transform 0.15s;
filter: drop-shadow(0 8px 14px rgba(0,0,0,0.28));
}
.alph-fab:hover { transform: scale(1.15) rotate(-6deg); animation-play-state: paused; }
.alph-fab:active { transform: scale(0.95); }
.alph-fab svg { width: 100%; height: 100%; display: block; overflow: visible; }
.alph-wing { transform-origin: 50% 50%; animation: alph-wing 0.13s ease-in-out infinite alternate; }
.alph-wing-r { animation-delay: 0.04s; }
.alph-antenna { transform-origin: 50% 100%; animation: alph-antenna 4s ease-in-out infinite; }
.alph-tooltip {
position: absolute;
bottom: 120px;
${POSITION === 'bottom-left' ? 'left: 0' : 'right: 0'};
background: #2C3E50; color: white;
padding: 0.5rem 0.9rem; border-radius: 8px;
font-size: 0.92rem; font-weight: 500; white-space: nowrap;
opacity: 0; pointer-events: none;
transition: opacity 0.15s;
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
}
.alph-fab:hover .alph-tooltip { opacity: 1; }
.alph-close {
position: absolute;
top: 2px; right: 2px;
width: 26px; height: 26px; border-radius: 50%;
background: #E74C3C; color: white;
font-size: 18px; font-weight: bold; line-height: 24px;
text-align: center;
cursor: pointer; opacity: 0;
transition: opacity 0.15s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.alph-fab:hover .alph-close { opacity: 1; }
.alph-fab.alph-shake { animation: alph-shake 0.5s ease-in-out !important; }
/* Eine einzige 6-Sekunden-Schleife: ruhiges Schweben + bei 70-92% ein Hop */
@keyframes alph-float {
0% { transform: translateY(0) translateX(0) rotate(-2deg); }
10% { transform: translateY(-8px) translateX(2px) rotate(0deg); }
20% { transform: translateY(-14px) translateX(0) rotate(2deg); }
30% { transform: translateY(-8px) translateX(-2px) rotate(0deg); }
40% { transform: translateY(0) translateX(0) rotate(-2deg); }
55% { transform: translateY(-10px) translateX(2px) rotate(1deg); }
65% { transform: translateY(-4px) translateX(0) rotate(-1deg); }
/* Hop! Aufmerksamkeitsmacher */
72% { transform: translateY(-26px) rotate(-9deg) scale(1.08); }
78% { transform: translateY(-6px) rotate(7deg) scale(1.02); }
84% { transform: translateY(-20px) rotate(-4deg) scale(1.05); }
90% { transform: translateY(-4px) rotate(2deg) scale(1); }
100% { transform: translateY(0) translateX(0) rotate(-2deg); }
}
@keyframes alph-wing {
0% { transform: scaleY(1) rotate(0deg); }
100% { transform: scaleY(0.35) rotate(14deg); }
}
@keyframes alph-shake {
0%, 100% { transform: translateX(0) rotate(0) scale(1); }
20% { transform: translateX(-6px) rotate(-6deg) scale(1.06); }
40% { transform: translateX(6px) rotate(5deg) scale(1.10); }
60% { transform: translateX(-4px) rotate(-3deg) scale(1.05); }
80% { transform: translateX(4px) rotate(2deg) scale(1.02); }
}
@keyframes alph-antenna {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(4deg); }
}
.alph-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.45);
z-index: 99999;
display: flex; align-items: center; justify-content: center;
animation: alph-fade-in 0.2s ease-out;
}
.alph-modal {
background: white; color: #2C3E50;
width: min(440px, 92vw); max-height: 92vh; overflow-y: auto;
border-radius: 12px; padding: 1.4rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
animation: alph-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.alph-modal h2 {
font-size: 1.15rem; margin: 0 0 0.4rem 0;
color: #2C3E50; display: flex; align-items: center; gap: 0.5rem;
}
.alph-modal p.alph-sub {
color: #7F8C8D; font-size: 0.88rem; margin: 0 0 1rem 0;
}
.alph-kat-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;
margin-bottom: 0.9rem;
}
.alph-kat-btn {
background: #F8F9FA; border: 2px solid #DEE2E6;
border-radius: 8px; padding: 0.7rem 0.6rem;
text-align: left; cursor: pointer;
font-size: 0.88rem; color: #2C3E50;
transition: all 0.15s; font-family: inherit;
}
.alph-kat-btn:hover { border-color: #00979D; background: #EBF8F9; }
.alph-kat-btn.alph-active {
border-color: #00979D; background: #00979D; color: white;
}
.alph-kat-btn .alph-emoji { font-size: 1.1rem; margin-right: 0.3rem; }
.alph-kat-btn small { display: block; opacity: 0.75; font-size: 0.74rem; margin-top: 0.15rem; }
.alph-modal textarea {
width: 100%; padding: 0.7rem; min-height: 100px;
border: 1px solid #DEE2E6; border-radius: 6px;
font-family: inherit; font-size: 0.95rem; resize: vertical;
color: #2C3E50;
}
.alph-modal textarea:focus { outline: none; border-color: #00979D; box-shadow: 0 0 0 3px rgba(0,151,157,0.15); }
.alph-context {
font-size: 0.78rem; color: #7F8C8D;
background: #F8F9FA; padding: 0.5rem 0.7rem; border-radius: 4px;
margin: 0.6rem 0; font-family: 'Fira Code', Consolas, monospace;
}
.alph-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.alph-btn {
padding: 0.55rem 1.2rem; border-radius: 6px; border: none;
cursor: pointer; font-size: 0.92rem; font-family: inherit;
transition: background 0.15s;
}
.alph-btn-primary { background: #00979D; color: white; }
.alph-btn-primary:hover { background: #007A80; }
.alph-btn-primary:disabled { background: #B5C4C5; cursor: not-allowed; }
.alph-btn-secondary { background: #ECF0F1; color: #2C3E50; }
.alph-btn-secondary:hover { background: #D5DBDB; }
.alph-error {
background: #FADBD8; color: #943126;
padding: 0.5rem 0.8rem; border-radius: 4px; font-size: 0.88rem;
margin-top: 0.5rem;
}
.alph-success {
background: #D5F5E3; color: #1E8449;
padding: 1rem; border-radius: 6px; text-align: center;
}
.alph-success .alph-success-icon { font-size: 2rem; display: block; margin-bottom: 0.4rem; }
@keyframes alph-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes alph-pop-in {
from { opacity: 0; transform: scale(0.85) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@media (max-width: 600px) {
.alph-fab { width: 88px; height: 88px; bottom: 1rem; right: 1rem; }
.alph-tooltip { bottom: 96px; font-size: 0.84rem; }
.alph-kat-grid { grid-template-columns: 1fr; }
}
/* Reduzierte Bewegungs-Praeferenz respektieren (Barrierefreiheit) */
@media (prefers-reduced-motion: reduce) {
.alph-fab { animation: none; }
.alph-wing, .alph-antenna { animation: none; }
}
`;
var styleEl = document.createElement('style');
styleEl.textContent = css;
document.head.appendChild(styleEl);
// === Hummel-SVG ================================================
var hummelSVG = `
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label="Alph der PummelHummel">
<!-- Linker Fluegel -->
<ellipse class="alph-wing alph-wing-l" cx="32" cy="35" rx="14" ry="22"
fill="#E8F4FD" fill-opacity="0.85" stroke="#B0C4DE" stroke-width="1"/>
<!-- Rechter Fluegel -->
<ellipse class="alph-wing alph-wing-r" cx="68" cy="35" rx="14" ry="22"
fill="#E8F4FD" fill-opacity="0.85" stroke="#B0C4DE" stroke-width="1"/>
<!-- Body (Pummel) -->
<ellipse cx="50" cy="58" rx="28" ry="26" fill="#FFCC33" stroke="#2C3E50" stroke-width="2"/>
<!-- Streifen -->
<path d="M 26 50 Q 50 46 74 50" stroke="#2C3E50" stroke-width="5" fill="none" stroke-linecap="round"/>
<path d="M 28 66 Q 50 70 72 66" stroke="#2C3E50" stroke-width="5" fill="none" stroke-linecap="round"/>
<!-- T-Shirt mit Alpha -->
<rect x="38" y="54" width="24" height="14" rx="3" fill="white" stroke="#2C3E50" stroke-width="1.2"/>
<text x="50" y="65" font-family="Georgia, serif" font-size="11" font-weight="bold"
text-anchor="middle" fill="#00979D">α</text>
<!-- Kopf -->
<circle cx="50" cy="32" r="13" fill="#2C3E50"/>
<!-- Augen -->
<circle cx="46" cy="30" r="2.5" fill="white"/>
<circle cx="54" cy="30" r="2.5" fill="white"/>
<circle cx="46" cy="30.5" r="1.2" fill="#2C3E50"/>
<circle cx="54" cy="30.5" r="1.2" fill="#2C3E50"/>
<!-- Antennen (wackeln) -->
<g class="alph-antenna">
<path d="M 44 22 Q 41 14 38 12" stroke="#2C3E50" stroke-width="1.8" fill="none" stroke-linecap="round"/>
<path d="M 56 22 Q 59 14 62 12" stroke="#2C3E50" stroke-width="1.8" fill="none" stroke-linecap="round"/>
<circle cx="38" cy="12" r="2" fill="#FFCC33" stroke="#2C3E50" stroke-width="1"/>
<circle cx="62" cy="12" r="2" fill="#FFCC33" stroke="#2C3E50" stroke-width="1"/>
</g>
<!-- Mund -->
<path d="M 47 36 Q 50 38 53 36" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Stinger -->
<path d="M 50 84 L 47 92 L 53 92 Z" fill="#2C3E50"/>
</svg>
`;
// === DOM aufbauen =============================================
var host = document.createElement('div');
host.className = 'alph-host';
host.innerHTML = `
<div class="alph-fab" id="alph-fab" role="button" aria-label="Feedback geben — Alph der PummelHummel" tabindex="0">
<span class="alph-close" id="alph-close" title="Heute ausblenden">×</span>
${hummelSVG}
<span class="alph-tooltip">Hier was kaputt? Sag's Alph!</span>
</div>
`;
document.body.appendChild(host);
var fab = host.querySelector('#alph-fab');
var closeBtn = host.querySelector('#alph-close');
closeBtn.addEventListener('click', function (e) {
e.stopPropagation();
setCookie('alph_versteckt', '1', 4); // 4 Stunden weg
host.remove();
});
fab.addEventListener('click', oeffneModal);
fab.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffneModal(); }
});
// === Modal-Logik ==============================================
var KATEGORIEN = [
{ id: 'code', emoji: '🐛', label: 'Codefehler', hint: 'Sketch tut nicht, Compiler-Error' },
{ id: 'link', emoji: '🔗', label: 'Link kaputt', hint: 'Knopf fuehrt ins Leere' },
{ id: 'verstehen', emoji: '❓', label: 'Versteh ich nicht', hint: 'Erklaerung unklar' },
{ id: 'fehlt', emoji: '❌', label: 'Etwas fehlt', hint: 'Info / Bauteil / Schritt' },
{ id: 'sonstiges', emoji: '💬', label: 'Sonstiges', hint: 'Idee, Lob, anderes' },
];
var modalEl = null;
var ausgewaehlteKat = null;
function oeffneModal() {
fab.classList.add('alph-shake');
setTimeout(function () { fab.classList.remove('alph-shake'); }, 400);
if (modalEl) return;
ausgewaehlteKat = null;
modalEl = document.createElement('div');
modalEl.className = 'alph-overlay';
var katBtns = KATEGORIEN.map(function (k) {
return `<button type="button" class="alph-kat-btn" data-kat="${k.id}">
<span class="alph-emoji">${k.emoji}</span>${k.label}
<small>${k.hint}</small>
</button>`;
}).join('');
modalEl.innerHTML = `
<div class="alph-modal" role="dialog" aria-label="Feedback geben">
<h2>🐝 Alph hört zu</h2>
<p class="alph-sub">Was ist dir aufgefallen? Wähl eine Kategorie:</p>
<div class="alph-kat-grid">${katBtns}</div>
<textarea id="alph-text" placeholder="Beschreib's kurz — was hast du gemacht, was ist passiert?"></textarea>
<div class="alph-context" id="alph-ctx"></div>
<div id="alph-msg"></div>
<div class="alph-actions">
<button type="button" class="alph-btn alph-btn-secondary" id="alph-cancel">Abbrechen</button>
<button type="button" class="alph-btn alph-btn-primary" id="alph-send" disabled>Absenden</button>
</div>
</div>
`;
host.appendChild(modalEl);
modalEl.querySelector('#alph-ctx').textContent =
'Seite: ' + window.location.pathname + ' · Titel: ' + (document.title || '—');
modalEl.querySelectorAll('.alph-kat-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
modalEl.querySelectorAll('.alph-kat-btn').forEach(function (b) {
b.classList.remove('alph-active');
});
btn.classList.add('alph-active');
ausgewaehlteKat = btn.getAttribute('data-kat');
pruefeAbsendbar();
});
});
modalEl.querySelector('#alph-text').addEventListener('input', pruefeAbsendbar);
modalEl.querySelector('#alph-cancel').addEventListener('click', schliesseModal);
modalEl.querySelector('#alph-send').addEventListener('click', sende);
modalEl.addEventListener('click', function (e) {
if (e.target === modalEl) schliesseModal();
});
document.addEventListener('keydown', escListener);
}
function escListener(e) {
if (e.key === 'Escape') schliesseModal();
}
function schliesseModal() {
if (!modalEl) return;
modalEl.remove();
modalEl = null;
document.removeEventListener('keydown', escListener);
}
function pruefeAbsendbar() {
if (!modalEl) return;
var text = modalEl.querySelector('#alph-text').value.trim();
modalEl.querySelector('#alph-send').disabled = !(ausgewaehlteKat && text.length >= 3);
}
function sende() {
if (!modalEl || !ausgewaehlteKat) return;
var sendBtn = modalEl.querySelector('#alph-send');
var msgEl = modalEl.querySelector('#alph-msg');
var text = modalEl.querySelector('#alph-text').value.trim();
sendBtn.disabled = true;
sendBtn.textContent = 'Sende...';
msgEl.innerHTML = '';
var payload = {
app: APP_NAME,
kategorie: ausgewaehlteKat,
text: text,
url_pfad: window.location.pathname + window.location.search,
seitentitel: document.title || '',
viewport: window.innerWidth + 'x' + window.innerHeight,
sprache: navigator.language || ''
};
// user_kontext nur senden, wenn vom Server via data-user mitgegeben
// (Drop-In-Modus). Bei Flask-Blueprint-Integration wird er sowieso
// serverseitig gesetzt und ueberschrieben.
if (USER_KONTEXT) {
payload.user_kontext = USER_KONTEXT;
}
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'same-origin'
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, body: j }; });
}).then(function (res) {
if (res.ok && res.body && res.body.ok) {
erfolgZeigen();
} else {
var err = (res.body && res.body.error) || 'Unbekannter Fehler';
if (err === 'rate_limit') err = 'Zu viele Reports — kurz warten.';
if (err === 'text_zu_kurz') err = 'Text muss mindestens 3 Zeichen lang sein.';
msgEl.innerHTML = '<div class="alph-error">' + err + '</div>';
sendBtn.disabled = false;
sendBtn.textContent = 'Absenden';
}
}).catch(function () {
msgEl.innerHTML = '<div class="alph-error">Netzwerkfehler. Versuch nochmal.</div>';
sendBtn.disabled = false;
sendBtn.textContent = 'Absenden';
});
}
function erfolgZeigen() {
if (!modalEl) return;
var modal = modalEl.querySelector('.alph-modal');
modal.innerHTML = `
<div class="alph-success">
<span class="alph-success-icon">🐝</span>
<strong>Danke!</strong><br>
Alph hat's notiert.
</div>
`;
setTimeout(schliesseModal, 1500);
}
// Klick-Capture Mode (optional, fuer "Codefehler" und "Link kaputt")
// — bewusst nicht implementiert in Erstversion; Element-Selector wird
// im Backend leergelassen. Erweiterbar ueber Page-Click-Handler.
})();

View File

@@ -0,0 +1,163 @@
{#
Admin-Liste fuer Alph-PummelHummel-Feedbacks.
- Standalone HTML, wenn parent_template == None (default)
- Sonst: extends parent_template, Inhalt im {% block content %}
Erwartet:
eintraege — list[dict] (mit dict-keys "user", "technik")
kategorien — list[(id, label, hint)]
stats — OrderedDict(status -> count)
status_filter — str
kategorie_filter — str
parent_template — str | None
app_name — str
#}
{% if parent_template %}{% extends parent_template %}{% endif %}
{% set _kat_emoji = {"code": "🐛", "link": "🔗", "verstehen": "❓", "fehlt": "❌", "sonstiges": "💬"} %}
{% set _status_label = {"neu": "neu", "triagiert": "triagiert", "in_arbeit": "in Arbeit", "erledigt": "erledigt", "wontfix": "wontfix", "spam": "spam"} %}
{% set _alle_status = ["alle", "offen", "neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"] %}
{% block title %}Admin: Alph-Feedback{% endblock %}
{% block content %}
{% if not parent_template %}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Admin: Alph-Feedback — {{ app_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 1100px; margin: 1rem auto; padding: 0 1rem; color: #2C3E50; }
h1 { margin: 0.2rem 0; font-size: 1.4rem; }
.fb-sub { color: #7F8C8D; font-size: 0.9rem; margin-bottom: 1rem; }
.fb-stats { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
.fb-stat { background: #F8F9FA; padding: 0.4rem 0.8rem; border-radius: 6px;
font-size: 0.88rem; border: 1px solid #DEE2E6; }
.fb-stat strong { color: #00979D; }
.fb-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
margin-bottom: 1rem; padding: 0.6rem; background: #F8F9FA;
border-radius: 6px; }
.fb-filters a { padding: 0.3rem 0.7rem; border-radius: 4px; text-decoration: none;
color: #2C3E50; font-size: 0.85rem; background: white;
border: 1px solid #DEE2E6; }
.fb-filters a.active { background: #00979D; color: white; border-color: #00979D; }
.fb-filters .label { font-size: 0.82rem; color: #7F8C8D; margin-right: 0.3rem; }
.fb-exports { margin-left: auto; display: flex; gap: 0.4rem; }
.fb-card { border: 1px solid #DEE2E6; border-radius: 8px; padding: 0.8rem 1rem;
margin-bottom: 0.8rem; background: white; }
.fb-card.fb-neu { border-left: 4px solid #E67E22; }
.fb-card.fb-erledigt, .fb-card.fb-wontfix, .fb-card.fb-spam { opacity: 0.7; }
.fb-head { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap;
margin-bottom: 0.4rem; font-size: 0.85rem; color: #7F8C8D; }
.fb-kat { background: #EBF8F9; color: #00979D; padding: 0.15rem 0.5rem;
border-radius: 4px; font-weight: bold; font-size: 0.82rem; }
.fb-status { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.78rem;
font-weight: bold; }
.fb-status-neu { background: #FCE4D2; color: #BA4A00; }
.fb-status-triagiert { background: #D6EAF8; color: #1F618D; }
.fb-status-in_arbeit { background: #FCF3CF; color: #9A7D0A; }
.fb-status-erledigt { background: #D5F5E3; color: #1E8449; }
.fb-status-wontfix { background: #E5E7E9; color: #566573; }
.fb-status-spam { background: #FADBD8; color: #943126; }
.fb-team { font-weight: bold; }
.fb-pfad { font-family: "Fira Code", Consolas, monospace; font-size: 0.85rem;
background: #F4F6F7; padding: 0.1rem 0.4rem; border-radius: 3px; }
.fb-text { font-size: 0.95rem; line-height: 1.4; margin: 0.4rem 0; }
.fb-meta { font-size: 0.78rem; color: #7F8C8D; margin-top: 0.3rem; }
.fb-statusform { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center;
margin-top: 0.6rem; padding-top: 0.5rem; border-top: 1px solid #ECEFF1; }
.fb-statusform select, .fb-statusform input, .fb-statusform button {
font-family: inherit; font-size: 0.85rem; padding: 0.3rem 0.5rem;
border: 1px solid #DEE2E6; border-radius: 4px; }
.fb-statusform input { flex: 1; min-width: 180px; }
.fb-statusform button { background: #00979D; color: white; border-color: #00979D;
cursor: pointer; }
.fb-statusform button:hover { background: #007A80; }
.fb-empty { text-align: center; padding: 2rem; color: #7F8C8D; }
.fb-elem { font-family: "Fira Code", Consolas, monospace; font-size: 0.78rem;
background: #FDF2E9; padding: 0.3rem 0.5rem; border-radius: 4px;
margin-top: 0.3rem; word-break: break-all; }
</style>
</head>
<body>
{% endif %}
<h1>🐝 Alph-Feedback — {{ app_name }}</h1>
<p class="fb-sub">Was Schueler ueber das PummelHummel-Widget gemeldet haben.</p>
<div class="fb-stats">
{% for status, anz in stats.items() %}
<div class="fb-stat"><strong>{{ anz }}</strong> {{ _status_label.get(status, status) }}</div>
{% endfor %}
</div>
<div class="fb-filters">
<span class="label">Status:</span>
{% for s in _alle_status %}
<a href="?status={{ s }}{% if kategorie_filter %}&kategorie={{ kategorie_filter }}{% endif %}"
class="{% if status_filter == s %}active{% endif %}">{{ _status_label.get(s, s) }}</a>
{% endfor %}
<span class="label" style="margin-left: 0.8rem;">Kategorie:</span>
<a href="?status={{ status_filter }}" class="{% if not kategorie_filter %}active{% endif %}">alle</a>
{% for kat_id, kat_label, _hint in kategorien %}
<a href="?status={{ status_filter }}&kategorie={{ kat_id }}"
class="{% if kategorie_filter == kat_id %}active{% endif %}">{{ _kat_emoji.get(kat_id, '') }} {{ kat_label }}</a>
{% endfor %}
<span class="fb-exports">
<a href="{{ url_for('feedback_modul.admin_feedback_export_md') }}" target="_blank">export.md</a>
<a href="{{ url_for('feedback_modul.admin_feedback_export_json') }}" target="_blank">export.json</a>
<a href="{{ url_for('feedback_modul.admin_feedback_claude_prompt') }}" target="_blank">claude-prompt</a>
</span>
</div>
{% if not eintraege %}
<div class="fb-empty">Keine Feedbacks im aktuellen Filter.</div>
{% endif %}
{% for e in eintraege %}
<div class="fb-card fb-{{ e.status }}{% if e.status == 'neu' %} fb-neu{% endif %}">
<div class="fb-head">
<span class="fb-kat">{{ _kat_emoji.get(e.kategorie, '•') }} {{ e.kategorie }}</span>
<span class="fb-status fb-status-{{ e.status }}">{{ _status_label.get(e.status, e.status) }}</span>
<span class="fb-team">{{ e.user.tier or '?' }}{% if e.user.kasten %} / Kasten {{ e.user.kasten }}{% endif %}</span>
{% if e.user.rolle %}<span>({{ e.user.rolle }})</span>{% endif %}
{% if e.user.kurs %}<span>· {{ e.user.kurs }}</span>{% endif %}
<span class="fb-pfad">{{ e.url_pfad }}</span>
<span style="margin-left: auto;">{{ e.erstellt_am }}</span>
</div>
<div class="fb-text">{{ e.text }}</div>
{% if e.element_selector or e.element_text %}
<div class="fb-elem">
{% if e.element_selector %}<strong>Selector:</strong> {{ e.element_selector }}<br>{% endif %}
{% if e.element_text %}<strong>Text:</strong> {{ e.element_text }}{% endif %}
</div>
{% endif %}
<div class="fb-meta">
{% if e.seitentitel %}Seitentitel: {{ e.seitentitel }} · {% endif %}
{% if e.technik.viewport %}{{ e.technik.viewport }} · {% endif %}
{% if e.technik.sprache %}{{ e.technik.sprache }} · {% endif %}
ID #{{ e.id }} · Cluster {{ e.cluster_hash }}
</div>
<form class="fb-statusform" method="post"
action="{{ url_for('feedback_modul.admin_feedback_status', feedback_id=e.id) }}">
<select name="status">
{% for s in ["neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"] %}
<option value="{{ s }}"{% if e.status == s %} selected{% endif %}>{{ _status_label.get(s, s) }}</option>
{% endfor %}
</select>
<input type="text" name="notiz" placeholder="Notiz / Commit-SHA / wer hat's gefixt"
value="{{ e.bearbeitet_notiz or '' }}">
<button type="submit">Setzen</button>
</form>
</div>
{% endfor %}
{% if not parent_template %}
</body>
</html>
{% endif %}
{% endblock %}