Compare commits

..

No commits in common. "4b03448e29a56b66729dbde2f9d5e42b4e607227" and "c38bca615d4bfc04c88301051c166f07d073da23" have entirely different histories.

23 changed files with 111 additions and 1722 deletions

View File

@ -4,7 +4,7 @@ from pathlib import Path
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "GWÖ-Antragsprüfer" app_name: str = "GWÖ-Antragsprüfer"
app_version: str = "1.0.2" app_version: str = "1.0.0"
prompt_version: str = "v4.1" prompt_version: str = "v4.1"
# Paths # Paths
@ -54,14 +54,6 @@ class Settings(BaseSettings):
# Token für Unsubscribe-Links (HMAC-Secret) # Token für Unsubscribe-Links (HMAC-Secret)
unsubscribe_secret: str = "change-me-in-prod" 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"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

View File

@ -86,45 +86,3 @@ def ist_abstimmbar(typ_normiert: str) -> bool:
def ist_abstimmbar_original(original: str) -> bool: def ist_abstimmbar_original(original: str) -> bool:
"""Convenience: prüft direkt am Original-Typ-String.""" """Convenience: prüft direkt am Original-Typ-String."""
return ist_abstimmbar(normalize_typ(original)) 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 # die URL bleibt bounded (sonst würden 500-Zeichen-Snippets in jeder
# Zitat-URL stehen und das HTML-Report-JSON aufblähen). # Zitat-URL stehen und das HTML-Report-JSON aufblähen).
q = urllib.parse.quote_plus(text[:200]) q = urllib.parse.quote_plus(text[:200])
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}#page={seite}" return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}"
if seite: if seite:
return f"/static/referenzen/{pdf}#page={seite}" return f"/static/referenzen/{pdf}#page={seite}"
@ -777,14 +777,9 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
rects = [] rects = []
if needle: if needle:
clean = needle.replace("\u00ad", "") 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() words = clean.split()
anchor = " ".join(words[:5]) if len(words) >= 5 else clean anchor = " ".join(words[:5]) if len(words) >= 5 else clean
# Versuch 1: angegebene Seite, Volltext (gestrippt) # Versuch 1: angegebene Seite, Volltext
rects = src[target_page_idx].search_for(clean) rects = src[target_page_idx].search_for(clean)
# Versuch 2: angegebene Seite, 5-Wort-Anker # Versuch 2: angegebene Seite, 5-Wort-Anker
if not rects: if not rects:
@ -797,7 +792,8 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
target_page_idx = i target_page_idx = i
break break
# Volles PDF mit Highlight-Annotation. # Volles PDF mit Highlight-Annotation. Der Browser öffnet das
# vollständige Wahlprogramm; das Frontend hängt #page=N an die URL.
page = src[target_page_idx] page = src[target_page_idx]
if needle and rects: if needle and rects:
for rect in rects: for rect in rects:
@ -806,16 +802,6 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb
annot.update() 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) highlighted = bool(needle and rects)
try: try:
return src.tobytes(), target_page_idx + 1, highlighted return src.tobytes(), target_page_idx + 1, highlighted

View File

