feat(v2): Feedback-Widget mit Audit-Trail + Screenshot + direkter Gitea-Anbindung
- Component v2/components/feedback_widget.html: Button unten links oberhalb der Queue, Klick oeffnet Modal mit vorausgefuellten Kontext-Feldern (URL, Drucksache, Viewport, User-Agent, letzte 15 Klicks, letzte 10 Console-Errors, letzte 5 Page-Loads). Eingaben: Titel, Beschreibung, optional Screenshot - Audit-Trail-Sammler in localStorage (Ringbuffer 30 Klicks, 10 Errors) - Screenshot via self-hosted html2canvas 1.4.1 (194 KB unter app/static/v2/lib/) - Backend POST /api/feedback (rate-limit 5/h): - validiert + html-strippt Inputs - erstellt Gitea-Issue per API mit Label 'feedback' (Label wird idempotent angelegt) - laedt Screenshot als Issue-Asset hoch (Gitea Issue-Attachment-API) - 4 neue Settings: gitea_token, gitea_api_url, gitea_repo_owner, gitea_repo_name - Server .env um GITEA_TOKEN ergaenzt - 10 neue Unit-Tests (mit gemocktem httpx) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fab1bddd3c
commit
a8d7b72702
@ -54,6 +54,14 @@ class Settings(BaseSettings):
|
||||
# Token für Unsubscribe-Links (HMAC-Secret)
|
||||
unsubscribe_secret: str = "change-me-in-prod"
|
||||
|
||||
# Gitea-API-Token für Feedback-Issues (Issue #feedback-widget)
|
||||
# Wert in .env: GITEA_TOKEN=<token>
|
||||
# Token-Quelle: cat ~/.claude/.gitea-token
|
||||
gitea_token: str = ""
|
||||
gitea_api_url: str = "https://repo.toppyr.de/api/v1"
|
||||
gitea_repo_owner: str = "tobias"
|
||||
gitea_repo_name: str = "gwoe-antragspruefer"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
|
||||
207
app/main.py
207
app/main.py
@ -2574,6 +2574,213 @@ async def api_admin_wahlprogramm_fetch(
|
||||
})
|
||||
|
||||
|
||||
# ─── Feedback / Bug-Report — Gitea-Issue-Anbindung ───────────────────────────
|
||||
|
||||
def _strip_html(text: str, max_len: int) -> str:
|
||||
"""Minimale HTML-Tag-Entfernung + Längenbegrenzung für Nutzerinput."""
|
||||
import re
|
||||
cleaned = re.sub(r'<[^>]+>', '', text)
|
||||
return cleaned[:max_len]
|
||||
|
||||
|
||||
async def _gitea_ensure_label(session, base_url: str, owner: str, repo: str,
|
||||
token: str, label_name: str, color: str = "#e11d48") -> int | None:
|
||||
"""Gibt die ID des Labels zurück; legt es idempotent an, falls es fehlt."""
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
url = f"{base_url}/repos/{owner}/{repo}/labels"
|
||||
try:
|
||||
r = await session.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
for lbl in r.json():
|
||||
if lbl.get("name") == label_name:
|
||||
return lbl["id"]
|
||||
# Label fehlt → anlegen
|
||||
r2 = await session.post(url, headers=headers,
|
||||
json={"name": label_name, "color": color})
|
||||
if r2.status_code in (200, 201):
|
||||
return r2.json().get("id")
|
||||
except Exception as exc:
|
||||
logger.exception("Gitea-Label-Lookup fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _gitea_upload_screenshot(session, base_url: str, owner: str, repo: str,
|
||||
token: str, issue_index: int,
|
||||
data_uri: str) -> str | None:
|
||||
"""Lädt einen Screenshot als Issue-Asset hoch. Gibt Attachment-URL zurück oder None."""
|
||||
import base64, re as _re
|
||||
m = _re.match(r'data:(image/[a-z]+);base64,(.+)', data_uri, _re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
mime, b64data = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64data)
|
||||
except Exception:
|
||||
return None
|
||||
ext = mime.split('/')[-1]
|
||||
upload_url = f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/assets"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
files = {"attachment": (f"screenshot.{ext}", raw, mime)}
|
||||
try:
|
||||
r = await session.post(upload_url, headers=headers, files=files)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json().get("browser_download_url") or r.json().get("download_url")
|
||||
except Exception as exc:
|
||||
logger.exception("Screenshot-Upload fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
@limiter.limit("5/hour")
|
||||
async def submit_feedback(
|
||||
request: Request,
|
||||
titel: str = Form(...),
|
||||
beschreibung: str = Form(...),
|
||||
url: str = Form(""),
|
||||
user_agent: str = Form(""),
|
||||
viewport: str = Form(""),
|
||||
drucksache: str = Form(""),
|
||||
klicks_json: str = Form("[]"),
|
||||
errors_json: str = Form("[]"),
|
||||
screenshot: Optional[str] = Form(None),
|
||||
screenshot_error: Optional[str] = Form(None),
|
||||
current_user: Optional[dict] = Depends(get_current_user),
|
||||
):
|
||||
"""Erstellt ein Gitea-Issue mit Label 'feedback'.
|
||||
|
||||
Audit-Trail (Klicks, Errors, URL etc.) wird im Issue-Body als
|
||||
Markdown-Code-Block angefügt. Screenshot wird als Issue-Asset
|
||||
hochgeladen, falls vorhanden.
|
||||
"""
|
||||
import json as _json
|
||||
import httpx
|
||||
|
||||
# Validierung
|
||||
titel_clean = _strip_html(titel, 200).strip()
|
||||
beschreibung_clean = _strip_html(beschreibung, 5000).strip()
|
||||
if not titel_clean:
|
||||
raise HTTPException(status_code=400, detail="Titel darf nicht leer sein")
|
||||
if not beschreibung_clean:
|
||||
raise HTTPException(status_code=400, detail="Beschreibung darf nicht leer sein")
|
||||
|
||||
# Audit-Trail parsen
|
||||
try:
|
||||
klicks = _json.loads(klicks_json)[:15]
|
||||
except Exception:
|
||||
klicks = []
|
||||
try:
|
||||
errors = _json.loads(errors_json)[:10]
|
||||
except Exception:
|
||||
errors = []
|
||||
|
||||
# User-Identität (wenn eingeloggt)
|
||||
user_email = ""
|
||||
user_name = ""
|
||||
if current_user:
|
||||
user_email = current_user.get("email", "")
|
||||
user_name = current_user.get("preferred_username", current_user.get("name", ""))
|
||||
|
||||
# Issue-Body zusammenbauen
|
||||
body_parts = [beschreibung_clean, ""]
|
||||
|
||||
body_parts.append("## Kontext")
|
||||
body_parts.append(f"- **URL:** `{url[:300]}`")
|
||||
if drucksache:
|
||||
body_parts.append(f"- **Drucksache:** `{drucksache[:100]}`")
|
||||
body_parts.append(f"- **Viewport:** {viewport}")
|
||||
body_parts.append(f"- **User-Agent:** `{user_agent[:200]}`")
|
||||
if user_name:
|
||||
body_parts.append(f"- **Gemeldet von:** {user_name} ({user_email})")
|
||||
else:
|
||||
body_parts.append("- **Gemeldet von:** anonym")
|
||||
body_parts.append("")
|
||||
|
||||
if klicks:
|
||||
body_parts.append("## Letzte Klicks (Audit-Trail)")
|
||||
body_parts.append("```")
|
||||
for c in klicks:
|
||||
txt_part = f' "{c["txt"]}"' if c.get("txt") else ""
|
||||
body_parts.append(f'{c.get("t","")[-8:]} {c.get("el","")}{txt_part}')
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if errors:
|
||||
body_parts.append("## Console-Errors")
|
||||
body_parts.append("```")
|
||||
for err in errors:
|
||||
body_parts.append(f'{err.get("t","")[-8:]} {err.get("msg","")} @ {err.get("src","")}')
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if screenshot_error:
|
||||
body_parts.append(f"_Screenshot angefordert, aber fehlgeschlagen: `{screenshot_error[:200]}`_")
|
||||
body_parts.append("")
|
||||
|
||||
issue_body = "\n".join(body_parts)
|
||||
|
||||
if not settings.gitea_token:
|
||||
logger.warning("GITEA_TOKEN nicht gesetzt — Feedback-Issue kann nicht angelegt werden")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Feedback-Funktion ist derzeit nicht konfiguriert (kein Gitea-Token)."
|
||||
)
|
||||
|
||||
base_url = settings.gitea_api_url
|
||||
owner = settings.gitea_repo_owner
|
||||
repo = settings.gitea_repo_name
|
||||
token = settings.gitea_token
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as session:
|
||||
# Label sicherstellen
|
||||
label_id = await _gitea_ensure_label(session, base_url, owner, repo, token, "feedback")
|
||||
label_ids = [label_id] if label_id else []
|
||||
|
||||
# Issue anlegen
|
||||
payload = {
|
||||
"title": titel_clean,
|
||||
"body": issue_body,
|
||||
"label_ids": label_ids,
|
||||
}
|
||||
try:
|
||||
r = await session.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/issues",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
logger.exception("Gitea-Request fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="Gitea nicht erreichbar")
|
||||
|
||||
if r.status_code not in (200, 201):
|
||||
logger.error("Gitea-Issue-Anlage fehlgeschlagen: %s %s", r.status_code, r.text[:500])
|
||||
raise HTTPException(status_code=502, detail=f"Gitea: {r.status_code}")
|
||||
|
||||
issue = r.json()
|
||||
issue_index = issue.get("number") or issue.get("id")
|
||||
issue_url = issue.get("html_url", "")
|
||||
|
||||
# Screenshot hochladen (optional)
|
||||
if screenshot and issue_index:
|
||||
att_url = await _gitea_upload_screenshot(
|
||||
session, base_url, owner, repo, token, issue_index, screenshot
|
||||
)
|
||||
if att_url:
|
||||
# Screenshot-Link als Kommentar anhängen
|
||||
comment_headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
try:
|
||||
await session.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/comments",
|
||||
headers=comment_headers,
|
||||
json={"body": f"**Screenshot:**\n\n"},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Screenshot-Kommentar fehlgeschlagen: %s", exc)
|
||||
|
||||
logger.info("Feedback-Issue #%s angelegt: %s", issue_index, issue_url)
|
||||
return JSONResponse({"issue_id": issue_index, "issue_url": issue_url})
|
||||
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
|
||||
1
app/static/v2/icons/phosphor/bug.svg
Normal file
1
app/static/v2/icons/phosphor/bug.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
|
||||
|
After Width: | Height: | Size: 674 B |
20
app/static/v2/lib/html2canvas.min.js
vendored
Normal file
20
app/static/v2/lib/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -312,6 +312,9 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Feedback/Bug-Report-Widget — öffnet Gitea-Issues direkt aus dem Browser #}
|
||||
{% include "v2/components/feedback_widget.html" %}
|
||||
|
||||
{# Queue-Statusbar mit Hover-Tooltip — analog zu classic-UI (#149) #}
|
||||
{% include "v2/components/queue_widget.html" %}
|
||||
</body>
|
||||
|
||||
356
app/templates/v2/components/feedback_widget.html
Normal file
356
app/templates/v2/components/feedback_widget.html
Normal file
@ -0,0 +1,356 @@
|
||||
{#
|
||||
feedback_widget.html — Feedback/Bug-Report-Widget mit Audit-Trail und Gitea-Anbindung.
|
||||
|
||||
Position: bottom:4rem, left:1rem — über dem Queue-Widget.
|
||||
Self-contained: Button + Modal + Audit-Trail-Sammler + Submit-Logic.
|
||||
Wird via {% include %} in base.html eingebunden.
|
||||
#}
|
||||
|
||||
{# ── Feedback-Button ──────────────────────────────────────────────────────── #}
|
||||
<button id="v2-feedback-btn"
|
||||
onclick="v2FeedbackOpen()"
|
||||
aria-label="Feedback oder Bug melden"
|
||||
title="Feedback / Bug melden"
|
||||
style="position:fixed;bottom:4rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.4rem 0.8rem;
|
||||
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:pointer;
|
||||
display:inline-flex;align-items:center;gap:5px;
|
||||
transition:all 0.2s;white-space:nowrap;">
|
||||
<span style="display:inline-flex;align-items:center;width:14px;height:14px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" width="14" height="14"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
|
||||
</span>
|
||||
Feedback
|
||||
</button>
|
||||
|
||||
{# ── Feedback-Modal ───────────────────────────────────────────────────────── #}
|
||||
<div id="v2-feedback-modal"
|
||||
role="dialog" aria-modal="true" aria-labelledby="v2-feedback-modal-title"
|
||||
style="display:none;position:fixed;inset:0;z-index:10000;
|
||||
background:rgba(0,0,0,0.45);
|
||||
align-items:center;justify-content:center;">
|
||||
|
||||
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:8px;padding:1.5rem;
|
||||
width:min(680px,96vw);max-height:90vh;overflow-y:auto;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.25);
|
||||
font-family:var(--font-sans);font-size:13px;color:var(--ecg-dark);
|
||||
position:relative;">
|
||||
|
||||
<button onclick="v2FeedbackClose()"
|
||||
aria-label="Schließen"
|
||||
style="position:absolute;top:0.75rem;right:0.75rem;
|
||||
background:none;border:none;cursor:pointer;
|
||||
font-size:16px;color:var(--ecg-text-muted);line-height:1;">✕</button>
|
||||
|
||||
<h2 id="v2-feedback-modal-title"
|
||||
style="margin:0 0 1rem;font-size:14px;font-weight:900;
|
||||
letter-spacing:0.04em;text-transform:uppercase;
|
||||
color:var(--ecg-blue);">Feedback / Bug melden</h2>
|
||||
|
||||
<form id="v2-feedback-form" onsubmit="v2FeedbackSubmit(event)">
|
||||
|
||||
{# ── User-Eingaben ────────────────────────────────────────────── #}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label for="v2-fb-titel"
|
||||
style="display:block;margin-bottom:0.25rem;font-size:11px;
|
||||
font-family:var(--font-mono);text-transform:uppercase;
|
||||
letter-spacing:0.06em;color:var(--ecg-text-muted);">
|
||||
Titel <span style="color:var(--ecg-green);">*</span>
|
||||
</label>
|
||||
<input id="v2-fb-titel" type="text" required maxlength="200"
|
||||
placeholder="Kurze Zusammenfassung des Problems"
|
||||
style="width:100%;box-sizing:border-box;
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
padding:0.5rem 0.6rem;font-family:var(--font-sans);
|
||||
font-size:13px;background:var(--ecg-card-bg);
|
||||
color:var(--ecg-dark);">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label for="v2-fb-beschreibung"
|
||||
style="display:block;margin-bottom:0.25rem;font-size:11px;
|
||||
font-family:var(--font-mono);text-transform:uppercase;
|
||||
letter-spacing:0.06em;color:var(--ecg-text-muted);">
|
||||
Beschreibung <span style="color:var(--ecg-green);">*</span>
|
||||
</label>
|
||||
<textarea id="v2-fb-beschreibung" required maxlength="5000" rows="5"
|
||||
placeholder="Was ist passiert? Was hast du erwartet?"
|
||||
style="width:100%;box-sizing:border-box;
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
padding:0.5rem 0.6rem;font-family:var(--font-sans);
|
||||
font-size:13px;background:var(--ecg-card-bg);
|
||||
color:var(--ecg-dark);resize:vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
{# ── Screenshot-Checkbox ──────────────────────────────────────── #}
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;">
|
||||
<input id="v2-fb-screenshot" type="checkbox">
|
||||
<label for="v2-fb-screenshot" style="font-size:12px;cursor:pointer;">
|
||||
Screenshot anhängen (aktueller Seitenausschnitt)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# ── Audit-Trail-Vorschau ─────────────────────────────────────── #}
|
||||
<details style="margin-bottom:1rem;">
|
||||
<summary style="cursor:pointer;font-size:11px;font-family:var(--font-mono);
|
||||
text-transform:uppercase;letter-spacing:0.06em;
|
||||
color:var(--ecg-text-muted);">
|
||||
Mitgesendeter Kontext (Audit-Trail) ▾
|
||||
</summary>
|
||||
<div id="v2-fb-audit-preview"
|
||||
style="margin-top:0.5rem;padding:0.75rem;
|
||||
background:var(--ecg-bg, #f8f8f5);
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
font-family:var(--font-mono);font-size:10px;
|
||||
color:var(--ecg-text-muted);
|
||||
white-space:pre-wrap;max-height:200px;overflow-y:auto;
|
||||
word-break:break-all;"></div>
|
||||
</details>
|
||||
|
||||
{# ── Status-Anzeige ───────────────────────────────────────────── #}
|
||||
<div id="v2-fb-status" style="display:none;margin-bottom:0.75rem;
|
||||
padding:0.5rem 0.75rem;border-radius:4px;
|
||||
font-size:12px;"></div>
|
||||
|
||||
{# ── Buttons ──────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
|
||||
<button type="button" onclick="v2FeedbackClose()"
|
||||
style="background:none;border:1px solid var(--ecg-light);
|
||||
border-radius:4px;padding:0.5rem 1rem;cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:13px;
|
||||
color:var(--ecg-dark);">Abbrechen</button>
|
||||
<button type="submit" id="v2-fb-submit-btn"
|
||||
style="background:var(--ecg-blue,#1a6fa8);border:none;
|
||||
border-radius:4px;padding:0.5rem 1.25rem;cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:13px;
|
||||
color:#fff;font-weight:600;">Absenden</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Audit-Trail-Sammler + Modal-Logik ────────────────────────────────────── #}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Ringbuffer-Helper ─────────────────────────────────────── */
|
||||
var AUDIT_KEY = 'gwoe.audit';
|
||||
var ERRORS_KEY = 'gwoe.errors';
|
||||
var MAX_CLICKS = 30;
|
||||
var MAX_ERRORS = 10;
|
||||
|
||||
function ringPush(key, item, max) {
|
||||
var arr = [];
|
||||
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) {}
|
||||
arr.push(item);
|
||||
if (arr.length > max) arr = arr.slice(arr.length - max);
|
||||
try { localStorage.setItem(key, JSON.stringify(arr)); } catch (_) {}
|
||||
}
|
||||
|
||||
function ringRead(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) { return []; }
|
||||
}
|
||||
|
||||
/* ── CSS-Pfad (kurz) ────────────────────────────────────────── */
|
||||
function cssPath(el) {
|
||||
if (!el || el === document.body) return 'body';
|
||||
var path = [];
|
||||
var cur = el;
|
||||
while (cur && cur !== document.body && path.length < 4) {
|
||||
var tag = cur.tagName ? cur.tagName.toLowerCase() : '';
|
||||
var id = cur.id ? '#' + cur.id : '';
|
||||
var cls = cur.className && typeof cur.className === 'string'
|
||||
? ('.' + cur.className.trim().split(/\s+/).slice(0,2).join('.'))
|
||||
: '';
|
||||
path.unshift(tag + id + cls);
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
/* ── Click-Listener ─────────────────────────────────────────── */
|
||||
document.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (!target) return;
|
||||
// Feedback-Modal-Klicks nicht tracken
|
||||
if (target.closest && target.closest('#v2-feedback-modal')) return;
|
||||
var text = (target.textContent || target.value || target.alt || '')
|
||||
.trim().slice(0, 60).replace(/\s+/g, ' ');
|
||||
ringPush(AUDIT_KEY, {
|
||||
t: new Date().toISOString(),
|
||||
el: cssPath(target),
|
||||
txt: text || null
|
||||
}, MAX_CLICKS);
|
||||
}, true);
|
||||
|
||||
/* ── Error-Listener ─────────────────────────────────────────── */
|
||||
window.addEventListener('error', function (e) {
|
||||
ringPush(ERRORS_KEY, {
|
||||
t: new Date().toISOString(),
|
||||
msg: e.message || String(e),
|
||||
src: (e.filename || '').replace(window.location.origin, '') + ':' + e.lineno
|
||||
}, MAX_ERRORS);
|
||||
});
|
||||
|
||||
/* ── Modal öffnen/schließen ─────────────────────────────────── */
|
||||
window.v2FeedbackOpen = function () {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (!modal) return;
|
||||
|
||||
// Audit-Vorschau befüllen
|
||||
var preview = document.getElementById('v2-fb-audit-preview');
|
||||
if (preview) {
|
||||
var clicks = ringRead(AUDIT_KEY).slice(-15);
|
||||
var errors = ringRead(ERRORS_KEY).slice(-10);
|
||||
var lines = [];
|
||||
lines.push('URL: ' + window.location.href);
|
||||
lines.push('User-Agent: ' + navigator.userAgent.slice(0, 120));
|
||||
lines.push('Viewport: ' + window.innerWidth + 'x' + window.innerHeight);
|
||||
// Drucksache aus URL extrahieren
|
||||
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
|
||||
if (dsMatch) lines.push('Drucksache: ' + decodeURIComponent(dsMatch[1]));
|
||||
lines.push('');
|
||||
if (clicks.length) {
|
||||
lines.push('Letzte Klicks:');
|
||||
clicks.forEach(function (c) {
|
||||
lines.push(' ' + c.t.slice(11,19) + ' ' + c.el + (c.txt ? ' "' + c.txt + '"' : ''));
|
||||
});
|
||||
}
|
||||
if (errors.length) {
|
||||
lines.push('');
|
||||
lines.push('Console-Errors:');
|
||||
errors.forEach(function (e) {
|
||||
lines.push(' ' + e.t.slice(11,19) + ' ' + e.msg + ' @ ' + e.src);
|
||||
});
|
||||
}
|
||||
preview.textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
// Status zurücksetzen
|
||||
var status = document.getElementById('v2-fb-status');
|
||||
if (status) { status.style.display = 'none'; status.textContent = ''; }
|
||||
var submitBtn = document.getElementById('v2-fb-submit-btn');
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Absenden'; }
|
||||
|
||||
modal.style.display = 'flex';
|
||||
var titel = document.getElementById('v2-fb-titel');
|
||||
if (titel) setTimeout(function () { titel.focus(); }, 50);
|
||||
};
|
||||
|
||||
window.v2FeedbackClose = function () {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
};
|
||||
|
||||
// Schließen bei Klick auf Backdrop
|
||||
document.getElementById('v2-feedback-modal').addEventListener('click', function (e) {
|
||||
if (e.target === this) window.v2FeedbackClose();
|
||||
});
|
||||
|
||||
// Escape-Taste
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (modal && modal.style.display === 'flex') window.v2FeedbackClose();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Submit ─────────────────────────────────────────────────── */
|
||||
window.v2FeedbackSubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var titel = document.getElementById('v2-fb-titel').value.trim();
|
||||
var beschreibung = document.getElementById('v2-fb-beschreibung').value.trim();
|
||||
var screenshot = document.getElementById('v2-fb-screenshot').checked;
|
||||
var submitBtn = document.getElementById('v2-fb-submit-btn');
|
||||
var statusEl = document.getElementById('v2-fb-status');
|
||||
|
||||
function setStatus(msg, ok) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.style.background = ok ? 'rgba(0,128,64,0.1)' : 'rgba(200,0,0,0.08)';
|
||||
statusEl.style.border = '1px solid ' + (ok ? 'rgba(0,128,64,0.3)' : 'rgba(200,0,0,0.2)');
|
||||
statusEl.style.color = ok ? 'var(--ecg-green)' : '#c00';
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gesendet…';
|
||||
|
||||
// Audit-Daten sammeln
|
||||
var clicks = ringRead(AUDIT_KEY).slice(-15);
|
||||
var errors = ringRead(ERRORS_KEY).slice(-10);
|
||||
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('titel', titel);
|
||||
fd.append('beschreibung', beschreibung);
|
||||
fd.append('url', window.location.href);
|
||||
fd.append('user_agent', navigator.userAgent);
|
||||
fd.append('viewport', window.innerWidth + 'x' + window.innerHeight);
|
||||
fd.append('drucksache', dsMatch ? decodeURIComponent(dsMatch[1]) : '');
|
||||
fd.append('klicks_json', JSON.stringify(clicks));
|
||||
fd.append('errors_json', JSON.stringify(errors));
|
||||
|
||||
// Screenshot (optional, via html2canvas)
|
||||
if (screenshot && window.html2canvas) {
|
||||
submitBtn.textContent = 'Screenshot wird erstellt…';
|
||||
try {
|
||||
var canvas = await window.html2canvas(document.body, {
|
||||
scale: Math.min(window.devicePixelRatio || 1, 2),
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
logging: false,
|
||||
});
|
||||
// Breite auf max 800 px beschränken
|
||||
var MAX_W = 800;
|
||||
var finalCanvas = canvas;
|
||||
if (canvas.width > MAX_W) {
|
||||
var ratio = MAX_W / canvas.width;
|
||||
var sc = document.createElement('canvas');
|
||||
sc.width = MAX_W;
|
||||
sc.height = Math.round(canvas.height * ratio);
|
||||
var ctx = sc.getContext('2d');
|
||||
ctx.drawImage(canvas, 0, 0, sc.width, sc.height);
|
||||
finalCanvas = sc;
|
||||
}
|
||||
var dataUrl = finalCanvas.toDataURL('image/jpeg', 0.7);
|
||||
fd.append('screenshot', dataUrl);
|
||||
} catch (err) {
|
||||
// Screenshot fehlgeschlagen → trotzdem absenden
|
||||
fd.append('screenshot_error', String(err));
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.textContent = 'Wird gesendet…';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/feedback', { method: 'POST', body: fd });
|
||||
var data = await resp.json();
|
||||
if (resp.ok && data.issue_url) {
|
||||
setStatus('Danke! Issue angelegt: ' + data.issue_url, true);
|
||||
submitBtn.textContent = 'Abgeschlossen';
|
||||
// Felder leeren
|
||||
document.getElementById('v2-fb-titel').value = '';
|
||||
document.getElementById('v2-fb-beschreibung').value = '';
|
||||
document.getElementById('v2-fb-screenshot').checked = false;
|
||||
setTimeout(window.v2FeedbackClose, 3000);
|
||||
} else {
|
||||
setStatus('Fehler: ' + (data.detail || JSON.stringify(data)), false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Absenden';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Netzwerkfehler: ' + err.message, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Absenden';
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# html2canvas — self-hosted, kein CDN-Call #}
|
||||
<script src="/static/v2/lib/html2canvas.min.js"></script>
|
||||
159
tests/test_feedback_endpoint.py
Normal file
159
tests/test_feedback_endpoint.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Unit-Tests für /api/feedback — gemockter Gitea-Call.
|
||||
|
||||
Prüft:
|
||||
- Issue-Body wird korrekt aus Eingaben + Audit-Trail zusammengebaut
|
||||
- Endpoint antwortet mit issue_id und issue_url
|
||||
- Rate-Limit-Decorator ist deklariert
|
||||
- Kein Token → 503
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# Skip falls die App-Abhängigkeiten nicht importierbar sind
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app, _strip_html, _gitea_ensure_label
|
||||
_HAS_APP = True
|
||||
except ImportError:
|
||||
_HAS_APP = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
|
||||
|
||||
|
||||
# ── _strip_html ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStripHtml:
|
||||
def test_removes_tags(self):
|
||||
assert _strip_html("<b>hallo</b>", 200) == "hallo"
|
||||
|
||||
def test_max_len(self):
|
||||
assert len(_strip_html("a" * 300, 100)) == 100
|
||||
|
||||
def test_empty(self):
|
||||
assert _strip_html("", 200) == ""
|
||||
|
||||
def test_no_tags(self):
|
||||
assert _strip_html("plain text", 200) == "plain text"
|
||||
|
||||
|
||||
# ── /api/feedback Endpoint ───────────────────────────────────────────────────
|
||||
|
||||
class TestFeedbackEndpoint:
|
||||
"""Smoke-Tests mit gemocktem httpx-Client + gemocktem gitea_token."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_gitea(self):
|
||||
"""Patches settings.gitea_token und httpx.AsyncClient."""
|
||||
# settings.gitea_token setzen
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.gitea_token = "fake-token-123"
|
||||
mock_settings.gitea_api_url = "https://repo.example.com/api/v1"
|
||||
mock_settings.gitea_repo_owner = "testowner"
|
||||
mock_settings.gitea_repo_name = "testrepo"
|
||||
|
||||
# httpx.AsyncClient mocken
|
||||
mock_resp_labels = MagicMock()
|
||||
mock_resp_labels.status_code = 200
|
||||
mock_resp_labels.json.return_value = [{"id": 7, "name": "feedback"}]
|
||||
|
||||
mock_resp_issue = MagicMock()
|
||||
mock_resp_issue.status_code = 201
|
||||
mock_resp_issue.json.return_value = {
|
||||
"number": 42,
|
||||
"html_url": "https://repo.example.com/testowner/testrepo/issues/42",
|
||||
}
|
||||
|
||||
async_client = AsyncMock()
|
||||
async_client.get.return_value = mock_resp_labels
|
||||
async_client.post.return_value = mock_resp_issue
|
||||
async_client.__aenter__ = AsyncMock(return_value=async_client)
|
||||
async_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=async_client):
|
||||
self._async_client = async_client
|
||||
yield
|
||||
|
||||
def test_happy_path_returns_issue_url(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Test-Bug",
|
||||
"beschreibung": "Etwas ist kaputt",
|
||||
"url": "https://gwoe.toppyr.de/antrag/NRW-18/1234",
|
||||
"drucksache": "NRW-18/1234",
|
||||
"viewport": "1440x900",
|
||||
"user_agent": "TestAgent/1.0",
|
||||
"klicks_json": json.dumps([{"t": "2026-04-25T10:00:00Z", "el": "button.v2-nav-item", "txt": "Durchsuchen"}]),
|
||||
"errors_json": json.dumps([]),
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["issue_id"] == 42
|
||||
assert "issues/42" in data["issue_url"]
|
||||
|
||||
def test_issue_body_contains_drucksache(self):
|
||||
"""Stellt sicher, dass die Drucksachen-Nummer im POST-Payload auftaucht."""
|
||||
client = TestClient(app)
|
||||
client.post("/api/feedback", data={
|
||||
"titel": "Body-Check",
|
||||
"beschreibung": "Details",
|
||||
"drucksache": "BY-18/9999",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
# Zweiter Post-Call ist der Issue-Create-Call
|
||||
calls = self._async_client.post.call_args_list
|
||||
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
||||
assert issue_call is not None
|
||||
body_arg = issue_call.kwargs.get("json", {}).get("body", "")
|
||||
assert "BY-18/9999" in body_arg
|
||||
|
||||
def test_missing_titel_returns_422(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"beschreibung": "Ohne Titel",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_missing_beschreibung_returns_422(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Ohne Beschreibung",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_html_stripped_from_titel(self):
|
||||
"""XSS im Titel wird entfernt."""
|
||||
client = TestClient(app)
|
||||
client.post("/api/feedback", data={
|
||||
"titel": "<script>alert(1)</script>Bug",
|
||||
"beschreibung": "XSS-Test",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
calls = self._async_client.post.call_args_list
|
||||
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
||||
if issue_call:
|
||||
title_arg = issue_call.kwargs.get("json", {}).get("title", "")
|
||||
assert "<script>" not in title_arg
|
||||
|
||||
def test_no_token_returns_503(self):
|
||||
"""Ohne konfiguriertes Token gibt es 503."""
|
||||
with patch("app.main.settings") as s:
|
||||
s.gitea_token = ""
|
||||
s.gitea_api_url = "https://repo.example.com/api/v1"
|
||||
s.gitea_repo_owner = "testowner"
|
||||
s.gitea_repo_name = "testrepo"
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Test",
|
||||
"beschreibung": "Kein Token",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 503
|
||||
Loading…
Reference in New Issue
Block a user