Compare commits

...

20 Commits

Author SHA1 Message Date
Dotty Dotter
4b03448e29 fix(feedback): Screenshot scharf + ohne Feedback-UI
- Auflösung: scale = window.devicePixelRatio (statt min:2 cap) — Retina-scharf
- Vor dem html2canvas-Capture werden v2-feedback-{modal,overlay,btn} auf
  display:none gesetzt; finally-Block stellt UI zurueck. Damit ist die
  ausgegraute Modal-Schicht nicht im Bild
- Capture nur des sichtbaren Viewports (width/height/x/y/windowWidth/Height
  explizit), spart Bandbreite + zeigt was der User wirklich sieht
- MAX_W 800 -> 1600, JPEG 0.7 -> 0.85, imageSmoothingQuality high
- requestAnimationFrame x2 vor capture, damit Browser den Reflow vor dem Snap fertig hat
- app_version 1.0.1 -> 1.0.2 (Cache-Buster)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:10:36 +02:00
Dotty Dotter
07bb832c35 ops: GITEA_TOKEN + GITEA_*-Settings im docker-compose.yml durchreichen
Container hatte kein GITEA_TOKEN trotz Eintrag in .env, weil docker-compose.yml
env-Vars explizit listet. 4 neue Eintraege fuer das Feedback-Widget (#149-Folge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:06:57 +02:00
Dotty Dotter
a8d7b72702 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>
2026-04-28 01:00:44 +02:00
Dotty Dotter
fab1bddd3c fix(v2): Hamburger-Toggle wirklich ausblenden (Specificity-Konflikt + Cache)
Bug: .v2-topbar button {display:inline-flex} ueberschreibt .v2-menu-toggle{display:none}
wegen hoeherer Specificity. Fix: Selektor .v2-topbar .v2-menu-toggle + !important.

Plus app_version 1.0.0 -> 1.0.1 als Cache-Buster fuer alle CSS-Refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:37:55 +02:00
Dotty Dotter
98787c8684 fix(v2): Cache-Buster fuer CSS via ?v=app_version
Browser-Cache zeigte alte v2.css ohne v2-menu-toggle-display:none-Regel.
Mit ?v=1.0.0 wird auf Versionsspruenge sauber neu geladen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:33:18 +02:00
Dotty Dotter
b1ad2bd45d fix(v2): Hamburger-Menü-Toggle nur auf Mobile (< 900 px) sichtbar
Auf Desktop ist die Sidebar permanent — der Burger-Button hatte dort keine
Funktion. display: none default + @media max-width:900px → inline-flex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:28:51 +02:00
Dotty Dotter
7a64335e64 feat(auth): 'Passwort vergessen?'-Link im v2-Login-Modal
Klick öffnet /api/auth/forgot-password → 302 zur Keycloak-Reset-Page mit
client_id + redirect_uri (auf eigene Domain). Keycloak schickt Mail mit
Reset-Link, User setzt neues Passwort, kommt zurück.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:21:02 +02:00
Dotty Dotter
c1926ada4f feat(#143): Registrierungs-Bestätigungsmail an User direkt nach Anmeldung
Vorher: User registriert -> Keycloak-User mit enabled=false angelegt -> KEINE
Mail bis Admin manuell freischaltet. UX-Luecke: User weiss zwischen Klick und
Admin-Freischaltung nicht, ob etwas passiert ist.

Jetzt: nach erfolgreichem Keycloak-User-Create wird sofort eine Bestaetigungs-
Mail an die angegebene Adresse geschickt mit Hinweis auf den 3-Schritt-Flow
(Anmeldung -> Admin-Freischaltung -> Passwort-Setzen-Mail). Plain-Text + HTML.
Fehler beim Mail-Versand wird geloggt aber nicht weitergereicht — User-Anlage
ist davon unabhaengig.

Response-Message angepasst: 'Wir haben dir eine Bestaetigung per E-Mail geschickt.'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:40:04 +02:00
Dotty Dotter
6581acd28e ux(v2): Partei-Dropdown statt Freitext in /v2/abos und /v2/feed
Beide Routes liefern jetzt all_canonical_keys() (ohne Landesregierung) als Dropdown-
Optionen. Verhindert Tippfehler und gibt nur tatsaechlich erkannte Parteien zur Auswahl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:37:31 +02:00
Dotty Dotter
7cbd46f88d feat(v2): Atom-Feed-Konfig-Seite + Eigene-Abos-Verwaltung
Backend (Filter sind seit jeher da):
- /api/feed.xml?bundesland=&partei=&limit=
- /api/subscriptions GET/POST/DELETE

UI:
- /v2/feed: Form mit BL/Partei/Limit, generiert Feed-URL live, Buttons Oeffnen/
  URL-Kopieren/In-Feedly. Default-BL aus Header-Selektor uebernommen
- /v2/abos: Liste eigener Abos + Form zum Anlegen/Loeschen, BL-Dropdown,
  Partei-Freitext, Frequenz daily/weekly
- Sidebar 'Daten'-Gruppe um beide Eintraege erweitert (statt Direkt-Link auf
  /api/feed.xml)
- Beide Routen mit Depends(require_auth) — Anonyme bekommen 401-Redirect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:34:55 +02:00
Dotty Dotter
7f070b5e6c fix(v2): Topbar harte Hoehe 32px + Kleine-Anfragen-Heuristik in Landtag-Suche
Topbar:
- height: 32px (statt auto), line-height: 1, alle children max 24px
- Topbar-Icons explizit auf 12x12 (statt 14)
- selects/buttons/a mit fester Hoehe 22px, padding 2px 6px

Landtag-Suche:
- search_landtag filtert jetzt Drucksachen aus, deren Titel typische
  Frage-Praefixe haben (Welche/Wie viele/Wann/Was/Hat/Ist/...)  oder mit '?'
  enden — bei NRW-OPAL liefert der Adapter alle als 'sonstige', daher
  Title-Heuristik. Server-side, damit alle Adapter profitieren.
- Neuer Helper drucksache_typen.likely_kleine_anfrage_titel()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:23:22 +02:00
Dotty Dotter
fa5a5b6026 ux(v2): Prüfen + Daten-Sidebar-Gruppen ganz ausblenden ohne Auth (statt nur leere Labels)
Vorher: '— Pruefen' + '— Daten'-Labels waren sichtbar, aber alle Eintraege darin
hidden — nur ein verlorener Header. Jetzt: ganzer Gruppen-Container hinter
{% if is_authenticated %} → Anonymous-User sieht nur 'Lesen'-Gruppe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:18:58 +02:00
Dotty Dotter
85a10b7fc3 ux(v2): bessere Anzeige für 'skipped' Drucksachen (Kleine Anfragen etc.)
Vorher: Button-Text 'Übersprungen', der Grund nur als Tooltip — User versteht
nicht warum. Jetzt: 'Nicht abstimmbar' + sichtbare Italic-Begruendung unter der
Zeile mit dem konkreten Reason-Text vom Server (Backend liefert reason, typ
und typ_normiert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:17:11 +02:00
Dotty Dotter
997d59a9a5 fix(v2): Queue-Widget ist immer sichtbar (auch ohne aktive Jobs)
Vorher: filterte stale-Jobs raus, bei leerer aktiver Queue display:none → User sah nichts.
Jetzt: immer sichtbar mit 'Queue leer · N Worker bereit' wenn nichts aktiv.
Tooltip zeigt Stale-Jobs als 'letzter Lauf'-Liste, wenn keine aktiven Jobs da sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:13:30 +02:00
Dotty Dotter
273d45ea36 fix: PDF-Link mit #page=N-Hash — Browser-PDF-Viewer landet jetzt direkt auf der richtigen Seite
Browser-PDF-Reader (Chrome, Firefox) ignorieren das von /OpenAction-Eintrag im
PDF-Catalog (#88f9c7d) komplett. Der zuverlaessige Weg: URL-Hash-Anker '#page=N'.

Drei Stellen angepasst:
- redline_utils.build_pdf_href: haengt #page={seite} an die URL
- embeddings._build_zitat_url (rebind): analog
- v2/components/quote_card.html: bei alten DB-Eintraegen ohne Hash wird er
  on-the-fly aus dem 'seite='-Query-Param erzeugt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:09:46 +02:00
Dotty Dotter
88f9c7db6c fix: PDF-Endpoint setzt OpenAction auf gefundene Seite + Topbar weiter komprimiert
Vorher: /api/wahlprogramm-cite lieferte das gesamte PDF mit Highlight-Annot
auf der gefundenen Seite, aber der Browser-PDF-Viewer landete auf Seite 1.
Sieht User: 'PDF oeffnet, aber falsche Seite'.

Jetzt: doc.xref_set_key(catalog, 'OpenAction', '[<page-ref> 0 R /Fit]')
schreibt eine PDF-Open-Action ins Dokument-Catalog. Reader springt beim
Oeffnen direkt auf target_page_idx, ohne dass Browser-Hash-Anker noetig sind.

Plus: Topbar select/button padding-top/bottom 1px, links 0px (User: 'nur so
hoch wie noetig').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:06:39 +02:00
Dotty Dotter
489a1915f8 fix: PDF-Highlight strippt führende Seitenzahl + Topbar noch kompakter
- render_highlighted_page: führende Seitenzahl-Tokens ('44 Gute Bildung …')
  vor search_for entfernen — LLMs ziehen den Header oft ins Zitat mit, was
  PyMuPDFs Volltext-Match scheitern lässt
- v2-Topbar: padding 4px -> 2px, line-height 1.2, min-height entfernt
  (auto-size, nur so hoch wie noetig)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:04:26 +02:00
Dotty Dotter
50c026e3a0 fix(v2): Topbar-Höhe runter, Share-Felder erweitert (Kopieren/LinkedIn/Email/Bild), Smoke-Test 401-Pattern
- Topbar padding 10px -> 4px, min-height 32px (User: 'Header weniger hoch')
- Share-Buttons im Antragsdetail erweitert auf 7 Plattformen analog v1:
  Kopieren (Clipboard), Threads, X, Mastodon, LinkedIn, E-Mail (mailto), Bild (Freepik)
- v2DetailShareCopy/Email/Image-Helper, ANTRAG_TOPICS ans Template uebergeben
- Smoke-Test akzeptiert 401 fuer auth-protected Routen (curl ohne Accept-Header
  bekommt 401-JSON, echte Browser bekommen 302-Redirect via _auth_redirect_handler)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:57:04 +02:00
Dotty Dotter
553e99d14e feat(v2): globaler BL-Selector im Header + Auth-gated Sidebar + Queue-Widget
Bundesland-Auswahl:
- Topbar: einziger BL-Selektor mit localStorage.gwoe.bl-Persistenz
- BL-Felder entfernt aus durchsuchen.html, landtag_suche.html, neu.html, auswertungen.html
- Screens hoeren auf v2-bl-changed CustomEvent + initial via window.v2GetGlobalBl()

Sichtbarkeit (Sidebar):
- Durchsuchen + Tags: immer
- Merkliste / Neuer Antrag / Landtag-Suche / Auswertungen / Export / Feed: nur eingeloggt
- Cluster + Batch-Analyse + Administration: nur Admin

Server-Side Schutz:
- _v2_template_context()-Helper liefert is_authenticated, is_admin, v2_bundeslaender
- HTML-Routen mit Depends(require_auth) bzw. require_admin
- 401/403-Browser-Requests redirecten auf /?login=1 statt JSON-Error

Queue-Widget (#149):
- Neues Component-Partial v2/components/queue_widget.html
- Statusbar unten links + Hover-Tooltip mit den letzten 20 Jobs
- 5s-Polling auf /api/queue/status, blendet sich aus wenn keine Jobs

Smoke-Test angepasst an neue Auth-Erwartungen (302 fuer auth-protected Routen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:50:36 +02:00
Dotty Dotter
38bffb23fa fix: Job-Polling vor Redirect statt sofortigem Antrag-nicht-gefunden
Vorher: Klick 'Analysieren' -> POST /api/analyze-drucksache -> sofort
window.location.href = '/antrag/{ds}' -> aber Job laeuft noch im Hintergrund
-> Detail-Seite zeigt 'Antrag nicht gefunden'.

Jetzt:
- already_checked -> sofortiger Redirect
- skipped (nicht abstimmbar) -> Hinweistext im Form
- queued -> Polling auf /status/{job_id} alle 2s, max 3 Min
- completed -> Redirect zur Detail-Seite
- failed/rejected -> Fehlermeldung mit Grund

Anwendung in v2/screens/landtag_suche.html + v2/screens/neu.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:35:55 +02:00
23 changed files with 1722 additions and 111 deletions

View File

@ -4,7 +4,7 @@ from pathlib import Path
class Settings(BaseSettings):
app_name: str = "GWÖ-Antragsprüfer"
app_version: str = "1.0.0"
app_version: str = "1.0.2"
prompt_version: str = "v4.1"
# Paths
@ -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"}

View File

@ -86,3 +86,45 @@ def ist_abstimmbar(typ_normiert: str) -> bool:
def ist_abstimmbar_original(original: str) -> bool:
"""Convenience: prüft direkt am Original-Typ-String."""
return ist_abstimmbar(normalize_typ(original))
# Frage-Präfixe die typisch für Kleine Anfragen sind. Wird genutzt wenn der
# Adapter (z.B. NRW) den Typ nur als "Drucksache" liefert — wir versuchen
# anhand des Titels eine bessere Klassifikation, damit Search-Ergebnisse
# nicht voll mit nicht-abstimmbaren Anfragen sind.
_FRAGE_PRAEFIXE = (
"welche ", "wie viele ", "wieviel", "wie viel ", "wie hoch ", "wie ",
"wann ", "warum ", "weshalb ", "wo ", "wer ", "wie steht ", "wie weit ",
"ist es ", "ist der ", "ist die ", "ist das ", "sind ",
"trifft es ", "kann ", "wird ", "wieso ", "was ",
"hat ", "hat der ", "hat die ", "hat das ",
"haben ", "war ", "waren ",
)
def likely_kleine_anfrage_titel(title: str) -> bool:
"""Heuristik: erkennt Kleine Anfragen am Titel-Format.
Wenn der Titel mit einem typischen Frage-Präfix beginnt oder mit "?" endet,
behandeln wir die Drucksache als Kleine Anfrage. NRW-OPAL klassifiziert
alle Drucksachen als "Drucksache" ohne diese Heuristik landen Anfragen
in den Search-Ergebnissen, was den User verwirrt (#149 Folge).
Args:
title: Drucksachen-Titel inkl. evtl. Nummer-Präfix wie "1Welche...".
Returns:
True wenn der Titel wie eine Kleine Anfrage aussieht.
"""
if not title:
return False
t = title.strip()
# Manche Adapter prefixen mit Nummerierung wie "1Welche..." — strippen
while t and (t[0].isdigit() or t[0] in " .-"):
t = t[1:]
t_low = t.lower()
if t_low.startswith(_FRAGE_PRAEFIXE):
return True
if t.rstrip().endswith("?"):
return True
return False

View File

@ -714,7 +714,7 @@ def _chunk_pdf_url(chunk: dict) -> Optional[str]:
# die URL bleibt bounded (sonst würden 500-Zeichen-Snippets in jeder
# Zitat-URL stehen und das HTML-Report-JSON aufblähen).
q = urllib.parse.quote_plus(text[:200])
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}"
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}#page={seite}"
if seite:
return f"/static/referenzen/{pdf}#page={seite}"
@ -777,9 +777,14 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
rects = []
if needle:
clean = needle.replace("\u00ad", "")
# LLMs ziehen h\u00e4ufig die Seitenzahl-Header (\u201e44 Gute Bildung \u2026")
# mit ins Zitat. Wenn die ersten Tokens reine Ziffern sind,
# strippen wir sie f\u00fcr die Suche \u2014 sonst matched search_for nicht.
import re as _re
clean = _re.sub(r"^\s*\d+\s+", "", clean).strip()
words = clean.split()
anchor = " ".join(words[:5]) if len(words) >= 5 else clean
# Versuch 1: angegebene Seite, Volltext
# Versuch 1: angegebene Seite, Volltext (gestrippt)
rects = src[target_page_idx].search_for(clean)
# Versuch 2: angegebene Seite, 5-Wort-Anker
if not rects:
@ -792,8 +797,7 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
target_page_idx = i
break
# Volles PDF mit Highlight-Annotation. Der Browser öffnet das
# vollständige Wahlprogramm; das Frontend hängt #page=N an die URL.
# Volles PDF mit Highlight-Annotation.
page = src[target_page_idx]
if needle and rects:
for rect in rects:
@ -802,6 +806,16 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb
annot.update()
# PDF-OpenAction setzen, damit der Reader direkt auf der richtigen
# Seite startet (statt Seite 1) — sonst sieht der User „PDF öffnet,
# aber falsche Seite". /Fit = passt-zur-Größe.
try:
page_xref = page.xref
catalog_xref = src.pdf_catalog()
src.xref_set_key(catalog_xref, "OpenAction", f"[{page_xref} 0 R /Fit]")
except Exception:
logger.exception("render_highlighted_page: OpenAction-Setzen fehlgeschlagen")
highlighted = bool(needle and rects)
try:
return src.tobytes(), target_page_idx + 1, highlighted

View File

@ -91,6 +91,25 @@ app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Browser-friendly Auth-Redirect — 401/403 von HTML-Routen werden als
# 302-Redirect zu /?login=1 ausgeliefert (Login-Modal öffnet sich automatisch).
# API-Calls (Accept: application/json) bleiben bei 401/403-JSON.
@app.exception_handler(HTTPException)
async def _auth_redirect_handler(request: Request, exc: HTTPException):
if exc.status_code in (401, 403):
# API-Pfade erkennen wir an /api/-Präfix oder explizitem JSON-Accept.
accept = request.headers.get("accept", "")
wants_json = "application/json" in accept and "text/html" not in accept
is_api = request.url.path.startswith("/api/")
is_browser = not is_api and not wants_json
if is_browser:
from fastapi.responses import RedirectResponse
target = f"/?login=1&next={request.url.path}"
return RedirectResponse(url=target, status_code=302)
# Default-Verhalten von FastAPI nachbauen
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers or None)
# Security Headers Middleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
@ -127,6 +146,28 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=str(templates_dir))
# ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ─────────────────
@app.exception_handler(401)
async def auth_required_redirect(request: Request, exc: HTTPException):
"""Bei 401 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal)."""
accept = request.headers.get("accept", "")
if "text/html" in accept:
from fastapi.responses import RedirectResponse
return RedirectResponse("/?login=1", status_code=302)
return JSONResponse({"detail": exc.detail}, status_code=401)
@app.exception_handler(403)
async def admin_required_redirect(request: Request, exc: HTTPException):
"""Bei 403 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal)."""
accept = request.headers.get("accept", "")
if "text/html" in accept:
from fastapi.responses import RedirectResponse
return RedirectResponse("/?login=1", status_code=302)
return JSONResponse({"detail": exc.detail}, status_code=403)
@app.on_event("startup")
async def startup():
import asyncio
@ -209,7 +250,7 @@ async def classic_index(request: Request):
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
async def index(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
"""Startseite — rendert v2-Listenansicht (Default-Flip Phase 2).
Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf
@ -230,11 +271,12 @@ async def index(request: Request):
"assessments": assessments,
"bl_codes": bl_codes,
"assessment_count": len(assessments),
**_v2_template_context(current_user),
})
@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse)
async def antrag_detail(request: Request, drucksache: str):
async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)):
"""v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten."""
try:
drucksache = validate_drucksache(drucksache)
@ -243,6 +285,7 @@ async def antrag_detail(request: Request, drucksache: str):
"request": request,
"v2_active_nav": "durchsuchen",
"error": f"Ungültige Drucksachen-ID: {drucksache}",
**_v2_template_context(current_user),
}, status_code=400)
row = await get_assessment(drucksache)
@ -251,6 +294,7 @@ async def antrag_detail(request: Request, drucksache: str):
"request": request,
"v2_active_nav": "durchsuchen",
"error": f"Antrag {drucksache} wurde nicht gefunden.",
**_v2_template_context(current_user),
}, status_code=404)
antrag = _row_to_detail(row)
@ -294,9 +338,34 @@ async def antrag_detail(request: Request, drucksache: str):
"E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
},
"matrix_labels": MATRIX_LABELS,
**_v2_template_context(current_user),
})
def _v2_template_context(current_user=None) -> dict:
"""Gemeinsame v2-Template-Variablen: is_admin, is_authenticated, v2_bundeslaender.
Wird in jeder v2-Route aufgerufen und per **-Spread in den Template-Context gemischt.
"""
is_authenticated = bool(current_user and current_user.get("authenticated", False))
# require_auth liefert keinen "authenticated"-Key, aber ein sub-Feld — beides prüfen
if current_user and current_user.get("sub"):
is_authenticated = True
roles = (current_user or {}).get("roles", [])
is_admin = "admin" in roles or "gwoe-admin" in roles
v2_bls = [
{"code": bl.code, "name": bl.name}
for bl in alle_bundeslaender()
if bl.aktiv
]
return {
"is_authenticated": is_authenticated,
"is_admin": is_admin,
"v2_bundeslaender": v2_bls,
"app_version": settings.app_version,
}
def _rows_to_list(rows):
"""Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht."""
result = []
@ -648,6 +717,25 @@ async def auth_login_url(request: Request, redirect: str = "/"):
return {"enabled": True, "url": url}
@app.get("/api/auth/forgot-password")
async def auth_forgot_password(request: Request):
"""Redirect zur Keycloak-Passwort-Reset-Seite (#143-Folge).
Keycloak bietet bei `resetPasswordAllowed=True` eine eigene Reset-Page,
die per Mail einen Link zum Passwort-Setzen schickt. Wir leiten direkt
dahin um statt eine eigene UI zu bauen.
"""
from fastapi.responses import RedirectResponse
base = str(request.base_url).rstrip("/").replace("http://", "https://")
issuer = f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
target = (
f"{issuer}/login-actions/reset-credentials"
f"?client_id={settings.keycloak_client_id}"
f"&redirect_uri={base}/"
)
return RedirectResponse(url=target, status_code=302)
@app.post("/api/auth/login")
async def auth_direct_login(
username: str = Form(...),
@ -1015,7 +1103,37 @@ async def auth_register(
if create_resp.status_code != 201:
raise HTTPException(status_code=500, detail="Registrierung fehlgeschlagen")
return {"status": "pending_approval", "message": "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen."}
# #143: Bestätigungsmail an User direkt nach Anmeldung
try:
from .mail import send_mail
anrede = f"{firstName} {lastName}".strip() or username
text_body = (
f"Hallo {anrede},\n\n"
f"deine Registrierung am GWÖ-Antragsprüfer ist eingegangen.\n\n"
f"Was passiert jetzt?\n"
f" 1. Ein Admin schaltet deinen Account manuell frei.\n"
f" 2. Du erhältst dann eine separate Mail mit einem Link zum Passwort-Setzen.\n"
f" 3. Anschließend kannst du dich auf https://gwoe.toppyr.de/ anmelden.\n\n"
f"Falls du nach 1-2 Werktagen nichts hörst, melde dich gerne unter mail@tobiasroedel.de.\n\n"
f"Schöne Grüße\nGWÖ-Antragsprüfer"
)
html_body = (
f"<p>Hallo <strong>{anrede}</strong>,</p>"
f"<p>deine Registrierung am <a href=\"https://gwoe.toppyr.de/\">GWÖ-Antragsprüfer</a> ist eingegangen.</p>"
f"<p><strong>Was passiert jetzt?</strong></p>"
f"<ol>"
f"<li>Ein Admin schaltet deinen Account manuell frei.</li>"
f"<li>Du erhältst dann eine separate Mail mit einem Link zum Passwort-Setzen.</li>"
f"<li>Anschließend kannst du dich auf <a href=\"https://gwoe.toppyr.de/\">gwoe.toppyr.de</a> anmelden.</li>"
f"</ol>"
f"<p>Falls du nach 1-2 Werktagen nichts hörst, melde dich gerne unter <a href=\"mailto:mail@tobiasroedel.de\">mail@tobiasroedel.de</a>.</p>"
f"<p style=\"color:#666;font-size:0.9em\">Schöne Grüße<br>GWÖ-Antragsprüfer</p>"
)
await send_mail(email, "GWÖ-Antragsprüfer — Registrierung eingegangen", text_body, html_body)
except Exception:
logger.exception("Bestätigungsmail an %s fehlgeschlagen — User-Anlage war aber erfolgreich", email)
return {"status": "pending_approval", "message": "Registrierung eingegangen. Wir haben dir eine Bestätigung per E-Mail geschickt."}
@app.get("/api/auth/pending-users")
@ -1265,8 +1383,13 @@ async def search_landtag(
try:
external = adapter._filter_abstimmbar(await adapter.search(q, limit))
# Zusätzliche Title-Heuristik: bei Adaptern die Typ='Drucksache' liefern
# (NRW), Kleine-Anfrage-Frage-Pattern erkennen und ausfiltern.
from .drucksache_typen import likely_kleine_anfrage_titel, KLEINE_ANFRAGE
results = []
for doc in external:
if doc.typ_normiert == "sonstige" and likely_kleine_anfrage_titel(doc.title):
continue # höchstwahrscheinlich Kleine Anfrage
results.append({
"drucksache": doc.drucksache,
"title": doc.title,
@ -1588,25 +1711,27 @@ async def list_bundeslaender():
# === Impressum / Datenschutz ===
@app.get("/impressum", response_class=HTMLResponse)
async def impressum_page(request: Request):
async def impressum_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
return templates.TemplateResponse("v2/screens/legal.html", {
"request": request, "app_name": settings.app_name,
"title": "Impressum", "section": "impressum",
**_v2_template_context(current_user),
})
@app.get("/datenschutz", response_class=HTMLResponse)
async def datenschutz_page(request: Request):
async def datenschutz_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
return templates.TemplateResponse("v2/screens/legal.html", {
"request": request, "app_name": settings.app_name,
"title": "Datenschutzerklärung", "section": "datenschutz",
**_v2_template_context(current_user),
})
# === Quellen / Programme ===
@app.get("/methodik", response_class=HTMLResponse)
async def methodik_page(request: Request):
async def methodik_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
"""Transparenz-/Methodik-Seite (#96)."""
from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER
from .embeddings import get_indexing_status
@ -1630,11 +1755,12 @@ async def methodik_page(request: Request):
"programme_count": status.get("total", 0),
"chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])),
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
})
@app.get("/quellen", response_class=HTMLResponse)
async def quellen_page(request: Request):
async def quellen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert."""
from .bundeslaender import BUNDESLAENDER
programmes = get_programme_info()
@ -1661,6 +1787,7 @@ async def quellen_page(request: Request):
"wahlprogramme_grouped": wahlprogramme_grouped,
"grundsatzprogramme": grundsatz,
"status": status,
**_v2_template_context(current_user),
})
@ -1850,8 +1977,8 @@ async def index_programme(
@app.get("/auswertungen", response_class=HTMLResponse)
async def auswertungen_page(request: Request):
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic)."""
async def auswertungen_page(request: Request, current_user: dict = Depends(require_auth)):
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic). Auth-only."""
from .auswertungen import get_wahlperioden
from .bundeslaender import alle_bundeslaender
@ -1864,6 +1991,7 @@ async def auswertungen_page(request: Request):
"v2_active_nav": "auswertungen",
"wahlperioden": wahlperioden,
"bl_codes": bl_codes,
**_v2_template_context(current_user),
})
@ -2171,26 +2299,55 @@ async def v2_antrag_redirect(request: Request, drucksache: str):
@app.get("/v2/merkliste", response_class=HTMLResponse)
async def v2_merkliste(request: Request):
"""Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig."""
async def v2_merkliste(request: Request, current_user: dict = Depends(require_auth)):
"""Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig."""
return templates.TemplateResponse("v2/screens/merkliste.html", {
"request": request,
"v2_active_nav": "merkliste",
**_v2_template_context(current_user),
})
@app.get("/v2/tags", response_class=HTMLResponse)
async def v2_tags(request: Request):
async def v2_tags(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
"""Tag-Cloud-Seite — Themen-Filter über alle Assessments."""
return templates.TemplateResponse("v2/screens/tags.html", {
"request": request,
"v2_active_nav": "tags",
**_v2_template_context(current_user),
})
@app.get("/v2/abos", response_class=HTMLResponse)
async def v2_abos(request: Request, current_user: dict = Depends(require_auth)):
"""Eigene E-Mail-Abos verwalten — auth-only."""
from .parteien import all_canonical_keys
# Landesregierung als Filter unsinnig — ausblenden
parteien = [p for p in all_canonical_keys() if p != "Landesregierung"]
return templates.TemplateResponse("v2/screens/abos.html", {
"request": request,
"v2_active_nav": "abos",
"parteien": parteien,
**_v2_template_context(current_user),
})
@app.get("/v2/feed", response_class=HTMLResponse)
async def v2_feed(request: Request, current_user: dict = Depends(require_auth)):
"""Atom-Feed-Konfigurations-Seite — auth-only."""
from .parteien import all_canonical_keys
parteien = [p for p in all_canonical_keys() if p != "Landesregierung"]
return templates.TemplateResponse("v2/screens/feed.html", {
"request": request,
"v2_active_nav": "feed",
"parteien": parteien,
**_v2_template_context(current_user),
})
@app.get("/v2/cluster", response_class=HTMLResponse)
async def v2_cluster(request: Request):
"""Cluster-Liste — Top-10 Cluster als redaktionelle Liste."""
async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)):
"""Cluster-Liste — nur für Admins."""
rows = await get_all_assessments(None)
assessments = _rows_to_list(rows)
bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")})
@ -2198,12 +2355,13 @@ async def v2_cluster(request: Request):
"request": request,
"v2_active_nav": "cluster",
"bl_codes": bl_codes,
**_v2_template_context(current_user),
})
@app.get("/v2/neu", response_class=HTMLResponse)
async def v2_neu(request: Request):
"""Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache."""
async def v2_neu(request: Request, current_user: dict = Depends(require_auth)):
"""Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache."""
from .bundeslaender import alle_bundeslaender
bl_list = [
{"code": bl.code, "name": bl.name}
@ -2215,12 +2373,13 @@ async def v2_neu(request: Request):
"v2_active_nav": "neu",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
"default_model": settings.llm_model_default,
**_v2_template_context(current_user),
})
@app.get("/v2/landtag-suche", response_class=HTMLResponse)
async def v2_landtag_suche(request: Request):
"""Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB)."""
async def v2_landtag_suche(request: Request, current_user: dict = Depends(require_auth)):
"""Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal."""
from .bundeslaender import alle_bundeslaender
bl_list = [
{"code": bl.code, "name": bl.name}
@ -2231,12 +2390,13 @@ async def v2_landtag_suche(request: Request):
"request": request,
"v2_active_nav": "landtag_suche",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
})
@app.get("/v2/batch", response_class=HTMLResponse)
async def v2_batch(request: Request):
"""Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL."""
async def v2_batch(request: Request, current_user: dict = Depends(require_admin)):
"""Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL."""
from .bundeslaender import alle_bundeslaender
bl_list = [
{"code": bl.code, "name": bl.name}
@ -2247,6 +2407,7 @@ async def v2_batch(request: Request):
"request": request,
"v2_active_nav": "batch",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
})
@ -2258,7 +2419,7 @@ async def v2_admin_freischaltungen(request: Request, user: dict = Depends(requir
return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", {
"request": request,
"v2_active_nav": "admin_freischaltungen",
"is_admin": True,
**_v2_template_context(user),
})
@ -2268,7 +2429,7 @@ async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)):
return templates.TemplateResponse("v2/screens/admin_queue.html", {
"request": request,
"v2_active_nav": "admin_queue",
"is_admin": True,
**_v2_template_context(user),
})
@ -2278,7 +2439,7 @@ async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)):
return templates.TemplateResponse("v2/screens/admin_abos.html", {
"request": request,
"v2_active_nav": "admin_abos",
"is_admin": True,
**_v2_template_context(user),
})
@ -2413,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![screenshot]({att_url})"},
)
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():

View File

@ -85,4 +85,6 @@ def build_pdf_href(zitat: dict, bundesland: str = "") -> str:
text = zitat.get("text", "")
q = " ".join(text.split()[:5])
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}"
# #page=N als URL-Hash, damit der Browser-PDF-Viewer direkt zur Seite
# springt — OpenAction im PDF wird von Chrome/Firefox ignoriert.
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}#page={seite}"

View 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

File diff suppressed because one or more lines are too long

View File

@ -66,14 +66,32 @@ body.v2 :focus-visible {
grid-area: topbar;
background: var(--paper);
border-bottom: 1px solid var(--hairline);
padding: 10px 24px;
padding: 0 24px;
height: 32px; /* harte Höhe statt min-height */
display: flex;
align-items: center;
gap: var(--space-4);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1;
color: var(--ecg-dark);
}
.v2-topbar > * {
height: auto;
max-height: 24px; /* nichts darin höher als 24 px */
}
.v2-topbar select,
.v2-topbar button,
.v2-topbar a {
padding: 2px 6px;
margin: 0;
line-height: 1;
height: 22px;
display: inline-flex;
align-items: center;
}
.v2-topbar .v2-icon { width: 12px !important; height: 12px !important; }
.v2-topbar .v2-icon svg { width: 12px; height: 12px; }
.v2-topbar-spacer {
flex: 1;
@ -881,8 +899,11 @@ body.v2 ul.v2-manual ul li::before {
color: #fff;
}
/* ── Menu Toggle Button ─────────────────────────────────────────── */
.v2-menu-toggle {
/* Menu Toggle Button nur auf Mobile sichtbar (< 900 px)
!important nötig wegen .v2-topbar button { display: inline-flex }
(die generische Reset-Regel hat 2 Klassen Specificity vs hier 1). */
.v2-topbar .v2-menu-toggle {
display: none !important;
padding: 6px 10px;
background: none;
border: 1px solid var(--hairline);
@ -891,6 +912,9 @@ body.v2 ul.v2-manual ul li::before {
cursor: pointer;
line-height: 1;
}
@media (max-width: 900px) {
.v2-topbar .v2-menu-toggle { display: inline-flex !important; }
}
/* ── Fraktions-Score-Tabelle (Fix 2+3) ─────────────────────────── */
.v2-fraktions-scores {

View File

@ -8,9 +8,9 @@
<link rel="alternate" type="application/atom+xml" title="GWÖ-Antragsprüfer — Neue Bewertungen" href="/api/feed.xml">
{# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #}
<link rel="stylesheet" href="/static/v2/tokens.css">
<link rel="stylesheet" href="/static/v2/fonts.css">
<link rel="stylesheet" href="/static/v2/v2.css">
<link rel="stylesheet" href="/static/v2/tokens.css?v={{ app_version|default('1') }}">
<link rel="stylesheet" href="/static/v2/fonts.css?v={{ app_version|default('1') }}">
<link rel="stylesheet" href="/static/v2/v2.css?v={{ app_version|default('1') }}">
{% block head_extra %}{% endblock %}
</head>
@ -40,26 +40,29 @@
<span class="v2-nav-count">{{ assessment_count }}</span>
{% endif %}
</a>
<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>
{% if is_authenticated %}<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>{% endif %}
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>
<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>
{% if is_admin %}<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>{% endif %}
{% if is_authenticated %}<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>{% endif %}
</div>
{% if is_authenticated %}
<div class="v2-nav-group">
<div class="v2-nav-label">— Prüfen</div>
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>
<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>
{% if is_admin %}<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>{% endif %}
</div>
<div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div>
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a>
<a href="/v2/abos" class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">{{ icon("envelope-simple", 14) }} Meine Abos</a>
</div>
{% endif %}
{% if is_admin is defined and is_admin %}
{% if is_admin %}
<div class="v2-nav-group">
<div class="v2-nav-label">— Administration</div>
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
@ -77,6 +80,15 @@
<a href="/methodik">{{ icon("info", 13) }} Methodik</a>
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>
{# ── Globaler Bundesland-Selector ─────────────────────────────────── #}
<select id="v2-global-bl"
onchange="v2SetGlobalBl(this.value)"
aria-label="Bundesland wählen"
style="font-family:var(--font-mono);font-size:11px;padding:3px 6px;border:1px solid var(--ecg-light, var(--ecg-border));background:var(--ecg-card-bg);color:var(--ecg-dark);text-transform:uppercase;border-radius:3px;cursor:pointer;">
<option value="ALL">Bundesweit</option>
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }}</option>{% endfor %}
</select>
{# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
<div id="v2-auth-control" style="display:inline-flex;align-items:center;">
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
@ -204,6 +216,27 @@
{% block body_scripts %}{% endblock %}
{# ── Globaler BL-Selector — Persistenz + Event ───────────────────────────── #}
<script>
(function () {
var BL_KEY = 'gwoe.bl';
window.v2SetGlobalBl = function (code) {
try { localStorage.setItem(BL_KEY, code); } catch (_) {}
window.dispatchEvent(new CustomEvent('v2-bl-changed', { detail: { bl: code } }));
};
window.v2GetGlobalBl = function () {
try { return localStorage.getItem(BL_KEY) || 'ALL'; } catch (_) { return 'ALL'; }
};
document.addEventListener('DOMContentLoaded', function () {
var sel = document.getElementById('v2-global-bl');
if (sel) sel.value = window.v2GetGlobalBl();
});
})();
</script>
{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
{% include "v2/components/auth_modal.html" %}
@ -278,5 +311,11 @@
})();
</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>
</html>

View File

@ -49,6 +49,10 @@
style="padding:var(--space-3);background:var(--ecg-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
Anmelden
</button>
<a href="/api/auth/forgot-password" target="_blank" rel="noopener"
style="font-family:var(--font-mono);font-size:0.78rem;color:var(--ecg-blue);text-align:right;text-decoration:none;border-bottom:1px solid rgba(0,157,165,0.35);align-self:flex-end;">
Passwort vergessen?
</a>
</form>
<!-- Register Form -->

View File

@ -0,0 +1,386 @@
{#
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…';
// Modal + Overlay verstecken, damit der Screenshot die Seite ohne
// Feedback-UI zeigt. Nach dem Capture wieder einblenden.
var modal = document.getElementById('v2-feedback-modal');
var overlay = document.getElementById('v2-feedback-overlay');
var fbBtn = document.getElementById('v2-feedback-btn');
var prev = {
modalDisp: modal ? modal.style.display : null,
overlayDisp: overlay ? overlay.style.display : null,
btnDisp: fbBtn ? fbBtn.style.display : null,
};
if (modal) modal.style.display = 'none';
if (overlay) overlay.style.display = 'none';
if (fbBtn) fbBtn.style.display = 'none';
// ein Frame warten, damit die Browser den Reflow rendert
await new Promise(function (r) { requestAnimationFrame(function(){ requestAnimationFrame(r); }); });
try {
var canvas = await window.html2canvas(document.body, {
scale: window.devicePixelRatio || 2, // Hi-DPI: scharfes Bild
useCORS: true,
allowTaint: false,
logging: false,
backgroundColor: getComputedStyle(document.body).backgroundColor || '#fff',
// Sichtbares Viewport, nicht das ganze Dokument
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
x: window.scrollX,
y: window.scrollY,
windowWidth: document.documentElement.clientWidth,
windowHeight: document.documentElement.clientHeight,
});
// Breite begrenzen — bei Hi-DPI Display kann canvas.width 4000+ sein.
// Cap bei 1600 logischen px (à la Retina-friendly), JPEG quality 0.85.
var MAX_W = 1600;
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.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(canvas, 0, 0, sc.width, sc.height);
finalCanvas = sc;
}
var dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85);
fd.append('screenshot', dataUrl);
} catch (err) {
fd.append('screenshot_error', String(err));
} finally {
// UI zurückbringen
if (modal) modal.style.display = prev.modalDisp || '';
if (overlay) overlay.style.display = prev.overlayDisp || '';
if (fbBtn) fbBtn.style.display = prev.btnDisp || '';
}
}
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>

View File

@ -0,0 +1,93 @@
{#
queue_widget.html — Queue-Statusbar mit Hover-Tooltip (#149).
Wird am Ende von base.html eingebunden via {% include %}. Self-contained:
Eigenes <div id="v2-queue-statusbar"> + <div id="v2-queue-tooltip"> +
Polling-Script. Pollt alle 5 s `/api/queue/status` und blendet sich aus,
wenn keine Jobs aktiv/fertig/fehlgeschlagen sind.
Portiert aus classic-UI (#99). Nutzt v2-Tokens statt classic-Variablen.
#}
<div id="v2-queue-statusbar"
style="position:fixed;bottom:1rem;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:default;
transition:all 0.2s;"
onmouseenter="document.getElementById('v2-queue-tooltip').style.display='block'"
onmouseleave="document.getElementById('v2-queue-tooltip').style.display='none'"
aria-label="Analyse-Queue Status">
<span id="v2-queue-status-text"></span>
</div>
<div id="v2-queue-tooltip"
style="display:none;position:fixed;bottom:3.5rem;left:1rem;
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
border-radius:6px;padding:0.8rem 1rem;
font-family:var(--font-sans);font-size:12px;color:var(--ecg-dark);
box-shadow:0 4px 16px rgba(0,0,0,0.15);z-index:101;
max-width:420px;max-height:320px;overflow-y:auto;">
</div>
<script>
(function () {
function poll() {
fetch('/api/queue/status')
.then(function (r) { return r.json(); })
.then(function (qs) {
var allJobs = qs.jobs || [];
var jobs = allJobs.filter(function (j) { return j.status !== 'stale'; });
var processing = jobs.filter(function (j) { return j.status === 'processing'; }).length;
var queued = jobs.filter(function (j) { return j.status === 'queued' || j.status === 'pending'; }).length;
var completed = jobs.filter(function (j) { return j.status === 'completed'; }).length;
var failed = jobs.filter(function (j) { return j.status === 'failed'; }).length;
var bar = document.getElementById('v2-queue-statusbar');
var text = document.getElementById('v2-queue-status-text');
if (!bar || !text) return;
var workers = qs.workers_running != null ? qs.workers_running : '?';
var parts = [];
if (processing > 0) parts.push('⏳ ' + processing + ' in Bearbeitung');
if (queued > 0) parts.push('⏸ ' + queued + ' wartend');
if (completed > 0) parts.push('✓ ' + completed + ' fertig');
if (failed > 0) parts.push('✗ ' + failed + ' fehlgeschlagen');
if (parts.length === 0) {
parts.push('Queue leer · ' + workers + ' Worker bereit');
}
text.textContent = parts.join(' · ');
var tip = document.getElementById('v2-queue-tooltip');
if (!tip) return;
// Tooltip zeigt bevorzugt aktive Jobs, Stale als „letzter Lauf"-Block.
var displayJobs = jobs.length ? jobs : allJobs;
var rows = displayJobs.slice(0, 20).map(function (j) {
var icon = j.status === 'completed' ? '✓'
: j.status === 'processing' ? '⏳'
: j.status === 'failed' ? '✗'
: '⏸';
var dur = j.duration ? (' · ' + j.duration + 's') : '';
var bl = j.bundesland ? (' · ' + j.bundesland) : '';
var ds = j.drucksache || '?';
var dsLink = j.status === 'completed' && j.drucksache
? '<a href="/antrag/' + encodeURIComponent(j.drucksache) + '" style="color:var(--ecg-blue);">' + ds + '</a>'
: ds;
return '<div style="padding:0.25rem 0;border-bottom:1px solid var(--ecg-light);">'
+ '<span style="font-family:var(--font-mono);">' + icon + '</span> '
+ dsLink
+ '<span style="font-family:var(--font-mono);color:var(--ecg-text-muted);font-size:0.85em;">' + bl + dur + '</span>'
+ '</div>';
}).join('');
tip.innerHTML = '<div style="margin-bottom:0.5rem;font-weight:900;font-size:11px;letter-spacing:0.04em;text-transform:uppercase;color:var(--ecg-blue);">Queue · '
+ workers + ' Worker</div>'
+ (rows || '<div style="color:var(--ecg-text-muted);">leer</div>');
})
.catch(function () { /* still */ });
}
// erster Aufruf direkt + danach alle 5 s
poll();
setInterval(poll, 5000);
})();
</script>

View File

@ -27,7 +27,9 @@
{% endif %}
{{ source }}
{% if pdf_href %}
· <a href="{{ pdf_href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
{# Falls pdf_href noch keinen #page=…-Anker hat, aus seite= im Query-String einen anhaengen — Browser-PDF-Viewer ignorieren PDF-OpenAction, der Hash-Anker funktioniert zuverlaessig. #}
{% set _href = pdf_href if '#page=' in pdf_href else (pdf_href ~ ('#page=' ~ (pdf_href.split('seite=')[1].split('&')[0] if 'seite=' in pdf_href else '1'))) %}
· <a href="{{ _href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
{% endif %}
</cite>
</div>

View File

@ -0,0 +1,166 @@
{% extends "v2/base.html" %}
{% block title %}Meine Abos — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "abos" %}
{% block head_extra %}
<style>
.abo-form {
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
margin-bottom: 24px; max-width: 760px;
padding: 12px 14px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-bg-subtle);
}
.abo-form label {
display: block; font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7;
margin-bottom: 4px;
}
.abo-form select, .abo-form input[type="text"] {
font-family: var(--font-mono); font-size: 13px;
padding: 6px 8px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
}
.abo-form .submit {
font-family: var(--font-display); font-size: 12px; font-weight: 700;
padding: 7px 14px; background: var(--ecg-teal); color: #fff;
border: none; border-radius: 4px; cursor: pointer; letter-spacing: 0.04em;
}
.abo-row {
display: flex; align-items: center; gap: 14px;
padding: 10px 0; border-bottom: 1px solid var(--ecg-border);
font-size: 13px;
}
.abo-row:last-child { border-bottom: none; }
.abo-tag {
font-family: var(--font-mono); font-size: 11px;
padding: 3px 7px; border: 1px solid var(--ecg-border); border-radius: 3px;
background: var(--ecg-card-bg);
}
.abo-del {
font-family: var(--font-mono); font-size: 11px;
padding: 4px 10px; background: none; border: 1px solid var(--ecg-border);
border-radius: 3px; cursor: pointer; color: var(--redline-contra, #c00);
}
.abo-del:hover { background: var(--redline-contra-bg, #f9e6e6); }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Meine E-Mail-Abos</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Tägliche Zusammenfassung neuer Bewertungen — gefiltert nach Bundesland und/oder Partei.
</p>
</div>
<form class="abo-form" onsubmit="aboCreate(event)">
<div>
<label for="abo-bl">Bundesland</label>
<select id="abo-bl">
<option value="">— alle —</option>
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
</select>
</div>
<div>
<label for="abo-partei">Partei</label>
<select id="abo-partei">
<option value="">— alle —</option>
{% for p in parteien %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
</select>
</div>
<div>
<label for="abo-freq">Frequenz</label>
<select id="abo-freq">
<option value="daily">täglich</option>
<option value="weekly">wöchentlich</option>
</select>
</div>
<button type="submit" class="submit">Abo anlegen</button>
</form>
<div id="abo-status" style="margin-bottom:8px;font-family:var(--font-mono);font-size:12px;opacity:0.7;"></div>
<div id="abo-list">Lade …</div>
{% endblock %}
{% block body_scripts %}
<script>
async function aboLoad() {
var listEl = document.getElementById('abo-list');
try {
var r = await fetch('/api/subscriptions');
if (r.status === 401) {
listEl.innerHTML = '<p style="color:var(--ecg-dark);opacity:0.7;">Bitte erst anmelden.</p>';
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
return;
}
var subs = await r.json();
if (!subs || !subs.length) {
listEl.innerHTML = '<p style="opacity:0.6;font-style:italic;">Du hast noch keine Abos. Lege oben eines an.</p>';
return;
}
listEl.innerHTML = subs.map(function(s) {
var bl = s.bundesland || '—';
var p = s.partei || '—';
var f = s.frequency || 'daily';
var ls = s.last_sent ? ('zuletzt: ' + s.last_sent.substring(0,10)) : 'noch nie versandt';
return '<div class="abo-row">'
+ '<span class="abo-tag">BL ' + escHtml(bl) + '</span>'
+ '<span class="abo-tag">Partei ' + escHtml(p) + '</span>'
+ '<span class="abo-tag">' + escHtml(f) + '</span>'
+ '<span style="flex:1;opacity:0.6;font-family:var(--font-mono);font-size:11px;">' + escHtml(ls) + '</span>'
+ '<button class="abo-del" onclick="aboDelete(' + s.id + ')">✕ Löschen</button>'
+ '</div>';
}).join('');
} catch (e) {
listEl.innerHTML = '<p style="color:#c00;">Fehler: ' + escHtml(e.message) + '</p>';
}
}
async function aboCreate(e) {
e.preventDefault();
var bl = document.getElementById('abo-bl').value;
var part = document.getElementById('abo-partei').value.trim();
var freq = document.getElementById('abo-freq').value;
var fd = new FormData();
if (bl) fd.append('bundesland', bl);
if (part) fd.append('partei', part);
fd.append('frequency', freq);
var r = await fetch('/api/subscriptions', { method: 'POST', body: fd });
if (r.status === 401) {
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
return;
}
if (!r.ok) {
var err = await r.json().catch(()=>({detail:'Fehler'}));
setStatus('Fehler: ' + (err.detail || r.status), true);
return;
}
setStatus('Abo angelegt.');
document.getElementById('abo-partei').value = '';
aboLoad();
}
async function aboDelete(id) {
if (!confirm('Abo wirklich löschen?')) return;
var r = await fetch('/api/subscriptions/' + id, { method: 'DELETE' });
if (r.ok) { setStatus('Abo gelöscht.'); aboLoad(); }
else { setStatus('Löschen fehlgeschlagen.', true); }
}
function setStatus(msg, isErr) {
var el = document.getElementById('abo-status');
el.textContent = msg;
el.style.color = isErr ? '#c00' : 'var(--ecg-teal)';
setTimeout(function(){ el.textContent=''; }, 4000);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', aboLoad);
</script>
{% endblock %}

View File

@ -342,22 +342,38 @@
</div>
</div>
{# ── Share-Block ──────────────────────────────────────────────── #}
{# ── Share-Block (analog v1) ───────────────────────────────────── #}
<div style="margin-top:20px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button onclick="v2DetailShareCopy()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📋 Kopieren
</button>
<button onclick="v2DetailShare('threads')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Threads
</button>
<button onclick="v2DetailShare('twitter')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
X
𝕏
</button>
<button onclick="v2DetailShareMastodon()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Mastodon
</button>
<button onclick="v2DetailShare('linkedin')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
LinkedIn
</button>
<button onclick="v2DetailShareEmail()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📧 E-Mail
</button>
<button onclick="v2DetailShareImage()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
🖼 Bild
</button>
</div>
</div>
@ -490,6 +506,7 @@ window.v2ShowMatrixFieldInfo = function(field) {
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
var TITLE = {{ antrag.title | tojson }};
var SCORE = {{ antrag.score | tojson }};
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
var currentUser = null;
@ -633,11 +650,41 @@ window.v2ShowMatrixFieldInfo = function(field) {
var text = buildShareText(platform) + '\n' + PERMALINK;
var urls = {
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text),
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text)
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text),
linkedin: 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(PERMALINK)
};
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
};
window.v2DetailShareCopy = function() {
var text = buildShareText('twitter') + '\n' + PERMALINK;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
// kleiner visueller Hinweis: Button-Text temporär
var btn = event && event.currentTarget;
if (btn) {
var orig = btn.textContent;
btn.textContent = '✓ kopiert';
setTimeout(function(){ btn.textContent = orig; }, 1500);
}
});
} else {
prompt('Zum Kopieren markieren und Cmd/Strg-C drücken:', text);
}
};
window.v2DetailShareEmail = function() {
var subject = 'GWÖ-Bewertung: ' + (TITLE.substring(0, 60));
var body = (SHARE_THR || buildShareText('threads')) + '\n\n' + PERMALINK;
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
};
window.v2DetailShareImage = function() {
var topics = (window.ANTRAG_TOPICS || []).slice(0, 2).join(' ');
var query = (topics || TITLE.substring(0, 40)) + ' Politik';
window.open('https://www.freepik.com/search?format=search&query=' + encodeURIComponent(query), '_blank', 'noopener');
};
window.v2DetailShareMastodon = function() {
var text = buildShareText('mastodon') + '\n' + PERMALINK;
var instance = localStorage.getItem('mastodon_instance');

View File

@ -181,13 +181,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<option value="{{ wp }}">{{ wp }}</option>
{% endfor %}
</select>
<label for="bl-filter">Bundesland:</label>
<select id="bl-filter">
<option value="">Alle</option>
{% for code in bl_codes %}
<option value="{{ code }}">{{ code }}</option>
{% endfor %}
</select>
<button class="primary" onclick="loadBlMatrix()">Laden</button>
<button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button>
</div>
@ -200,14 +193,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
<!-- Panel 2: Thema × Fraktion -->
<div class="auswert-panel" id="panel-themen">
<div class="controls-bar">
<label for="themen-bl-filter">Bundesland:</label>
<select id="themen-bl-filter" onchange="loadThemenMatrix()">
<option value="">Alle</option>
{% for code in bl_codes %}
<option value="{{ code }}">{{ code }}</option>
{% endfor %}
</select>
</div>
<div id="themen-matrix-wrap" class="matrix-wrap">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div>
</div>
@ -258,6 +243,16 @@ function switchTab(id, btn) {
}
}
// Bei BL-Wechsel aktive Panels neu laden
window.addEventListener('v2-bl-changed', function () {
var activePanel = document.querySelector('.auswert-panel.active');
if (!activePanel) return;
if (activePanel.id === 'panel-bl-partei') loadBlMatrix();
if (activePanel.id === 'panel-themen') loadThemenMatrix();
});
document.addEventListener('DOMContentLoaded', function () { loadBlMatrix(); });
function scoreClass(avg) {
if (avg == null) return '';
if (avg >= 6) return 's-high';
@ -269,7 +264,8 @@ async function loadBlMatrix() {
const wrap = document.getElementById('bl-matrix-wrap');
const metaEl = document.getElementById('bl-matrix-meta');
const wp = document.getElementById('wp-filter').value;
const bl = document.getElementById('bl-filter').value;
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>';
metaEl.textContent = '';
@ -317,7 +313,8 @@ async function loadBlMatrix() {
async function loadThemenMatrix() {
const wrap = document.getElementById('themen-matrix-wrap');
const bl = document.getElementById('themen-bl-filter').value;
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const bl = (blRaw === 'ALL') ? '' : blRaw;
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>';
let url = '/api/auswertungen/themen-matrix';

View File

@ -9,13 +9,9 @@
{% block main %}
{# ── Toolbar: Bundesland-Filter + Suche ─────────────────────────── #}
{# ── Toolbar: Suche ──────────────────────────────────────────────── #}
{# BL-Filter läuft jetzt über den globalen Selector in der Topbar. #}
<div class="v2-toolbar" id="v2-toolbar" role="toolbar" aria-label="Filter und Suche">
<button class="v2-chip active" data-bl="ALL" onclick="v2SetBl(this,'ALL')">Bundesweit</button>
{% for code in bl_codes %}
<button class="v2-chip" data-bl="{{ code }}" onclick="v2SetBl(this,'{{ code }}')">{{ code }}</button>
{% endfor %}
<span class="v2-toolbar-sep"></span>
<input class="v2-search"
type="search"
placeholder="Anträge durchsuchen …"
@ -122,13 +118,11 @@
if (empty) empty.style.display = (visible === 0) ? 'block' : 'none';
}
window.v2SetBl = function (btn, code) {
activeBl = code;
document.querySelectorAll('[data-bl]').forEach(function (b) {
b.classList.toggle('active', b.dataset.bl === code);
});
/* BL-Filter: globaler Selector in der Topbar */
window.addEventListener('v2-bl-changed', function (e) {
activeBl = (e.detail && e.detail.bl) ? e.detail.bl : 'ALL';
applyFilters();
};
});
window.v2SetBand = function (btn, band) {
activeBand = band;
@ -140,13 +134,19 @@
window.v2ResetFilters = function () {
document.getElementById('v2-search-input').value = '';
v2SetBl(null, 'ALL');
// BL auf ALL zurücksetzen: globalen Selector aktualisieren
var sel = document.getElementById('v2-global-bl');
if (sel) sel.value = 'ALL';
window.v2SetGlobalBl && window.v2SetGlobalBl('ALL');
v2SetBand(null, 'ALL');
};
document.addEventListener('DOMContentLoaded', function () {
var input = document.getElementById('v2-search-input');
if (input) input.addEventListener('input', applyFilters);
// Gespeicherten BL-Wert beim Laden anwenden
activeBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
applyFilters();
});
})();

View File

@ -0,0 +1,125 @@
{% extends "v2/base.html" %}
{% block title %}Atom-Feed — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "feed" %}
{% block head_extra %}
<style>
.feed-form {
display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px;
align-items: center; margin-bottom: 24px; max-width: 560px;
padding: 14px; border: 1px solid var(--ecg-border); border-radius: 4px;
background: var(--ecg-bg-subtle);
}
.feed-form label { font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7; }
.feed-form select, .feed-form input {
font-family: var(--font-mono); font-size: 13px;
padding: 6px 8px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
}
.feed-url-box {
margin-top: 16px; padding: 14px; border: 1px solid var(--ecg-border);
border-radius: 4px; background: var(--ecg-card-bg);
}
.feed-url {
font-family: var(--font-mono); font-size: 12px; padding: 8px 10px;
border: 1px solid var(--ecg-border); border-radius: 3px; word-break: break-all;
background: var(--paper); color: var(--ecg-dark); display: block;
margin: 8px 0;
}
.feed-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.feed-btn {
font-family: var(--font-mono); font-size: 12px; padding: 6px 14px;
background: var(--ecg-teal); color: #fff; border: none; border-radius: 3px;
cursor: pointer; text-decoration: none; display: inline-flex;
align-items: center; gap: 6px;
}
.feed-btn.secondary { background: none; color: var(--ecg-dark); border: 1px solid var(--ecg-border); }
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Atom-Feed</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Konfigurierbarer Feed der neuesten Bewertungen — abonnierbar mit jedem RSS/Atom-Reader.
</p>
</div>
<form class="feed-form" onsubmit="event.preventDefault();feedUpdate();">
<label for="feed-bl">Bundesland</label>
<select id="feed-bl" onchange="feedUpdate()">
<option value="">— alle —</option>
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
</select>
<label for="feed-partei">Partei</label>
<select id="feed-partei" onchange="feedUpdate()">
<option value="">— alle —</option>
{% for p in parteien %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
</select>
<label for="feed-limit">Anzahl</label>
<input type="number" id="feed-limit" min="1" max="200" value="50" oninput="feedUpdate()">
</form>
<div class="feed-url-box">
<div style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.07em;opacity:0.7;">Feed-URL</div>
<code class="feed-url" id="feed-url">/api/feed.xml</code>
<div class="feed-actions">
<a id="feed-open" href="/api/feed.xml" class="feed-btn" target="_blank" rel="noopener">📰 Öffnen</a>
<button class="feed-btn secondary" onclick="feedCopy()">📋 URL kopieren</button>
<a id="feed-reader" href="" class="feed-btn secondary" target="_blank" rel="noopener" title="In Feedly öffnen">In Feedly</a>
</div>
</div>
<div style="margin-top:24px;font-size:12px;color:var(--ecg-dark);opacity:0.7;line-height:1.6;max-width:600px;">
<p><strong>Hinweis:</strong> Du kannst die Feed-URL in jedem RSS-Reader (z.B. Feedly, NewsBlur, Inoreader, NetNewsWire, Thunderbird) abonnieren. Der Feed ist Atom 1.0 und liefert die letzten Bewertungen mit Score, Empfehlung und Kurzbegründung.</p>
<p>Wenn du regelmäßige Mails statt Pull-Feed willst, lege ein <a href="/v2/abos" style="color:var(--ecg-teal);">E-Mail-Abo</a> an.</p>
</div>
{% endblock %}
{% block body_scripts %}
<script>
function feedUpdate() {
var bl = document.getElementById('feed-bl').value;
var part = document.getElementById('feed-partei').value.trim();
var limit = document.getElementById('feed-limit').value;
var qs = [];
if (bl) qs.push('bundesland=' + encodeURIComponent(bl));
if (part) qs.push('partei=' + encodeURIComponent(part));
if (limit && limit !== '50') qs.push('limit=' + encodeURIComponent(limit));
var path = '/api/feed.xml' + (qs.length ? ('?' + qs.join('&')) : '');
var full = location.origin + path;
document.getElementById('feed-url').textContent = full;
document.getElementById('feed-open').href = path;
document.getElementById('feed-reader').href = 'https://feedly.com/i/subscription/feed%2F' + encodeURIComponent(full);
}
async function feedCopy() {
var url = document.getElementById('feed-url').textContent;
if (navigator.clipboard) await navigator.clipboard.writeText(url);
else { prompt('Kopieren:', url); }
}
// Bundesland aus globaler Auswahl als Default übernehmen
document.addEventListener('DOMContentLoaded', function() {
var globalBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
if (globalBl && globalBl !== 'ALL') {
var sel = document.getElementById('feed-bl');
if (sel) sel.value = globalBl;
}
feedUpdate();
});
window.addEventListener('v2-bl-changed', function(e) {
var sel = document.getElementById('feed-bl');
if (sel) {
sel.value = (e.detail && e.detail.bl !== 'ALL') ? e.detail.bl : '';
feedUpdate();
}
});
</script>
{% endblock %}

View File

@ -147,6 +147,9 @@
</p>
</div>
<div id="ls-bl-hint" style="display:none;margin-bottom:12px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
Bitte zuerst ein Bundesland im Header wählen.
</div>
<form class="ls-form" onsubmit="lsSearch(event)">
<div class="ls-q">
<label for="ls-q-input">Suchbegriff</label>
@ -158,14 +161,6 @@
required
onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
</div>
<div class="ls-bl">
<label for="ls-bl-select">Bundesland</label>
<select id="ls-bl-select" name="bundesland">
{% for bl in bundeslaender %}
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
{% endfor %}
</select>
</div>
<button type="submit" class="ls-submit" id="ls-btn">
{{ icon("magnifying-glass-plus", 14) }} Suchen
</button>
@ -202,7 +197,14 @@ async function lsSearch(e) {
if (e && e.preventDefault) e.preventDefault();
var q = (document.getElementById('ls-q-input').value || '').trim();
var bl = document.getElementById('ls-bl-select').value;
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
if (bl === 'ALL') {
document.getElementById('ls-bl-hint').style.display = '';
document.getElementById('ls-status').textContent = '';
return;
}
document.getElementById('ls-bl-hint').style.display = 'none';
if (q.length < 2) {
document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.';
@ -292,9 +294,50 @@ async function lsAnalyse(btn, drucksache, bundesland) {
}
var data = await resp.json();
var ds = data.drucksache || drucksache;
// Backend gibt {job_id, drucksache} zurück (Queue) — nicht direkt redirecten,
// sondern auf /antrag/{ds} gehen, dort wird dann ggf. der Polling-Status sichtbar
// Falls bereits bewertet oder skipped: direkt redirecten
if (data.status === 'already_checked') {
window.location.href = '/antrag/' + encodeURIComponent(ds);
return;
}
if (data.status === 'skipped') {
btn.textContent = 'Nicht abstimmbar';
btn.title = (data.reason || ('Typ „' + (data.typ || 'unbekannt') + '" ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll'));
btn.style.opacity = '0.55';
btn.style.cursor = 'not-allowed';
// Begründung sichtbar in der Zeile anzeigen
var row = btn.closest('.ls-row');
if (row) {
var hint = document.createElement('div');
hint.style.cssText = 'flex-basis:100%;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.7;margin-top:4px;font-style:italic;';
hint.textContent = data.reason || ('Typ „' + (data.typ || 'unbekannt') + '" — keine Abstimmung, keine GWÖ-Bewertung');
row.appendChild(hint);
}
return;
}
// Sonst Job-Polling bis fertig, dann redirect
btn.textContent = 'Analysiere…';
var jobId = data.job_id;
if (!jobId) { window.location.href = '/antrag/' + encodeURIComponent(ds); return; }
var attempts = 0;
var maxAttempts = 90; // 90 × 2s = 3 min
while (attempts < maxAttempts) {
await new Promise(function (r) { setTimeout(r, 2000); });
attempts++;
var st = await fetch('/status/' + jobId).then(function (r) { return r.json(); }).catch(function () { return null; });
if (!st) continue;
if (st.status === 'completed') {
window.location.href = '/antrag/' + encodeURIComponent(ds);
return;
}
if (st.status === 'failed' || st.status === 'rejected') {
throw new Error('Analyse fehlgeschlagen: ' + (st.error || 'unbekannt'));
}
btn.textContent = 'Analysiere… (' + (st.status || '...') + ')';
}
throw new Error('Analyse-Timeout (>3 min)');
} catch (err) {
btn.disabled = false;
btn.textContent = 'Fehler';
@ -313,5 +356,15 @@ function escHtml(s) {
function escAttr(s) {
return String(s).replace(/'/g, "\\'");
}
document.addEventListener('DOMContentLoaded', function () {
var hint = document.getElementById('ls-bl-hint');
function updateHint() {
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none';
}
updateHint();
window.addEventListener('v2-bl-changed', updateHint);
});
</script>
{% endblock %}

View File

@ -92,18 +92,15 @@
<form class="neu-form" onsubmit="startAnalyse(event)">
<div id="neu-bl-hint" style="display:none;margin-bottom:14px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
Bitte zuerst ein Bundesland im Header wählen.
</div>
<label for="neu-drucksache">Drucksachen-Nummer</label>
<input type="text" id="neu-drucksache" name="drucksache"
placeholder="z. B. 18/12345 oder NRW-18/12345"
required autocomplete="off">
<label for="neu-bl">Bundesland</label>
<select id="neu-bl" name="bundesland">
{% for bl in bundeslaender %}
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
{% endfor %}
</select>
<label for="neu-model">Modell</label>
<select id="neu-model" name="model">
<option value="">Standard ({{ default_model }})</option>
@ -136,11 +133,17 @@ async function startAnalyse(e) {
const errEl = document.getElementById('neu-error');
const drucksache = document.getElementById('neu-drucksache').value.trim();
const bundesland = document.getElementById('neu-bl').value;
const bundesland = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
const model = document.getElementById('neu-model').value;
if (!drucksache) return;
if (bundesland === 'ALL') {
errEl.style.display = '';
errEl.textContent = 'Bitte zuerst ein Bundesland im Header wählen.';
return;
}
btn.disabled = true;
statusEl.style.display = '';
progEl.style.display = '';
@ -154,16 +157,56 @@ async function startAnalyse(e) {
if (model) fd.append('model', model);
const resp = await fetch('/api/analyze-drucksache', { method: 'POST', body: fd });
if (resp.status === 401 || resp.status === 403) {
progEl.style.display = 'none';
errEl.style.display = '';
errEl.textContent = 'Sitzung abgelaufen — bitte erneut anmelden.';
btn.disabled = false;
if (typeof window.v2AuthModalOpen === 'function') window.v2AuthModalOpen();
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || ('HTTP ' + resp.status));
}
const data = await resp.json();
// Redirect to result page
const ds = data.drucksache || drucksache;
progText.textContent = 'Analyse abgeschlossen. Weiterleitung …';
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 600);
if (data.status === 'already_checked') {
progText.textContent = 'Bereits bewertet. Weiterleitung …';
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 400);
return;
}
if (data.status === 'skipped') {
progEl.style.display = 'none';
errEl.style.display = '';
errEl.textContent = 'Antrag-Typ "' + (data.typ || 'unbekannt') + '" ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll.';
btn.disabled = false;
return;
}
// Job-Polling bis Abschluss, dann redirect
const jobId = data.job_id;
if (!jobId) { window.location.href = '/antrag/' + encodeURIComponent(ds); return; }
let attempts = 0;
const maxAttempts = 90;
while (attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 2000));
attempts++;
const st = await fetch('/status/' + jobId).then(r => r.json()).catch(() => null);
if (!st) continue;
progText.textContent = 'Analyse läuft … (' + (st.status || '?') + ', ~' + (attempts * 2) + 's)';
if (st.status === 'completed') {
progText.textContent = 'Fertig. Weiterleitung …';
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 400);
return;
}
if (st.status === 'failed' || st.status === 'rejected') {
throw new Error('Analyse fehlgeschlagen: ' + (st.error || 'unbekannt'));
}
}
throw new Error('Analyse-Timeout (>3 min)');
} catch (err) {
progEl.style.display = 'none';
@ -172,5 +215,15 @@ async function startAnalyse(e) {
btn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', function () {
var hint = document.getElementById('neu-bl-hint');
function updateHint() {
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none';
}
updateHint();
window.addEventListener('v2-bl-changed', updateHint);
});
</script>
{% endblock %}

View File

@ -23,6 +23,11 @@ services:
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-GWÖ-Antragsprüfer}
- UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET}
- BASE_URL=${BASE_URL:-https://gwoe.toppyr.de}
# Gitea-Anbindung fuer Feedback-Widget (#149-Folge)
- GITEA_TOKEN=${GITEA_TOKEN}
- GITEA_API_URL=${GITEA_API_URL:-https://repo.toppyr.de/api/v1}
- GITEA_REPO_OWNER=${GITEA_REPO_OWNER:-tobias}
- GITEA_REPO_NAME=${GITEA_REPO_NAME:-gwoe-antragspruefer}
volumes:
- ./data:/app/data
- ./reports:/app/reports

View File

@ -42,23 +42,26 @@ echo " $(date -Iseconds)"
echo "================================================================"
echo
echo "[1] Hauptseiten erreichbar (alle 200)"
echo "[1] Public-Seiten (200 ohne Auth)"
check "v2 Default /" "200" "/"
check "v2 Detail (echte DS)" "200" "/antrag/21/754S"
check "Classic /classic" "200" "/classic"
check "/auswertungen" "200" "/auswertungen"
check "/methodik" "200" "/methodik"
check "/quellen" "200" "/quellen"
check "/impressum" "200" "/impressum"
check "/datenschutz" "200" "/datenschutz"
check "/v2/merkliste" "200" "/v2/merkliste"
check "/v2/tags" "200" "/v2/tags"
check "/v2/cluster" "200" "/v2/cluster"
check "/v2/landtag-suche" "200" "/v2/landtag-suche"
check "/v2/neu" "200" "/v2/neu"
check "/v2/batch" "200" "/v2/batch"
check "/health" "200" "/health"
echo
echo "[1b] Auth-Routen (302/401 ohne Auth — Redirect zu Login)"
check "/auswertungen (auth)" "401" "/auswertungen"
check "/v2/merkliste (auth)" "401" "/v2/merkliste"
check "/v2/landtag-suche (auth)" "401" "/v2/landtag-suche"
check "/v2/neu (auth)" "401" "/v2/neu"
check "/v2/cluster (admin)" "401" "/v2/cluster"
check "/v2/batch (admin)" "401" "/v2/batch"
echo
echo "[2] API-Endpoints (öffentlich)"
check "/api/assessments" "200" "/api/assessments"

View 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