@ -91,25 +91,6 @@ app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 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 # Security Headers Middleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
@ -146,28 +127,6 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=str(templates_dir)) 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") @app.on_event("startup")
async def startup(): async def startup():
import asyncio import asyncio
@ -250,7 +209,7 @@ async def classic_index(request: Request):
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ──────────────────────────── # ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def index(request: Request):
"""Startseite — rendert v2-Listenansicht (Default-Flip Phase 2). """Startseite — rendert v2-Listenansicht (Default-Flip Phase 2).
Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf
@ -271,12 +230,11 @@ async def index(request: Request, current_user: Optional[dict] = Depends(get_cur
"assessments": assessments, "assessments": assessments,
"bl_codes": bl_codes, "bl_codes": bl_codes,
"assessment_count": len(assessments), "assessment_count": len(assessments),
**_v2_template_context(current_user),
}) })
@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse) @app.get("/antrag/{drucksache:path}", response_class=HTMLResponse)
async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)): async def antrag_detail(request: Request, drucksache: str):
"""v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten.""" """v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten."""
try: try:
drucksache = validate_drucksache(drucksache) drucksache = validate_drucksache(drucksache)
@ -285,7 +243,6 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"error": f"Ungültige Drucksachen-ID: {drucksache}", "error": f"Ungültige Drucksachen-ID: {drucksache}",
**_v2_template_context(current_user),
}, status_code=400) }, status_code=400)
row = await get_assessment(drucksache) row = await get_assessment(drucksache)
@ -294,7 +251,6 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"error": f"Antrag {drucksache} wurde nicht gefunden.", "error": f"Antrag {drucksache} wurde nicht gefunden.",
**_v2_template_context(current_user),
}, status_code=404) }, status_code=404)
antrag = _row_to_detail(row) antrag = _row_to_detail(row)
@ -338,34 +294,9 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
"E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?", "E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
}, },
"matrix_labels": MATRIX_LABELS, "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): def _rows_to_list(rows):
"""Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht.""" """Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht."""
result = [] result = []
@ -717,25 +648,6 @@ async def auth_login_url(request: Request, redirect: str = "/"):
return {"enabled": True, "url": url} 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") @app.post("/api/auth/login")
async def auth_direct_login( async def auth_direct_login(
username: str = Form(...), username: str = Form(...),
@ -1103,37 +1015,7 @@ async def auth_register(
if create_resp.status_code != 201: if create_resp.status_code != 201:
raise HTTPException(status_code=500, detail="Registrierung fehlgeschlagen") raise HTTPException(status_code=500, detail="Registrierung fehlgeschlagen")
# #143: Bestätigungsmail an User direkt nach Anmeldung return {"status": "pending_approval", "message": "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen."}
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") @app.get("/api/auth/pending-users")
@ -1383,13 +1265,8 @@ async def search_landtag(
try: try:
external = adapter._filter_abstimmbar(await adapter.search(q, limit)) 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 = [] results = []
for doc in external: for doc in external:
if doc.typ_normiert == "sonstige" and likely_kleine_anfrage_titel(doc.title):
continue # höchstwahrscheinlich Kleine Anfrage
results.append({ results.append({
"drucksache": doc.drucksache, "drucksache": doc.drucksache,
"title": doc.title, "title": doc.title,
@ -1711,27 +1588,25 @@ async def list_bundeslaender():
# === Impressum / Datenschutz === # === Impressum / Datenschutz ===
@app.get("/impressum", response_class=HTMLResponse) @app.get("/impressum", response_class=HTMLResponse)
async def impressum_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def impressum_page(request: Request):
return templates.TemplateResponse("v2/screens/legal.html", { return templates.TemplateResponse("v2/screens/legal.html", {
"request": request, "app_name": settings.app_name, "request": request, "app_name": settings.app_name,
"title": "Impressum", "section": "impressum", "title": "Impressum", "section": "impressum",
**_v2_template_context(current_user),
}) })
@app.get("/datenschutz", response_class=HTMLResponse) @app.get("/datenschutz", response_class=HTMLResponse)
async def datenschutz_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def datenschutz_page(request: Request):
return templates.TemplateResponse("v2/screens/legal.html", { return templates.TemplateResponse("v2/screens/legal.html", {
"request": request, "app_name": settings.app_name, "request": request, "app_name": settings.app_name,
"title": "Datenschutzerklärung", "section": "datenschutz", "title": "Datenschutzerklärung", "section": "datenschutz",
**_v2_template_context(current_user),
}) })
# === Quellen / Programme === # === Quellen / Programme ===
@app.get("/methodik", response_class=HTMLResponse) @app.get("/methodik", response_class=HTMLResponse)
async def methodik_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def methodik_page(request: Request):
"""Transparenz-/Methodik-Seite (#96).""" """Transparenz-/Methodik-Seite (#96)."""
from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER
from .embeddings import get_indexing_status from .embeddings import get_indexing_status
@ -1755,12 +1630,11 @@ async def methodik_page(request: Request, current_user: Optional[dict] = Depends
"programme_count": status.get("total", 0), "programme_count": status.get("total", 0),
"chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])), "chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])),
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
}) })
@app.get("/quellen", response_class=HTMLResponse) @app.get("/quellen", response_class=HTMLResponse)
async def quellen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def quellen_page(request: Request):
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert.""" """Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert."""
from .bundeslaender import BUNDESLAENDER from .bundeslaender import BUNDESLAENDER
programmes = get_programme_info() programmes = get_programme_info()
@ -1787,7 +1661,6 @@ async def quellen_page(request: Request, current_user: Optional[dict] = Depends(
"wahlprogramme_grouped": wahlprogramme_grouped, "wahlprogramme_grouped": wahlprogramme_grouped,
"grundsatzprogramme": grundsatz, "grundsatzprogramme": grundsatz,
"status": status, "status": status,
**_v2_template_context(current_user),
}) })
@ -1977,8 +1850,8 @@ async def index_programme(
@app.get("/auswertungen", response_class=HTMLResponse) @app.get("/auswertungen", response_class=HTMLResponse)
async def auswertungen_page(request: Request, current_user: dict = Depends(require_auth)): async def auswertungen_page(request: Request):
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic). Auth-only.""" """Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic)."""
from .auswertungen import get_wahlperioden from .auswertungen import get_wahlperioden
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
@ -1991,7 +1864,6 @@ async def auswertungen_page(request: Request, current_user: dict = Depends(requi
"v2_active_nav": "auswertungen", "v2_active_nav": "auswertungen",
"wahlperioden": wahlperioden, "wahlperioden": wahlperioden,
"bl_codes": bl_codes, "bl_codes": bl_codes,
**_v2_template_context(current_user),
}) })
@ -2299,55 +2171,26 @@ async def v2_antrag_redirect(request: Request, drucksache: str):
@app.get("/v2/merkliste", response_class=HTMLResponse) @app.get("/v2/merkliste", response_class=HTMLResponse)
async def v2_merkliste(request: Request, current_user: dict = Depends(require_auth)): async def v2_merkliste(request: Request):
"""Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig.""" """Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig."""
return templates.TemplateResponse("v2/screens/merkliste.html", { return templates.TemplateResponse("v2/screens/merkliste.html", {
"request": request, "request": request,
"v2_active_nav": "merkliste", "v2_active_nav": "merkliste",
**_v2_template_context(current_user),
}) })
@app.get("/v2/tags", response_class=HTMLResponse) @app.get("/v2/tags", response_class=HTMLResponse)
async def v2_tags(request: Request, current_user: Optional[dict] = Depends(get_current_user)): async def v2_tags(request: Request):
"""Tag-Cloud-Seite — Themen-Filter über alle Assessments.""" """Tag-Cloud-Seite — Themen-Filter über alle Assessments."""
return templates.TemplateResponse("v2/screens/tags.html", { return templates.TemplateResponse("v2/screens/tags.html", {
"request": request, "request": request,
"v2_active_nav": "tags", "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) @app.get("/v2/cluster", response_class=HTMLResponse)
async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)): async def v2_cluster(request: Request):
"""Cluster-Liste — nur für Admins.""" """Cluster-Liste — Top-10 Cluster als redaktionelle Liste."""
rows = await get_all_assessments(None) rows = await get_all_assessments(None)
assessments = _rows_to_list(rows) assessments = _rows_to_list(rows)
bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")}) bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")})
@ -2355,13 +2198,12 @@ async def v2_cluster(request: Request, current_user: dict = Depends(require_admi
"request": request, "request": request,
"v2_active_nav": "cluster", "v2_active_nav": "cluster",
"bl_codes": bl_codes, "bl_codes": bl_codes,
**_v2_template_context(current_user),
}) })
@app.get("/v2/neu", response_class=HTMLResponse) @app.get("/v2/neu", response_class=HTMLResponse)
async def v2_neu(request: Request, current_user: dict = Depends(require_auth)): async def v2_neu(request: Request):
"""Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache.""" """Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache."""
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
bl_list = [ bl_list = [
{"code": bl.code, "name": bl.name} {"code": bl.code, "name": bl.name}
@ -2373,13 +2215,12 @@ async def v2_neu(request: Request, current_user: dict = Depends(require_auth)):
"v2_active_nav": "neu", "v2_active_nav": "neu",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
"default_model": settings.llm_model_default, "default_model": settings.llm_model_default,
**_v2_template_context(current_user),
}) })
@app.get("/v2/landtag-suche", response_class=HTMLResponse) @app.get("/v2/landtag-suche", response_class=HTMLResponse)
async def v2_landtag_suche(request: Request, current_user: dict = Depends(require_auth)): async def v2_landtag_suche(request: Request):
"""Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal.""" """Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB)."""
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
bl_list = [ bl_list = [
{"code": bl.code, "name": bl.name} {"code": bl.code, "name": bl.name}
@ -2390,13 +2231,12 @@ async def v2_landtag_suche(request: Request, current_user: dict = Depends(requir
"request": request, "request": request,
"v2_active_nav": "landtag_suche", "v2_active_nav": "landtag_suche",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
}) })
@app.get("/v2/batch", response_class=HTMLResponse) @app.get("/v2/batch", response_class=HTMLResponse)
async def v2_batch(request: Request, current_user: dict = Depends(require_admin)): async def v2_batch(request: Request):
"""Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL.""" """Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL."""
from .bundeslaender import alle_bundeslaender from .bundeslaender import alle_bundeslaender
bl_list = [ bl_list = [
{"code": bl.code, "name": bl.name} {"code": bl.code, "name": bl.name}
@ -2407,7 +2247,6 @@ async def v2_batch(request: Request, current_user: dict = Depends(require_admin)
"request": request, "request": request,
"v2_active_nav": "batch", "v2_active_nav": "batch",
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]), "bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
**_v2_template_context(current_user),
}) })
@ -2419,7 +2258,7 @@ async def v2_admin_freischaltungen(request: Request, user: dict = Depends(requir
return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", { return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", {
"request": request, "request": request,
"v2_active_nav": "admin_freischaltungen", "v2_active_nav": "admin_freischaltungen",
**_v2_template_context(user), "is_admin": True,
}) })
@ -2429,7 +2268,7 @@ async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)):
return templates.TemplateResponse("v2/screens/admin_queue.html", { return templates.TemplateResponse("v2/screens/admin_queue.html", {
"request": request, "request": request,
"v2_active_nav": "admin_queue", "v2_active_nav": "admin_queue",
**_v2_template_context(user), "is_admin": True,
}) })
@ -2439,7 +2278,7 @@ async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)):
return templates.TemplateResponse("v2/screens/admin_abos.html", { return templates.TemplateResponse("v2/screens/admin_abos.html", {
"request": request, "request": request,
"v2_active_nav": "admin_abos", "v2_active_nav": "admin_abos",
**_v2_template_context(user), "is_admin": True,
}) })
@ -2574,213 +2413,6 @@ 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 # Health check
@app.get("/health") @app.get("/health")
async def health(): async def health():

