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