View File

@ -85,6 +85,4 @@ def build_pdf_href(zitat: dict, bundesland: str = "") -> str:
text = zitat.get("text", "") text = zitat.get("text", "")
q = " ".join(text.split()[:5]) q = " ".join(text.split()[:5])
# #page=N als URL-Hash, damit der Browser-PDF-Viewer direkt zur Seite return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}"
# 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

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 674 B

File diff suppressed because one or more lines are too long

View File

@ -66,32 +66,14 @@ body.v2 :focus-visible {
grid-area: topbar; grid-area: topbar;
background: var(--paper); background: var(--paper);
border-bottom: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline);
padding: 0 24px; padding: 10px 24px;
height: 32px; /* harte Höhe statt min-height */
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-4); gap: var(--space-4);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
line-height: 1;
color: var(--ecg-dark); 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 { .v2-topbar-spacer {
flex: 1; flex: 1;
@ -899,11 +881,8 @@ body.v2 ul.v2-manual ul li::before {
color: #fff; color: #fff;
} }
/* Menu Toggle Button nur auf Mobile sichtbar (< 900 px) /* ── Menu Toggle Button ─────────────────────────────────────────── */
!important nötig wegen .v2-topbar button { display: inline-flex } .v2-menu-toggle {
(die generische Reset-Regel hat 2 Klassen Specificity vs hier 1). */
.v2-topbar .v2-menu-toggle {
display: none !important;
padding: 6px 10px; padding: 6px 10px;
background: none; background: none;
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
@ -912,9 +891,6 @@ body.v2 ul.v2-manual ul li::before {
cursor: pointer; cursor: pointer;
line-height: 1; line-height: 1;
} }
@media (max-width: 900px) {
.v2-topbar .v2-menu-toggle { display: inline-flex !important; }
}
/* ── Fraktions-Score-Tabelle (Fix 2+3) ─────────────────────────── */ /* ── Fraktions-Score-Tabelle (Fix 2+3) ─────────────────────────── */
.v2-fraktions-scores { .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"> <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 #} {# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #}
<link rel="stylesheet" href="/static/v2/tokens.css?v={{ app_version|default('1') }}"> <link rel="stylesheet" href="/static/v2/tokens.css">
<link rel="stylesheet" href="/static/v2/fonts.css?v={{ app_version|default('1') }}"> <link rel="stylesheet" href="/static/v2/fonts.css">
<link rel="stylesheet" href="/static/v2/v2.css?v={{ app_version|default('1') }}"> <link rel="stylesheet" href="/static/v2/v2.css">
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
@ -40,29 +40,26 @@
<span class="v2-nav-count">{{ assessment_count }}</span> <span class="v2-nav-count">{{ assessment_count }}</span>
{% endif %} {% endif %}
</a> </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/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a> <a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</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 %} <a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>
{% 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 %} <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>
</div> </div>
{% if is_authenticated %}
<div class="v2-nav-group"> <div class="v2-nav-group">
<div class="v2-nav-label">— Prüfen</div> <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/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</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 %} <a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>
</div> </div>
<div class="v2-nav-group"> <div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div> <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="/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/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a> <a href="/api/feed.xml" class="v2-nav-item">{{ 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> </div>
{% endif %}
{% if is_admin %} {% if is_admin is defined and is_admin %}
<div class="v2-nav-group"> <div class="v2-nav-group">
<div class="v2-nav-label">— Administration</div> <div class="v2-nav-label">— Administration</div>
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a> <a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
@ -80,15 +77,6 @@
<a href="/methodik">{{ icon("info", 13) }} Methodik</a> <a href="/methodik">{{ icon("info", 13) }} Methodik</a>
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</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 ── #} {# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
<div id="v2-auth-control" style="display:inline-flex;align-items:center;"> <div id="v2-auth-control" style="display:inline-flex;align-items:center;">
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #} {# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
@ -216,27 +204,6 @@
{% block body_scripts %}{% endblock %} {% 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) ────────────────────────────── #} {# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
{% include "v2/components/auth_modal.html" %} {% include "v2/components/auth_modal.html" %}
@ -311,11 +278,5 @@
})(); })();
</script> </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> </body>
</html> </html>

View File

@ -49,10 +49,6 @@
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;"> 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 Anmelden
</button> </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> </form>
<!-- Register Form --> <!-- Register Form -->

View File

@ -1,386 +0,0 @@
{#
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

@ -1,93 +0,0 @@
{#
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,9 +27,7 @@
{% endif %} {% endif %}
{{ source }} {{ source }}
{% if pdf_href %} {% if pdf_href %}
{# 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. #} · <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>
{% 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 %} {% endif %}
</cite> </cite>
</div> </div>

View File

@ -1,166 +0,0 @@
{% 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,38 +342,22 @@
</div> </div>
</div> </div>
{# ── Share-Block (analog v1) ───────────────────────────────────── #} {# ── Share-Block ──────────────────────────────────────────────── #}
<div style="margin-top:20px;"> <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="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;"> <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')" <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);"> 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 Threads
</button> </button>
<button onclick="v2DetailShare('twitter')" <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);"> 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>
<button onclick="v2DetailShareMastodon()" <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);"> 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 Mastodon
</button> </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>
</div> </div>
@ -506,7 +490,6 @@ window.v2ShowMatrixFieldInfo = function(field) {
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }}; var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
var TITLE = {{ antrag.title | tojson }}; var TITLE = {{ antrag.title | tojson }};
var SCORE = {{ antrag.score | tojson }}; var SCORE = {{ antrag.score | tojson }};
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS); var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
var currentUser = null; var currentUser = null;
@ -650,41 +633,11 @@ window.v2ShowMatrixFieldInfo = function(field) {
var text = buildShareText(platform) + '\n' + PERMALINK; var text = buildShareText(platform) + '\n' + PERMALINK;
var urls = { var urls = {
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text), 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'); 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() { window.v2DetailShareMastodon = function() {
var text = buildShareText('mastodon') + '\n' + PERMALINK; var text = buildShareText('mastodon') + '\n' + PERMALINK;
var instance = localStorage.getItem('mastodon_instance'); var instance = localStorage.getItem('mastodon_instance');

View File

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

View File

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

View File

@ -1,125 +0,0 @@
{% 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,9 +147,6 @@
</p> </p>
</div> </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)"> <form class="ls-form" onsubmit="lsSearch(event)">
<div class="ls-q"> <div class="ls-q">
<label for="ls-q-input">Suchbegriff</label> <label for="ls-q-input">Suchbegriff</label>
@ -161,6 +158,14 @@
required required
onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}"> onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
</div> </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"> <button type="submit" class="ls-submit" id="ls-btn">
{{ icon("magnifying-glass-plus", 14) }} Suchen {{ icon("magnifying-glass-plus", 14) }} Suchen
</button> </button>
@ -197,14 +202,7 @@ async function lsSearch(e) {
if (e && e.preventDefault) e.preventDefault(); if (e && e.preventDefault) e.preventDefault();
var q = (document.getElementById('ls-q-input').value || '').trim(); var q = (document.getElementById('ls-q-input').value || '').trim();
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL'; var bl = document.getElementById('ls-bl-select').value;
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) { if (q.length < 2) {
document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.'; document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.';
@ -294,50 +292,9 @@ async function lsAnalyse(btn, drucksache, bundesland) {
} }
var data = await resp.json(); var data = await resp.json();
var ds = data.drucksache || drucksache; var ds = data.drucksache || drucksache;
// Backend gibt {job_id, drucksache} zurück (Queue) — nicht direkt redirecten,
// Falls bereits bewertet oder skipped: direkt redirecten // sondern auf /antrag/{ds} gehen, dort wird dann ggf. der Polling-Status sichtbar
if (data.status === 'already_checked') {
window.location.href = '/antrag/' + encodeURIComponent(ds); 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) { } catch (err) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Fehler'; btn.textContent = 'Fehler';
@ -356,15 +313,5 @@ function escHtml(s) {
function escAttr(s) { function escAttr(s) {
return String(s).replace(/'/g, "\\'"); 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> </script>
{% endblock %} {% endblock %}

View File

@ -92,15 +92,18 @@
<form class="neu-form" onsubmit="startAnalyse(event)"> <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> <label for="neu-drucksache">Drucksachen-Nummer</label>
<input type="text" id="neu-drucksache" name="drucksache" <input type="text" id="neu-drucksache" name="drucksache"
placeholder="z. B. 18/12345 oder NRW-18/12345" placeholder="z. B. 18/12345 oder NRW-18/12345"
required autocomplete="off"> 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> <label for="neu-model">Modell</label>
<select id="neu-model" name="model"> <select id="neu-model" name="model">
<option value="">Standard ({{ default_model }})</option> <option value="">Standard ({{ default_model }})</option>
@ -133,17 +136,11 @@ async function startAnalyse(e) {
const errEl = document.getElementById('neu-error'); const errEl = document.getElementById('neu-error');
const drucksache = document.getElementById('neu-drucksache').value.trim(); const drucksache = document.getElementById('neu-drucksache').value.trim();
const bundesland = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL'; const bundesland = document.getElementById('neu-bl').value;
const model = document.getElementById('neu-model').value; const model = document.getElementById('neu-model').value;
if (!drucksache) return; if (!drucksache) return;
if (bundesland === 'ALL') {
errEl.style.display = '';
errEl.textContent = 'Bitte zuerst ein Bundesland im Header wählen.';
return;
}
btn.disabled = true; btn.disabled = true;
statusEl.style.display = ''; statusEl.style.display = '';
progEl.style.display = ''; progEl.style.display = '';
@ -157,56 +154,16 @@ async function startAnalyse(e) {
if (model) fd.append('model', model); if (model) fd.append('model', model);
const resp = await fetch('/api/analyze-drucksache', { method: 'POST', body: fd }); 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) { if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText })); const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || ('HTTP ' + resp.status)); throw new Error(err.detail || ('HTTP ' + resp.status));
} }
const data = await resp.json(); const data = await resp.json();
// Redirect to result page
const ds = data.drucksache || drucksache; const ds = data.drucksache || drucksache;
progText.textContent = 'Analyse abgeschlossen. Weiterleitung …';
if (data.status === 'already_checked') { setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 600);
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) { } catch (err) {
progEl.style.display = 'none'; progEl.style.display = 'none';
@ -215,15 +172,5 @@ async function startAnalyse(e) {
btn.disabled = false; 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> </script>
{% endblock %} {% endblock %}

View File

@ -23,11 +23,6 @@ services:
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-GWÖ-Antragsprüfer} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-GWÖ-Antragsprüfer}
- UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET} - UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET}
- BASE_URL=${BASE_URL:-https://gwoe.toppyr.de} - 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: volumes:
- ./data:/app/data - ./data:/app/data
- ./reports:/app/reports - ./reports:/app/reports

View File

@ -42,26 +42,23 @@ echo " $(date -Iseconds)"
echo "================================================================" echo "================================================================"
echo echo
echo "[1] Public-Seiten (200 ohne Auth)" echo "[1] Hauptseiten erreichbar (alle 200)"
check "v2 Default /" "200" "/" check "v2 Default /" "200" "/"
check "v2 Detail (echte DS)" "200" "/antrag/21/754S" check "v2 Detail (echte DS)" "200" "/antrag/21/754S"
check "Classic /classic" "200" "/classic" check "Classic /classic" "200" "/classic"
check "/auswertungen" "200" "/auswertungen"
check "/methodik" "200" "/methodik" check "/methodik" "200" "/methodik"
check "/quellen" "200" "/quellen" check "/quellen" "200" "/quellen"
check "/impressum" "200" "/impressum" check "/impressum" "200" "/impressum"
check "/datenschutz" "200" "/datenschutz" check "/datenschutz" "200" "/datenschutz"
check "/v2/merkliste" "200" "/v2/merkliste"
check "/v2/tags" "200" "/v2/tags" 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" 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
echo "[2] API-Endpoints (öffentlich)" echo "[2] API-Endpoints (öffentlich)"
check "/api/assessments" "200" "/api/assessments" check "/api/assessments" "200" "/api/assessments"

View File

@ -1,159 +0,0 @@
"""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