Compare commits
20 Commits
c38bca615d
...
4b03448e29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b03448e29 | ||
|
|
07bb832c35 | ||
|
|
a8d7b72702 | ||
|
|
fab1bddd3c | ||
|
|
98787c8684 | ||
|
|
b1ad2bd45d | ||
|
|
7a64335e64 | ||
|
|
c1926ada4f | ||
|
|
6581acd28e | ||
|
|
7cbd46f88d | ||
|
|
7f070b5e6c | ||
|
|
fa5a5b6026 | ||
|
|
85a10b7fc3 | ||
|
|
997d59a9a5 | ||
|
|
273d45ea36 | ||
|
|
88f9c7db6c | ||
|
|
489a1915f8 | ||
|
|
50c026e3a0 | ||
|
|
553e99d14e | ||
|
|
38bffb23fa |
@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "GWÖ-Antragsprüfer"
|
||||
app_version: str = "1.0.0"
|
||||
app_version: str = "1.0.2"
|
||||
prompt_version: str = "v4.1"
|
||||
|
||||
# Paths
|
||||
@ -54,6 +54,14 @@ class Settings(BaseSettings):
|
||||
# Token für Unsubscribe-Links (HMAC-Secret)
|
||||
unsubscribe_secret: str = "change-me-in-prod"
|
||||
|
||||
# Gitea-API-Token für Feedback-Issues (Issue #feedback-widget)
|
||||
# Wert in .env: GITEA_TOKEN=<token>
|
||||
# Token-Quelle: cat ~/.claude/.gitea-token
|
||||
gitea_token: str = ""
|
||||
gitea_api_url: str = "https://repo.toppyr.de/api/v1"
|
||||
gitea_repo_owner: str = "tobias"
|
||||
gitea_repo_name: str = "gwoe-antragspruefer"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
|
||||
@ -86,3 +86,45 @@ def ist_abstimmbar(typ_normiert: str) -> bool:
|
||||
def ist_abstimmbar_original(original: str) -> bool:
|
||||
"""Convenience: prüft direkt am Original-Typ-String."""
|
||||
return ist_abstimmbar(normalize_typ(original))
|
||||
|
||||
|
||||
# Frage-Präfixe die typisch für Kleine Anfragen sind. Wird genutzt wenn der
|
||||
# Adapter (z.B. NRW) den Typ nur als "Drucksache" liefert — wir versuchen
|
||||
# anhand des Titels eine bessere Klassifikation, damit Search-Ergebnisse
|
||||
# nicht voll mit nicht-abstimmbaren Anfragen sind.
|
||||
_FRAGE_PRAEFIXE = (
|
||||
"welche ", "wie viele ", "wieviel", "wie viel ", "wie hoch ", "wie ",
|
||||
"wann ", "warum ", "weshalb ", "wo ", "wer ", "wie steht ", "wie weit ",
|
||||
"ist es ", "ist der ", "ist die ", "ist das ", "sind ",
|
||||
"trifft es ", "kann ", "wird ", "wieso ", "was ",
|
||||
"hat ", "hat der ", "hat die ", "hat das ",
|
||||
"haben ", "war ", "waren ",
|
||||
)
|
||||
|
||||
|
||||
def likely_kleine_anfrage_titel(title: str) -> bool:
|
||||
"""Heuristik: erkennt Kleine Anfragen am Titel-Format.
|
||||
|
||||
Wenn der Titel mit einem typischen Frage-Präfix beginnt oder mit "?" endet,
|
||||
behandeln wir die Drucksache als Kleine Anfrage. NRW-OPAL klassifiziert
|
||||
alle Drucksachen als "Drucksache" → ohne diese Heuristik landen Anfragen
|
||||
in den Search-Ergebnissen, was den User verwirrt (#149 Folge).
|
||||
|
||||
Args:
|
||||
title: Drucksachen-Titel inkl. evtl. Nummer-Präfix wie "1Welche...".
|
||||
|
||||
Returns:
|
||||
True wenn der Titel wie eine Kleine Anfrage aussieht.
|
||||
"""
|
||||
if not title:
|
||||
return False
|
||||
t = title.strip()
|
||||
# Manche Adapter prefixen mit Nummerierung wie "1Welche..." — strippen
|
||||
while t and (t[0].isdigit() or t[0] in " .-"):
|
||||
t = t[1:]
|
||||
t_low = t.lower()
|
||||
if t_low.startswith(_FRAGE_PRAEFIXE):
|
||||
return True
|
||||
if t.rstrip().endswith("?"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -714,7 +714,7 @@ def _chunk_pdf_url(chunk: dict) -> Optional[str]:
|
||||
# die URL bleibt bounded (sonst würden 500-Zeichen-Snippets in jeder
|
||||
# Zitat-URL stehen und das HTML-Report-JSON aufblähen).
|
||||
q = urllib.parse.quote_plus(text[:200])
|
||||
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}"
|
||||
return f"/api/wahlprogramm-cite?pid={prog_id}&seite={seite}&q={q}#page={seite}"
|
||||
|
||||
if seite:
|
||||
return f"/static/referenzen/{pdf}#page={seite}"
|
||||
@ -777,9 +777,14 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
|
||||
rects = []
|
||||
if needle:
|
||||
clean = needle.replace("\u00ad", "")
|
||||
# LLMs ziehen h\u00e4ufig die Seitenzahl-Header (\u201e44 Gute Bildung \u2026")
|
||||
# mit ins Zitat. Wenn die ersten Tokens reine Ziffern sind,
|
||||
# strippen wir sie f\u00fcr die Suche \u2014 sonst matched search_for nicht.
|
||||
import re as _re
|
||||
clean = _re.sub(r"^\s*\d+\s+", "", clean).strip()
|
||||
words = clean.split()
|
||||
anchor = " ".join(words[:5]) if len(words) >= 5 else clean
|
||||
# Versuch 1: angegebene Seite, Volltext
|
||||
# Versuch 1: angegebene Seite, Volltext (gestrippt)
|
||||
rects = src[target_page_idx].search_for(clean)
|
||||
# Versuch 2: angegebene Seite, 5-Wort-Anker
|
||||
if not rects:
|
||||
@ -792,8 +797,7 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
|
||||
target_page_idx = i
|
||||
break
|
||||
|
||||
# Volles PDF mit Highlight-Annotation. Der Browser öffnet das
|
||||
# vollständige Wahlprogramm; das Frontend hängt #page=N an die URL.
|
||||
# Volles PDF mit Highlight-Annotation.
|
||||
page = src[target_page_idx]
|
||||
if needle and rects:
|
||||
for rect in rects:
|
||||
@ -802,6 +806,16 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
|
||||
annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb
|
||||
annot.update()
|
||||
|
||||
# PDF-OpenAction setzen, damit der Reader direkt auf der richtigen
|
||||
# Seite startet (statt Seite 1) — sonst sieht der User „PDF öffnet,
|
||||
# aber falsche Seite". /Fit = passt-zur-Größe.
|
||||
try:
|
||||
page_xref = page.xref
|
||||
catalog_xref = src.pdf_catalog()
|
||||
src.xref_set_key(catalog_xref, "OpenAction", f"[{page_xref} 0 R /Fit]")
|
||||
except Exception:
|
||||
logger.exception("render_highlighted_page: OpenAction-Setzen fehlgeschlagen")
|
||||
|
||||
highlighted = bool(needle and rects)
|
||||
try:
|
||||
return src.tobytes(), target_page_idx + 1, highlighted
|
||||
|
||||
414
app/main.py
414
app/main.py
@ -91,6 +91,25 @@ app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
|
||||
# Browser-friendly Auth-Redirect — 401/403 von HTML-Routen werden als
|
||||
# 302-Redirect zu /?login=1 ausgeliefert (Login-Modal öffnet sich automatisch).
|
||||
# API-Calls (Accept: application/json) bleiben bei 401/403-JSON.
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _auth_redirect_handler(request: Request, exc: HTTPException):
|
||||
if exc.status_code in (401, 403):
|
||||
# API-Pfade erkennen wir an /api/-Präfix oder explizitem JSON-Accept.
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_json = "application/json" in accept and "text/html" not in accept
|
||||
is_api = request.url.path.startswith("/api/")
|
||||
is_browser = not is_api and not wants_json
|
||||
if is_browser:
|
||||
from fastapi.responses import RedirectResponse
|
||||
target = f"/?login=1&next={request.url.path}"
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
# Default-Verhalten von FastAPI nachbauen
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers or None)
|
||||
|
||||
|
||||
# Security Headers Middleware
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
@ -127,6 +146,28 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
templates = Jinja2Templates(directory=str(templates_dir))
|
||||
|
||||
|
||||
# ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ─────────────────
|
||||
|
||||
@app.exception_handler(401)
|
||||
async def auth_required_redirect(request: Request, exc: HTTPException):
|
||||
"""Bei 401 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal)."""
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/?login=1", status_code=302)
|
||||
return JSONResponse({"detail": exc.detail}, status_code=401)
|
||||
|
||||
|
||||
@app.exception_handler(403)
|
||||
async def admin_required_redirect(request: Request, exc: HTTPException):
|
||||
"""Bei 403 auf HTML-Routes: Redirect zu /?login=1 (öffnet Auth-Modal)."""
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/?login=1", status_code=302)
|
||||
return JSONResponse({"detail": exc.detail}, status_code=403)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
import asyncio
|
||||
@ -209,7 +250,7 @@ async def classic_index(request: Request):
|
||||
# ─── Default: / → v2 (Default-Flip #139 Phase 2) ────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
async def index(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Startseite — rendert v2-Listenansicht (Default-Flip Phase 2).
|
||||
|
||||
Alte /?drucksache=XX-YYYY Deep-Links werden per 301-Redirect auf
|
||||
@ -230,11 +271,12 @@ async def index(request: Request):
|
||||
"assessments": assessments,
|
||||
"bl_codes": bl_codes,
|
||||
"assessment_count": len(assessments),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse)
|
||||
async def antrag_detail(request: Request, drucksache: str):
|
||||
async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten."""
|
||||
try:
|
||||
drucksache = validate_drucksache(drucksache)
|
||||
@ -243,6 +285,7 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
"request": request,
|
||||
"v2_active_nav": "durchsuchen",
|
||||
"error": f"Ungültige Drucksachen-ID: {drucksache}",
|
||||
**_v2_template_context(current_user),
|
||||
}, status_code=400)
|
||||
|
||||
row = await get_assessment(drucksache)
|
||||
@ -251,6 +294,7 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
"request": request,
|
||||
"v2_active_nav": "durchsuchen",
|
||||
"error": f"Antrag {drucksache} wurde nicht gefunden.",
|
||||
**_v2_template_context(current_user),
|
||||
}, status_code=404)
|
||||
|
||||
antrag = _row_to_detail(row)
|
||||
@ -294,9 +338,34 @@ async def antrag_detail(request: Request, drucksache: str):
|
||||
"E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
|
||||
},
|
||||
"matrix_labels": MATRIX_LABELS,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
def _v2_template_context(current_user=None) -> dict:
|
||||
"""Gemeinsame v2-Template-Variablen: is_admin, is_authenticated, v2_bundeslaender.
|
||||
|
||||
Wird in jeder v2-Route aufgerufen und per **-Spread in den Template-Context gemischt.
|
||||
"""
|
||||
is_authenticated = bool(current_user and current_user.get("authenticated", False))
|
||||
# require_auth liefert keinen "authenticated"-Key, aber ein sub-Feld — beides prüfen
|
||||
if current_user and current_user.get("sub"):
|
||||
is_authenticated = True
|
||||
roles = (current_user or {}).get("roles", [])
|
||||
is_admin = "admin" in roles or "gwoe-admin" in roles
|
||||
v2_bls = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
for bl in alle_bundeslaender()
|
||||
if bl.aktiv
|
||||
]
|
||||
return {
|
||||
"is_authenticated": is_authenticated,
|
||||
"is_admin": is_admin,
|
||||
"v2_bundeslaender": v2_bls,
|
||||
"app_version": settings.app_version,
|
||||
}
|
||||
|
||||
|
||||
def _rows_to_list(rows):
|
||||
"""Konvertiert DB-Rows in das flache Dict-Format für die v2-Listenansicht."""
|
||||
result = []
|
||||
@ -648,6 +717,25 @@ async def auth_login_url(request: Request, redirect: str = "/"):
|
||||
return {"enabled": True, "url": url}
|
||||
|
||||
|
||||
@app.get("/api/auth/forgot-password")
|
||||
async def auth_forgot_password(request: Request):
|
||||
"""Redirect zur Keycloak-Passwort-Reset-Seite (#143-Folge).
|
||||
|
||||
Keycloak bietet bei `resetPasswordAllowed=True` eine eigene Reset-Page,
|
||||
die per Mail einen Link zum Passwort-Setzen schickt. Wir leiten direkt
|
||||
dahin um statt eine eigene UI zu bauen.
|
||||
"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
base = str(request.base_url).rstrip("/").replace("http://", "https://")
|
||||
issuer = f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
|
||||
target = (
|
||||
f"{issuer}/login-actions/reset-credentials"
|
||||
f"?client_id={settings.keycloak_client_id}"
|
||||
f"&redirect_uri={base}/"
|
||||
)
|
||||
return RedirectResponse(url=target, status_code=302)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_direct_login(
|
||||
username: str = Form(...),
|
||||
@ -1015,7 +1103,37 @@ async def auth_register(
|
||||
if create_resp.status_code != 201:
|
||||
raise HTTPException(status_code=500, detail="Registrierung fehlgeschlagen")
|
||||
|
||||
return {"status": "pending_approval", "message": "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen."}
|
||||
# #143: Bestätigungsmail an User direkt nach Anmeldung
|
||||
try:
|
||||
from .mail import send_mail
|
||||
anrede = f"{firstName} {lastName}".strip() or username
|
||||
text_body = (
|
||||
f"Hallo {anrede},\n\n"
|
||||
f"deine Registrierung am GWÖ-Antragsprüfer ist eingegangen.\n\n"
|
||||
f"Was passiert jetzt?\n"
|
||||
f" 1. Ein Admin schaltet deinen Account manuell frei.\n"
|
||||
f" 2. Du erhältst dann eine separate Mail mit einem Link zum Passwort-Setzen.\n"
|
||||
f" 3. Anschließend kannst du dich auf https://gwoe.toppyr.de/ anmelden.\n\n"
|
||||
f"Falls du nach 1-2 Werktagen nichts hörst, melde dich gerne unter mail@tobiasroedel.de.\n\n"
|
||||
f"Schöne Grüße\nGWÖ-Antragsprüfer"
|
||||
)
|
||||
html_body = (
|
||||
f"<p>Hallo <strong>{anrede}</strong>,</p>"
|
||||
f"<p>deine Registrierung am <a href=\"https://gwoe.toppyr.de/\">GWÖ-Antragsprüfer</a> ist eingegangen.</p>"
|
||||
f"<p><strong>Was passiert jetzt?</strong></p>"
|
||||
f"<ol>"
|
||||
f"<li>Ein Admin schaltet deinen Account manuell frei.</li>"
|
||||
f"<li>Du erhältst dann eine separate Mail mit einem Link zum Passwort-Setzen.</li>"
|
||||
f"<li>Anschließend kannst du dich auf <a href=\"https://gwoe.toppyr.de/\">gwoe.toppyr.de</a> anmelden.</li>"
|
||||
f"</ol>"
|
||||
f"<p>Falls du nach 1-2 Werktagen nichts hörst, melde dich gerne unter <a href=\"mailto:mail@tobiasroedel.de\">mail@tobiasroedel.de</a>.</p>"
|
||||
f"<p style=\"color:#666;font-size:0.9em\">Schöne Grüße<br>GWÖ-Antragsprüfer</p>"
|
||||
)
|
||||
await send_mail(email, "GWÖ-Antragsprüfer — Registrierung eingegangen", text_body, html_body)
|
||||
except Exception:
|
||||
logger.exception("Bestätigungsmail an %s fehlgeschlagen — User-Anlage war aber erfolgreich", email)
|
||||
|
||||
return {"status": "pending_approval", "message": "Registrierung eingegangen. Wir haben dir eine Bestätigung per E-Mail geschickt."}
|
||||
|
||||
|
||||
@app.get("/api/auth/pending-users")
|
||||
@ -1265,8 +1383,13 @@ async def search_landtag(
|
||||
|
||||
try:
|
||||
external = adapter._filter_abstimmbar(await adapter.search(q, limit))
|
||||
# Zusätzliche Title-Heuristik: bei Adaptern die Typ='Drucksache' liefern
|
||||
# (NRW), Kleine-Anfrage-Frage-Pattern erkennen und ausfiltern.
|
||||
from .drucksache_typen import likely_kleine_anfrage_titel, KLEINE_ANFRAGE
|
||||
results = []
|
||||
for doc in external:
|
||||
if doc.typ_normiert == "sonstige" and likely_kleine_anfrage_titel(doc.title):
|
||||
continue # höchstwahrscheinlich Kleine Anfrage
|
||||
results.append({
|
||||
"drucksache": doc.drucksache,
|
||||
"title": doc.title,
|
||||
@ -1588,25 +1711,27 @@ async def list_bundeslaender():
|
||||
# === Impressum / Datenschutz ===
|
||||
|
||||
@app.get("/impressum", response_class=HTMLResponse)
|
||||
async def impressum_page(request: Request):
|
||||
async def impressum_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
return templates.TemplateResponse("v2/screens/legal.html", {
|
||||
"request": request, "app_name": settings.app_name,
|
||||
"title": "Impressum", "section": "impressum",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/datenschutz", response_class=HTMLResponse)
|
||||
async def datenschutz_page(request: Request):
|
||||
async def datenschutz_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
return templates.TemplateResponse("v2/screens/legal.html", {
|
||||
"request": request, "app_name": settings.app_name,
|
||||
"title": "Datenschutzerklärung", "section": "datenschutz",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
# === Quellen / Programme ===
|
||||
|
||||
@app.get("/methodik", response_class=HTMLResponse)
|
||||
async def methodik_page(request: Request):
|
||||
async def methodik_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Transparenz-/Methodik-Seite (#96)."""
|
||||
from .bundeslaender import aktive_bundeslaender, BUNDESLAENDER
|
||||
from .embeddings import get_indexing_status
|
||||
@ -1630,11 +1755,12 @@ async def methodik_page(request: Request):
|
||||
"programme_count": status.get("total", 0),
|
||||
"chunk_count": sum(p.get("chunks", 0) for p in status.get("programmes", [])),
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/quellen", response_class=HTMLResponse)
|
||||
async def quellen_page(request: Request):
|
||||
async def quellen_page(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Quellen-Seite mit allen Wahl- und Parteiprogrammen, nach BL gruppiert."""
|
||||
from .bundeslaender import BUNDESLAENDER
|
||||
programmes = get_programme_info()
|
||||
@ -1661,6 +1787,7 @@ async def quellen_page(request: Request):
|
||||
"wahlprogramme_grouped": wahlprogramme_grouped,
|
||||
"grundsatzprogramme": grundsatz,
|
||||
"status": status,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@ -1850,8 +1977,8 @@ async def index_programme(
|
||||
|
||||
|
||||
@app.get("/auswertungen", response_class=HTMLResponse)
|
||||
async def auswertungen_page(request: Request):
|
||||
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic)."""
|
||||
async def auswertungen_page(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Auswertungs-Dashboard in v2 (Phase 3 Migration aus Classic). Auth-only."""
|
||||
from .auswertungen import get_wahlperioden
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
|
||||
@ -1864,6 +1991,7 @@ async def auswertungen_page(request: Request):
|
||||
"v2_active_nav": "auswertungen",
|
||||
"wahlperioden": wahlperioden,
|
||||
"bl_codes": bl_codes,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@ -2171,26 +2299,55 @@ async def v2_antrag_redirect(request: Request, drucksache: str):
|
||||
|
||||
|
||||
@app.get("/v2/merkliste", response_class=HTMLResponse)
|
||||
async def v2_merkliste(request: Request):
|
||||
"""Merkliste (Bookmarks) — lädt Daten via /api/bookmarks client-seitig."""
|
||||
async def v2_merkliste(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Merkliste (Bookmarks) — nur für eingeloggte User; lädt Daten via /api/bookmarks client-seitig."""
|
||||
return templates.TemplateResponse("v2/screens/merkliste.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "merkliste",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/tags", response_class=HTMLResponse)
|
||||
async def v2_tags(request: Request):
|
||||
async def v2_tags(request: Request, current_user: Optional[dict] = Depends(get_current_user)):
|
||||
"""Tag-Cloud-Seite — Themen-Filter über alle Assessments."""
|
||||
return templates.TemplateResponse("v2/screens/tags.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "tags",
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/abos", response_class=HTMLResponse)
|
||||
async def v2_abos(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Eigene E-Mail-Abos verwalten — auth-only."""
|
||||
from .parteien import all_canonical_keys
|
||||
# Landesregierung als Filter unsinnig — ausblenden
|
||||
parteien = [p for p in all_canonical_keys() if p != "Landesregierung"]
|
||||
return templates.TemplateResponse("v2/screens/abos.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "abos",
|
||||
"parteien": parteien,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/feed", response_class=HTMLResponse)
|
||||
async def v2_feed(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Atom-Feed-Konfigurations-Seite — auth-only."""
|
||||
from .parteien import all_canonical_keys
|
||||
parteien = [p for p in all_canonical_keys() if p != "Landesregierung"]
|
||||
return templates.TemplateResponse("v2/screens/feed.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "feed",
|
||||
"parteien": parteien,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/cluster", response_class=HTMLResponse)
|
||||
async def v2_cluster(request: Request):
|
||||
"""Cluster-Liste — Top-10 Cluster als redaktionelle Liste."""
|
||||
async def v2_cluster(request: Request, current_user: dict = Depends(require_admin)):
|
||||
"""Cluster-Liste — nur für Admins."""
|
||||
rows = await get_all_assessments(None)
|
||||
assessments = _rows_to_list(rows)
|
||||
bl_codes = sorted({a["bundesland"] for a in assessments if a.get("bundesland")})
|
||||
@ -2198,12 +2355,13 @@ async def v2_cluster(request: Request):
|
||||
"request": request,
|
||||
"v2_active_nav": "cluster",
|
||||
"bl_codes": bl_codes,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/neu", response_class=HTMLResponse)
|
||||
async def v2_neu(request: Request):
|
||||
"""Neuer-Antrag-Form — startet Analyse via /api/analyze-drucksache."""
|
||||
async def v2_neu(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Neuer-Antrag-Form — nur für eingeloggte User; startet Analyse via /api/analyze-drucksache."""
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
bl_list = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
@ -2215,12 +2373,13 @@ async def v2_neu(request: Request):
|
||||
"v2_active_nav": "neu",
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
"default_model": settings.llm_model_default,
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/landtag-suche", response_class=HTMLResponse)
|
||||
async def v2_landtag_suche(request: Request):
|
||||
"""Landtag-Suche — sucht Drucksachen live im Landtags-Portal (nicht nur DB)."""
|
||||
async def v2_landtag_suche(request: Request, current_user: dict = Depends(require_auth)):
|
||||
"""Landtag-Suche — nur für eingeloggte User; sucht Drucksachen live im Landtags-Portal."""
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
bl_list = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
@ -2231,12 +2390,13 @@ async def v2_landtag_suche(request: Request):
|
||||
"request": request,
|
||||
"v2_active_nav": "landtag_suche",
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/v2/batch", response_class=HTMLResponse)
|
||||
async def v2_batch(request: Request):
|
||||
"""Batch-Analyse-Form (Admin) — enqueued ungeprüfte Drucksachen eines BL."""
|
||||
async def v2_batch(request: Request, current_user: dict = Depends(require_admin)):
|
||||
"""Batch-Analyse-Form — nur für Admins; enqueued ungeprüfte Drucksachen eines BL."""
|
||||
from .bundeslaender import alle_bundeslaender
|
||||
bl_list = [
|
||||
{"code": bl.code, "name": bl.name}
|
||||
@ -2247,6 +2407,7 @@ async def v2_batch(request: Request):
|
||||
"request": request,
|
||||
"v2_active_nav": "batch",
|
||||
"bundeslaender": sorted(bl_list, key=lambda x: x["name"]),
|
||||
**_v2_template_context(current_user),
|
||||
})
|
||||
|
||||
|
||||
@ -2258,7 +2419,7 @@ async def v2_admin_freischaltungen(request: Request, user: dict = Depends(requir
|
||||
return templates.TemplateResponse("v2/screens/admin_freischaltungen.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "admin_freischaltungen",
|
||||
"is_admin": True,
|
||||
**_v2_template_context(user),
|
||||
})
|
||||
|
||||
|
||||
@ -2268,7 +2429,7 @@ async def v2_admin_queue(request: Request, user: dict = Depends(require_admin)):
|
||||
return templates.TemplateResponse("v2/screens/admin_queue.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "admin_queue",
|
||||
"is_admin": True,
|
||||
**_v2_template_context(user),
|
||||
})
|
||||
|
||||
|
||||
@ -2278,7 +2439,7 @@ async def v2_admin_abos(request: Request, user: dict = Depends(require_admin)):
|
||||
return templates.TemplateResponse("v2/screens/admin_abos.html", {
|
||||
"request": request,
|
||||
"v2_active_nav": "admin_abos",
|
||||
"is_admin": True,
|
||||
**_v2_template_context(user),
|
||||
})
|
||||
|
||||
|
||||
@ -2413,6 +2574,213 @@ async def api_admin_wahlprogramm_fetch(
|
||||
})
|
||||
|
||||
|
||||
# ─── Feedback / Bug-Report — Gitea-Issue-Anbindung ───────────────────────────
|
||||
|
||||
def _strip_html(text: str, max_len: int) -> str:
|
||||
"""Minimale HTML-Tag-Entfernung + Längenbegrenzung für Nutzerinput."""
|
||||
import re
|
||||
cleaned = re.sub(r'<[^>]+>', '', text)
|
||||
return cleaned[:max_len]
|
||||
|
||||
|
||||
async def _gitea_ensure_label(session, base_url: str, owner: str, repo: str,
|
||||
token: str, label_name: str, color: str = "#e11d48") -> int | None:
|
||||
"""Gibt die ID des Labels zurück; legt es idempotent an, falls es fehlt."""
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
url = f"{base_url}/repos/{owner}/{repo}/labels"
|
||||
try:
|
||||
r = await session.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
for lbl in r.json():
|
||||
if lbl.get("name") == label_name:
|
||||
return lbl["id"]
|
||||
# Label fehlt → anlegen
|
||||
r2 = await session.post(url, headers=headers,
|
||||
json={"name": label_name, "color": color})
|
||||
if r2.status_code in (200, 201):
|
||||
return r2.json().get("id")
|
||||
except Exception as exc:
|
||||
logger.exception("Gitea-Label-Lookup fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _gitea_upload_screenshot(session, base_url: str, owner: str, repo: str,
|
||||
token: str, issue_index: int,
|
||||
data_uri: str) -> str | None:
|
||||
"""Lädt einen Screenshot als Issue-Asset hoch. Gibt Attachment-URL zurück oder None."""
|
||||
import base64, re as _re
|
||||
m = _re.match(r'data:(image/[a-z]+);base64,(.+)', data_uri, _re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
mime, b64data = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64data)
|
||||
except Exception:
|
||||
return None
|
||||
ext = mime.split('/')[-1]
|
||||
upload_url = f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/assets"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
files = {"attachment": (f"screenshot.{ext}", raw, mime)}
|
||||
try:
|
||||
r = await session.post(upload_url, headers=headers, files=files)
|
||||
if r.status_code in (200, 201):
|
||||
return r.json().get("browser_download_url") or r.json().get("download_url")
|
||||
except Exception as exc:
|
||||
logger.exception("Screenshot-Upload fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
@limiter.limit("5/hour")
|
||||
async def submit_feedback(
|
||||
request: Request,
|
||||
titel: str = Form(...),
|
||||
beschreibung: str = Form(...),
|
||||
url: str = Form(""),
|
||||
user_agent: str = Form(""),
|
||||
viewport: str = Form(""),
|
||||
drucksache: str = Form(""),
|
||||
klicks_json: str = Form("[]"),
|
||||
errors_json: str = Form("[]"),
|
||||
screenshot: Optional[str] = Form(None),
|
||||
screenshot_error: Optional[str] = Form(None),
|
||||
current_user: Optional[dict] = Depends(get_current_user),
|
||||
):
|
||||
"""Erstellt ein Gitea-Issue mit Label 'feedback'.
|
||||
|
||||
Audit-Trail (Klicks, Errors, URL etc.) wird im Issue-Body als
|
||||
Markdown-Code-Block angefügt. Screenshot wird als Issue-Asset
|
||||
hochgeladen, falls vorhanden.
|
||||
"""
|
||||
import json as _json
|
||||
import httpx
|
||||
|
||||
# Validierung
|
||||
titel_clean = _strip_html(titel, 200).strip()
|
||||
beschreibung_clean = _strip_html(beschreibung, 5000).strip()
|
||||
if not titel_clean:
|
||||
raise HTTPException(status_code=400, detail="Titel darf nicht leer sein")
|
||||
if not beschreibung_clean:
|
||||
raise HTTPException(status_code=400, detail="Beschreibung darf nicht leer sein")
|
||||
|
||||
# Audit-Trail parsen
|
||||
try:
|
||||
klicks = _json.loads(klicks_json)[:15]
|
||||
except Exception:
|
||||
klicks = []
|
||||
try:
|
||||
errors = _json.loads(errors_json)[:10]
|
||||
except Exception:
|
||||
errors = []
|
||||
|
||||
# User-Identität (wenn eingeloggt)
|
||||
user_email = ""
|
||||
user_name = ""
|
||||
if current_user:
|
||||
user_email = current_user.get("email", "")
|
||||
user_name = current_user.get("preferred_username", current_user.get("name", ""))
|
||||
|
||||
# Issue-Body zusammenbauen
|
||||
body_parts = [beschreibung_clean, ""]
|
||||
|
||||
body_parts.append("## Kontext")
|
||||
body_parts.append(f"- **URL:** `{url[:300]}`")
|
||||
if drucksache:
|
||||
body_parts.append(f"- **Drucksache:** `{drucksache[:100]}`")
|
||||
body_parts.append(f"- **Viewport:** {viewport}")
|
||||
body_parts.append(f"- **User-Agent:** `{user_agent[:200]}`")
|
||||
if user_name:
|
||||
body_parts.append(f"- **Gemeldet von:** {user_name} ({user_email})")
|
||||
else:
|
||||
body_parts.append("- **Gemeldet von:** anonym")
|
||||
body_parts.append("")
|
||||
|
||||
if klicks:
|
||||
body_parts.append("## Letzte Klicks (Audit-Trail)")
|
||||
body_parts.append("```")
|
||||
for c in klicks:
|
||||
txt_part = f' "{c["txt"]}"' if c.get("txt") else ""
|
||||
body_parts.append(f'{c.get("t","")[-8:]} {c.get("el","")}{txt_part}')
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if errors:
|
||||
body_parts.append("## Console-Errors")
|
||||
body_parts.append("```")
|
||||
for err in errors:
|
||||
body_parts.append(f'{err.get("t","")[-8:]} {err.get("msg","")} @ {err.get("src","")}')
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if screenshot_error:
|
||||
body_parts.append(f"_Screenshot angefordert, aber fehlgeschlagen: `{screenshot_error[:200]}`_")
|
||||
body_parts.append("")
|
||||
|
||||
issue_body = "\n".join(body_parts)
|
||||
|
||||
if not settings.gitea_token:
|
||||
logger.warning("GITEA_TOKEN nicht gesetzt — Feedback-Issue kann nicht angelegt werden")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Feedback-Funktion ist derzeit nicht konfiguriert (kein Gitea-Token)."
|
||||
)
|
||||
|
||||
base_url = settings.gitea_api_url
|
||||
owner = settings.gitea_repo_owner
|
||||
repo = settings.gitea_repo_name
|
||||
token = settings.gitea_token
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as session:
|
||||
# Label sicherstellen
|
||||
label_id = await _gitea_ensure_label(session, base_url, owner, repo, token, "feedback")
|
||||
label_ids = [label_id] if label_id else []
|
||||
|
||||
# Issue anlegen
|
||||
payload = {
|
||||
"title": titel_clean,
|
||||
"body": issue_body,
|
||||
"label_ids": label_ids,
|
||||
}
|
||||
try:
|
||||
r = await session.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/issues",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
logger.exception("Gitea-Request fehlgeschlagen: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="Gitea nicht erreichbar")
|
||||
|
||||
if r.status_code not in (200, 201):
|
||||
logger.error("Gitea-Issue-Anlage fehlgeschlagen: %s %s", r.status_code, r.text[:500])
|
||||
raise HTTPException(status_code=502, detail=f"Gitea: {r.status_code}")
|
||||
|
||||
issue = r.json()
|
||||
issue_index = issue.get("number") or issue.get("id")
|
||||
issue_url = issue.get("html_url", "")
|
||||
|
||||
# Screenshot hochladen (optional)
|
||||
if screenshot and issue_index:
|
||||
att_url = await _gitea_upload_screenshot(
|
||||
session, base_url, owner, repo, token, issue_index, screenshot
|
||||
)
|
||||
if att_url:
|
||||
# Screenshot-Link als Kommentar anhängen
|
||||
comment_headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
try:
|
||||
await session.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/comments",
|
||||
headers=comment_headers,
|
||||
json={"body": f"**Screenshot:**\n\n"},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Screenshot-Kommentar fehlgeschlagen: %s", exc)
|
||||
|
||||
logger.info("Feedback-Issue #%s angelegt: %s", issue_index, issue_url)
|
||||
return JSONResponse({"issue_id": issue_index, "issue_url": issue_url})
|
||||
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
|
||||
@ -85,4 +85,6 @@ def build_pdf_href(zitat: dict, bundesland: str = "") -> str:
|
||||
|
||||
text = zitat.get("text", "")
|
||||
q = " ".join(text.split()[:5])
|
||||
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}"
|
||||
# #page=N als URL-Hash, damit der Browser-PDF-Viewer direkt zur Seite
|
||||
# springt — OpenAction im PDF wird von Chrome/Firefox ignoriert.
|
||||
return f"/api/wahlprogramm-cite?pid={pid}&seite={seite}&q={quote_plus(q)}#page={seite}"
|
||||
|
||||
1
app/static/v2/icons/phosphor/bug.svg
Normal file
1
app/static/v2/icons/phosphor/bug.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
|
||||
|
After Width: | Height: | Size: 674 B |
20
app/static/v2/lib/html2canvas.min.js
vendored
Normal file
20
app/static/v2/lib/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -66,14 +66,32 @@ body.v2 :focus-visible {
|
||||
grid-area: topbar;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding: 10px 24px;
|
||||
padding: 0 24px;
|
||||
height: 32px; /* harte Höhe statt min-height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--ecg-dark);
|
||||
}
|
||||
.v2-topbar > * {
|
||||
height: auto;
|
||||
max-height: 24px; /* nichts darin höher als 24 px */
|
||||
}
|
||||
.v2-topbar select,
|
||||
.v2-topbar button,
|
||||
.v2-topbar a {
|
||||
padding: 2px 6px;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.v2-topbar .v2-icon { width: 12px !important; height: 12px !important; }
|
||||
.v2-topbar .v2-icon svg { width: 12px; height: 12px; }
|
||||
|
||||
.v2-topbar-spacer {
|
||||
flex: 1;
|
||||
@ -881,8 +899,11 @@ body.v2 ul.v2-manual ul li::before {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Menu Toggle Button ─────────────────────────────────────────── */
|
||||
.v2-menu-toggle {
|
||||
/* ── Menu Toggle Button — nur auf Mobile sichtbar (< 900 px) ───────
|
||||
!important nötig wegen .v2-topbar button { display: inline-flex }
|
||||
(die generische Reset-Regel hat 2 Klassen Specificity vs hier 1). */
|
||||
.v2-topbar .v2-menu-toggle {
|
||||
display: none !important;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--hairline);
|
||||
@ -891,6 +912,9 @@ body.v2 ul.v2-manual ul li::before {
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.v2-topbar .v2-menu-toggle { display: inline-flex !important; }
|
||||
}
|
||||
|
||||
/* ── Fraktions-Score-Tabelle (Fix 2+3) ─────────────────────────── */
|
||||
.v2-fraktions-scores {
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
<link rel="alternate" type="application/atom+xml" title="GWÖ-Antragsprüfer — Neue Bewertungen" href="/api/feed.xml">
|
||||
|
||||
{# Design-System: Tokens zuerst, dann Fonts, dann Base-Styles #}
|
||||
<link rel="stylesheet" href="/static/v2/tokens.css">
|
||||
<link rel="stylesheet" href="/static/v2/fonts.css">
|
||||
<link rel="stylesheet" href="/static/v2/v2.css">
|
||||
<link rel="stylesheet" href="/static/v2/tokens.css?v={{ app_version|default('1') }}">
|
||||
<link rel="stylesheet" href="/static/v2/fonts.css?v={{ app_version|default('1') }}">
|
||||
<link rel="stylesheet" href="/static/v2/v2.css?v={{ app_version|default('1') }}">
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
@ -40,26 +40,29 @@
|
||||
<span class="v2-nav-count">{{ assessment_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>
|
||||
{% if is_authenticated %}<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>{% endif %}
|
||||
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
|
||||
<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>
|
||||
<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>
|
||||
{% if is_admin %}<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>{% endif %}
|
||||
{% if is_authenticated %}<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Prüfen</div>
|
||||
<a href="/v2/neu" class="v2-nav-item {% if v2_active_nav == 'neu' %}active{% endif %}">{{ icon("file-plus", 14) }} Neuer Antrag</a>
|
||||
<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>
|
||||
{% if is_admin %}<a href="/v2/batch" class="v2-nav-item {% if v2_active_nav == 'batch' %}active{% endif %}">{{ icon("stack", 14) }} Batch-Analyse</a>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Daten</div>
|
||||
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
|
||||
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
|
||||
<a href="/api/feed.xml" class="v2-nav-item">{{ icon("rss", 14) }} Atom-Feed</a>
|
||||
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a>
|
||||
<a href="/v2/abos" class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">{{ icon("envelope-simple", 14) }} Meine Abos</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin is defined and is_admin %}
|
||||
{% if is_admin %}
|
||||
<div class="v2-nav-group">
|
||||
<div class="v2-nav-label">— Administration</div>
|
||||
<a href="/v2/admin/freischaltungen" class="v2-nav-item">{{ icon("user-check", 14) }} Freischaltungen</a>
|
||||
@ -77,6 +80,15 @@
|
||||
<a href="/methodik">{{ icon("info", 13) }} Methodik</a>
|
||||
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>
|
||||
|
||||
{# ── Globaler Bundesland-Selector ─────────────────────────────────── #}
|
||||
<select id="v2-global-bl"
|
||||
onchange="v2SetGlobalBl(this.value)"
|
||||
aria-label="Bundesland wählen"
|
||||
style="font-family:var(--font-mono);font-size:11px;padding:3px 6px;border:1px solid var(--ecg-light, var(--ecg-border));background:var(--ecg-card-bg);color:var(--ecg-dark);text-transform:uppercase;border-radius:3px;cursor:pointer;">
|
||||
<option value="ALL">Bundesweit</option>
|
||||
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
{# ── Auth-Control — wird per JS nach /api/auth/me-Aufruf umgeschaltet ── #}
|
||||
<div id="v2-auth-control" style="display:inline-flex;align-items:center;">
|
||||
{# Platzhalter, bis initV2Auth() den Zustand kennt — unsichtbar #}
|
||||
@ -204,6 +216,27 @@
|
||||
|
||||
{% block body_scripts %}{% endblock %}
|
||||
|
||||
{# ── Globaler BL-Selector — Persistenz + Event ───────────────────────────── #}
|
||||
<script>
|
||||
(function () {
|
||||
var BL_KEY = 'gwoe.bl';
|
||||
|
||||
window.v2SetGlobalBl = function (code) {
|
||||
try { localStorage.setItem(BL_KEY, code); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('v2-bl-changed', { detail: { bl: code } }));
|
||||
};
|
||||
|
||||
window.v2GetGlobalBl = function () {
|
||||
try { return localStorage.getItem(BL_KEY) || 'ALL'; } catch (_) { return 'ALL'; }
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var sel = document.getElementById('v2-global-bl');
|
||||
if (sel) sel.value = window.v2GetGlobalBl();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ── Auth Modal (global, einmal pro Seite) ────────────────────────────── #}
|
||||
{% include "v2/components/auth_modal.html" %}
|
||||
|
||||
@ -278,5 +311,11 @@
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Feedback/Bug-Report-Widget — öffnet Gitea-Issues direkt aus dem Browser #}
|
||||
{% include "v2/components/feedback_widget.html" %}
|
||||
|
||||
{# Queue-Statusbar mit Hover-Tooltip — analog zu classic-UI (#149) #}
|
||||
{% include "v2/components/queue_widget.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -49,6 +49,10 @@
|
||||
style="padding:var(--space-3);background:var(--ecg-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:var(--font-sans);font-size:0.95rem;font-weight:700;letter-spacing:0.04em;">
|
||||
Anmelden
|
||||
</button>
|
||||
<a href="/api/auth/forgot-password" target="_blank" rel="noopener"
|
||||
style="font-family:var(--font-mono);font-size:0.78rem;color:var(--ecg-blue);text-align:right;text-decoration:none;border-bottom:1px solid rgba(0,157,165,0.35);align-self:flex-end;">
|
||||
Passwort vergessen?
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<!-- Register Form -->
|
||||
|
||||
386
app/templates/v2/components/feedback_widget.html
Normal file
386
app/templates/v2/components/feedback_widget.html
Normal file
@ -0,0 +1,386 @@
|
||||
{#
|
||||
feedback_widget.html — Feedback/Bug-Report-Widget mit Audit-Trail und Gitea-Anbindung.
|
||||
|
||||
Position: bottom:4rem, left:1rem — über dem Queue-Widget.
|
||||
Self-contained: Button + Modal + Audit-Trail-Sammler + Submit-Logic.
|
||||
Wird via {% include %} in base.html eingebunden.
|
||||
#}
|
||||
|
||||
{# ── Feedback-Button ──────────────────────────────────────────────────────── #}
|
||||
<button id="v2-feedback-btn"
|
||||
onclick="v2FeedbackOpen()"
|
||||
aria-label="Feedback oder Bug melden"
|
||||
title="Feedback / Bug melden"
|
||||
style="position:fixed;bottom:4rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.4rem 0.8rem;
|
||||
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:pointer;
|
||||
display:inline-flex;align-items:center;gap:5px;
|
||||
transition:all 0.2s;white-space:nowrap;">
|
||||
<span style="display:inline-flex;align-items:center;width:14px;height:14px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" width="14" height="14"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
|
||||
</span>
|
||||
Feedback
|
||||
</button>
|
||||
|
||||
{# ── Feedback-Modal ───────────────────────────────────────────────────────── #}
|
||||
<div id="v2-feedback-modal"
|
||||
role="dialog" aria-modal="true" aria-labelledby="v2-feedback-modal-title"
|
||||
style="display:none;position:fixed;inset:0;z-index:10000;
|
||||
background:rgba(0,0,0,0.45);
|
||||
align-items:center;justify-content:center;">
|
||||
|
||||
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:8px;padding:1.5rem;
|
||||
width:min(680px,96vw);max-height:90vh;overflow-y:auto;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,0.25);
|
||||
font-family:var(--font-sans);font-size:13px;color:var(--ecg-dark);
|
||||
position:relative;">
|
||||
|
||||
<button onclick="v2FeedbackClose()"
|
||||
aria-label="Schließen"
|
||||
style="position:absolute;top:0.75rem;right:0.75rem;
|
||||
background:none;border:none;cursor:pointer;
|
||||
font-size:16px;color:var(--ecg-text-muted);line-height:1;">✕</button>
|
||||
|
||||
<h2 id="v2-feedback-modal-title"
|
||||
style="margin:0 0 1rem;font-size:14px;font-weight:900;
|
||||
letter-spacing:0.04em;text-transform:uppercase;
|
||||
color:var(--ecg-blue);">Feedback / Bug melden</h2>
|
||||
|
||||
<form id="v2-feedback-form" onsubmit="v2FeedbackSubmit(event)">
|
||||
|
||||
{# ── User-Eingaben ────────────────────────────────────────────── #}
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label for="v2-fb-titel"
|
||||
style="display:block;margin-bottom:0.25rem;font-size:11px;
|
||||
font-family:var(--font-mono);text-transform:uppercase;
|
||||
letter-spacing:0.06em;color:var(--ecg-text-muted);">
|
||||
Titel <span style="color:var(--ecg-green);">*</span>
|
||||
</label>
|
||||
<input id="v2-fb-titel" type="text" required maxlength="200"
|
||||
placeholder="Kurze Zusammenfassung des Problems"
|
||||
style="width:100%;box-sizing:border-box;
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
padding:0.5rem 0.6rem;font-family:var(--font-sans);
|
||||
font-size:13px;background:var(--ecg-card-bg);
|
||||
color:var(--ecg-dark);">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:0.75rem;">
|
||||
<label for="v2-fb-beschreibung"
|
||||
style="display:block;margin-bottom:0.25rem;font-size:11px;
|
||||
font-family:var(--font-mono);text-transform:uppercase;
|
||||
letter-spacing:0.06em;color:var(--ecg-text-muted);">
|
||||
Beschreibung <span style="color:var(--ecg-green);">*</span>
|
||||
</label>
|
||||
<textarea id="v2-fb-beschreibung" required maxlength="5000" rows="5"
|
||||
placeholder="Was ist passiert? Was hast du erwartet?"
|
||||
style="width:100%;box-sizing:border-box;
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
padding:0.5rem 0.6rem;font-family:var(--font-sans);
|
||||
font-size:13px;background:var(--ecg-card-bg);
|
||||
color:var(--ecg-dark);resize:vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
{# ── Screenshot-Checkbox ──────────────────────────────────────── #}
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;">
|
||||
<input id="v2-fb-screenshot" type="checkbox">
|
||||
<label for="v2-fb-screenshot" style="font-size:12px;cursor:pointer;">
|
||||
Screenshot anhängen (aktueller Seitenausschnitt)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# ── Audit-Trail-Vorschau ─────────────────────────────────────── #}
|
||||
<details style="margin-bottom:1rem;">
|
||||
<summary style="cursor:pointer;font-size:11px;font-family:var(--font-mono);
|
||||
text-transform:uppercase;letter-spacing:0.06em;
|
||||
color:var(--ecg-text-muted);">
|
||||
Mitgesendeter Kontext (Audit-Trail) ▾
|
||||
</summary>
|
||||
<div id="v2-fb-audit-preview"
|
||||
style="margin-top:0.5rem;padding:0.75rem;
|
||||
background:var(--ecg-bg, #f8f8f5);
|
||||
border:1px solid var(--ecg-light);border-radius:4px;
|
||||
font-family:var(--font-mono);font-size:10px;
|
||||
color:var(--ecg-text-muted);
|
||||
white-space:pre-wrap;max-height:200px;overflow-y:auto;
|
||||
word-break:break-all;"></div>
|
||||
</details>
|
||||
|
||||
{# ── Status-Anzeige ───────────────────────────────────────────── #}
|
||||
<div id="v2-fb-status" style="display:none;margin-bottom:0.75rem;
|
||||
padding:0.5rem 0.75rem;border-radius:4px;
|
||||
font-size:12px;"></div>
|
||||
|
||||
{# ── Buttons ──────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
|
||||
<button type="button" onclick="v2FeedbackClose()"
|
||||
style="background:none;border:1px solid var(--ecg-light);
|
||||
border-radius:4px;padding:0.5rem 1rem;cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:13px;
|
||||
color:var(--ecg-dark);">Abbrechen</button>
|
||||
<button type="submit" id="v2-fb-submit-btn"
|
||||
style="background:var(--ecg-blue,#1a6fa8);border:none;
|
||||
border-radius:4px;padding:0.5rem 1.25rem;cursor:pointer;
|
||||
font-family:var(--font-sans);font-size:13px;
|
||||
color:#fff;font-weight:600;">Absenden</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Audit-Trail-Sammler + Modal-Logik ────────────────────────────────────── #}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Ringbuffer-Helper ─────────────────────────────────────── */
|
||||
var AUDIT_KEY = 'gwoe.audit';
|
||||
var ERRORS_KEY = 'gwoe.errors';
|
||||
var MAX_CLICKS = 30;
|
||||
var MAX_ERRORS = 10;
|
||||
|
||||
function ringPush(key, item, max) {
|
||||
var arr = [];
|
||||
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) {}
|
||||
arr.push(item);
|
||||
if (arr.length > max) arr = arr.slice(arr.length - max);
|
||||
try { localStorage.setItem(key, JSON.stringify(arr)); } catch (_) {}
|
||||
}
|
||||
|
||||
function ringRead(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) { return []; }
|
||||
}
|
||||
|
||||
/* ── CSS-Pfad (kurz) ────────────────────────────────────────── */
|
||||
function cssPath(el) {
|
||||
if (!el || el === document.body) return 'body';
|
||||
var path = [];
|
||||
var cur = el;
|
||||
while (cur && cur !== document.body && path.length < 4) {
|
||||
var tag = cur.tagName ? cur.tagName.toLowerCase() : '';
|
||||
var id = cur.id ? '#' + cur.id : '';
|
||||
var cls = cur.className && typeof cur.className === 'string'
|
||||
? ('.' + cur.className.trim().split(/\s+/).slice(0,2).join('.'))
|
||||
: '';
|
||||
path.unshift(tag + id + cls);
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
/* ── Click-Listener ─────────────────────────────────────────── */
|
||||
document.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (!target) return;
|
||||
// Feedback-Modal-Klicks nicht tracken
|
||||
if (target.closest && target.closest('#v2-feedback-modal')) return;
|
||||
var text = (target.textContent || target.value || target.alt || '')
|
||||
.trim().slice(0, 60).replace(/\s+/g, ' ');
|
||||
ringPush(AUDIT_KEY, {
|
||||
t: new Date().toISOString(),
|
||||
el: cssPath(target),
|
||||
txt: text || null
|
||||
}, MAX_CLICKS);
|
||||
}, true);
|
||||
|
||||
/* ── Error-Listener ─────────────────────────────────────────── */
|
||||
window.addEventListener('error', function (e) {
|
||||
ringPush(ERRORS_KEY, {
|
||||
t: new Date().toISOString(),
|
||||
msg: e.message || String(e),
|
||||
src: (e.filename || '').replace(window.location.origin, '') + ':' + e.lineno
|
||||
}, MAX_ERRORS);
|
||||
});
|
||||
|
||||
/* ── Modal öffnen/schließen ─────────────────────────────────── */
|
||||
window.v2FeedbackOpen = function () {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (!modal) return;
|
||||
|
||||
// Audit-Vorschau befüllen
|
||||
var preview = document.getElementById('v2-fb-audit-preview');
|
||||
if (preview) {
|
||||
var clicks = ringRead(AUDIT_KEY).slice(-15);
|
||||
var errors = ringRead(ERRORS_KEY).slice(-10);
|
||||
var lines = [];
|
||||
lines.push('URL: ' + window.location.href);
|
||||
lines.push('User-Agent: ' + navigator.userAgent.slice(0, 120));
|
||||
lines.push('Viewport: ' + window.innerWidth + 'x' + window.innerHeight);
|
||||
// Drucksache aus URL extrahieren
|
||||
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
|
||||
if (dsMatch) lines.push('Drucksache: ' + decodeURIComponent(dsMatch[1]));
|
||||
lines.push('');
|
||||
if (clicks.length) {
|
||||
lines.push('Letzte Klicks:');
|
||||
clicks.forEach(function (c) {
|
||||
lines.push(' ' + c.t.slice(11,19) + ' ' + c.el + (c.txt ? ' "' + c.txt + '"' : ''));
|
||||
});
|
||||
}
|
||||
if (errors.length) {
|
||||
lines.push('');
|
||||
lines.push('Console-Errors:');
|
||||
errors.forEach(function (e) {
|
||||
lines.push(' ' + e.t.slice(11,19) + ' ' + e.msg + ' @ ' + e.src);
|
||||
});
|
||||
}
|
||||
preview.textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
// Status zurücksetzen
|
||||
var status = document.getElementById('v2-fb-status');
|
||||
if (status) { status.style.display = 'none'; status.textContent = ''; }
|
||||
var submitBtn = document.getElementById('v2-fb-submit-btn');
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Absenden'; }
|
||||
|
||||
modal.style.display = 'flex';
|
||||
var titel = document.getElementById('v2-fb-titel');
|
||||
if (titel) setTimeout(function () { titel.focus(); }, 50);
|
||||
};
|
||||
|
||||
window.v2FeedbackClose = function () {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
};
|
||||
|
||||
// Schließen bei Klick auf Backdrop
|
||||
document.getElementById('v2-feedback-modal').addEventListener('click', function (e) {
|
||||
if (e.target === this) window.v2FeedbackClose();
|
||||
});
|
||||
|
||||
// Escape-Taste
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
if (modal && modal.style.display === 'flex') window.v2FeedbackClose();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Submit ─────────────────────────────────────────────────── */
|
||||
window.v2FeedbackSubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var titel = document.getElementById('v2-fb-titel').value.trim();
|
||||
var beschreibung = document.getElementById('v2-fb-beschreibung').value.trim();
|
||||
var screenshot = document.getElementById('v2-fb-screenshot').checked;
|
||||
var submitBtn = document.getElementById('v2-fb-submit-btn');
|
||||
var statusEl = document.getElementById('v2-fb-status');
|
||||
|
||||
function setStatus(msg, ok) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.style.background = ok ? 'rgba(0,128,64,0.1)' : 'rgba(200,0,0,0.08)';
|
||||
statusEl.style.border = '1px solid ' + (ok ? 'rgba(0,128,64,0.3)' : 'rgba(200,0,0,0.2)');
|
||||
statusEl.style.color = ok ? 'var(--ecg-green)' : '#c00';
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gesendet…';
|
||||
|
||||
// Audit-Daten sammeln
|
||||
var clicks = ringRead(AUDIT_KEY).slice(-15);
|
||||
var errors = ringRead(ERRORS_KEY).slice(-10);
|
||||
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('titel', titel);
|
||||
fd.append('beschreibung', beschreibung);
|
||||
fd.append('url', window.location.href);
|
||||
fd.append('user_agent', navigator.userAgent);
|
||||
fd.append('viewport', window.innerWidth + 'x' + window.innerHeight);
|
||||
fd.append('drucksache', dsMatch ? decodeURIComponent(dsMatch[1]) : '');
|
||||
fd.append('klicks_json', JSON.stringify(clicks));
|
||||
fd.append('errors_json', JSON.stringify(errors));
|
||||
|
||||
// Screenshot (optional, via html2canvas)
|
||||
if (screenshot && window.html2canvas) {
|
||||
submitBtn.textContent = 'Screenshot wird erstellt…';
|
||||
// Modal + Overlay verstecken, damit der Screenshot die Seite ohne
|
||||
// Feedback-UI zeigt. Nach dem Capture wieder einblenden.
|
||||
var modal = document.getElementById('v2-feedback-modal');
|
||||
var overlay = document.getElementById('v2-feedback-overlay');
|
||||
var fbBtn = document.getElementById('v2-feedback-btn');
|
||||
var prev = {
|
||||
modalDisp: modal ? modal.style.display : null,
|
||||
overlayDisp: overlay ? overlay.style.display : null,
|
||||
btnDisp: fbBtn ? fbBtn.style.display : null,
|
||||
};
|
||||
if (modal) modal.style.display = 'none';
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
if (fbBtn) fbBtn.style.display = 'none';
|
||||
// ein Frame warten, damit die Browser den Reflow rendert
|
||||
await new Promise(function (r) { requestAnimationFrame(function(){ requestAnimationFrame(r); }); });
|
||||
try {
|
||||
var canvas = await window.html2canvas(document.body, {
|
||||
scale: window.devicePixelRatio || 2, // Hi-DPI: scharfes Bild
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
logging: false,
|
||||
backgroundColor: getComputedStyle(document.body).backgroundColor || '#fff',
|
||||
// Sichtbares Viewport, nicht das ganze Dokument
|
||||
width: document.documentElement.clientWidth,
|
||||
height: document.documentElement.clientHeight,
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
windowWidth: document.documentElement.clientWidth,
|
||||
windowHeight: document.documentElement.clientHeight,
|
||||
});
|
||||
// Breite begrenzen — bei Hi-DPI Display kann canvas.width 4000+ sein.
|
||||
// Cap bei 1600 logischen px (à la Retina-friendly), JPEG quality 0.85.
|
||||
var MAX_W = 1600;
|
||||
var finalCanvas = canvas;
|
||||
if (canvas.width > MAX_W) {
|
||||
var ratio = MAX_W / canvas.width;
|
||||
var sc = document.createElement('canvas');
|
||||
sc.width = MAX_W;
|
||||
sc.height = Math.round(canvas.height * ratio);
|
||||
var ctx = sc.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(canvas, 0, 0, sc.width, sc.height);
|
||||
finalCanvas = sc;
|
||||
}
|
||||
var dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85);
|
||||
fd.append('screenshot', dataUrl);
|
||||
} catch (err) {
|
||||
fd.append('screenshot_error', String(err));
|
||||
} finally {
|
||||
// UI zurückbringen
|
||||
if (modal) modal.style.display = prev.modalDisp || '';
|
||||
if (overlay) overlay.style.display = prev.overlayDisp || '';
|
||||
if (fbBtn) fbBtn.style.display = prev.btnDisp || '';
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.textContent = 'Wird gesendet…';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/feedback', { method: 'POST', body: fd });
|
||||
var data = await resp.json();
|
||||
if (resp.ok && data.issue_url) {
|
||||
setStatus('Danke! Issue angelegt: ' + data.issue_url, true);
|
||||
submitBtn.textContent = 'Abgeschlossen';
|
||||
// Felder leeren
|
||||
document.getElementById('v2-fb-titel').value = '';
|
||||
document.getElementById('v2-fb-beschreibung').value = '';
|
||||
document.getElementById('v2-fb-screenshot').checked = false;
|
||||
setTimeout(window.v2FeedbackClose, 3000);
|
||||
} else {
|
||||
setStatus('Fehler: ' + (data.detail || JSON.stringify(data)), false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Absenden';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Netzwerkfehler: ' + err.message, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Absenden';
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# html2canvas — self-hosted, kein CDN-Call #}
|
||||
<script src="/static/v2/lib/html2canvas.min.js"></script>
|
||||
93
app/templates/v2/components/queue_widget.html
Normal file
93
app/templates/v2/components/queue_widget.html
Normal file
@ -0,0 +1,93 @@
|
||||
{#
|
||||
queue_widget.html — Queue-Statusbar mit Hover-Tooltip (#149).
|
||||
|
||||
Wird am Ende von base.html eingebunden via {% include %}. Self-contained:
|
||||
Eigenes <div id="v2-queue-statusbar"> + <div id="v2-queue-tooltip"> +
|
||||
Polling-Script. Pollt alle 5 s `/api/queue/status` und blendet sich aus,
|
||||
wenn keine Jobs aktiv/fertig/fehlgeschlagen sind.
|
||||
|
||||
Portiert aus classic-UI (#99). Nutzt v2-Tokens statt classic-Variablen.
|
||||
#}
|
||||
|
||||
<div id="v2-queue-statusbar"
|
||||
style="position:fixed;bottom:1rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.4rem 0.8rem;
|
||||
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:default;
|
||||
transition:all 0.2s;"
|
||||
onmouseenter="document.getElementById('v2-queue-tooltip').style.display='block'"
|
||||
onmouseleave="document.getElementById('v2-queue-tooltip').style.display='none'"
|
||||
aria-label="Analyse-Queue Status">
|
||||
<span id="v2-queue-status-text"></span>
|
||||
</div>
|
||||
|
||||
<div id="v2-queue-tooltip"
|
||||
style="display:none;position:fixed;bottom:3.5rem;left:1rem;
|
||||
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
|
||||
border-radius:6px;padding:0.8rem 1rem;
|
||||
font-family:var(--font-sans);font-size:12px;color:var(--ecg-dark);
|
||||
box-shadow:0 4px 16px rgba(0,0,0,0.15);z-index:101;
|
||||
max-width:420px;max-height:320px;overflow-y:auto;">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function poll() {
|
||||
fetch('/api/queue/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (qs) {
|
||||
var allJobs = qs.jobs || [];
|
||||
var jobs = allJobs.filter(function (j) { return j.status !== 'stale'; });
|
||||
var processing = jobs.filter(function (j) { return j.status === 'processing'; }).length;
|
||||
var queued = jobs.filter(function (j) { return j.status === 'queued' || j.status === 'pending'; }).length;
|
||||
var completed = jobs.filter(function (j) { return j.status === 'completed'; }).length;
|
||||
var failed = jobs.filter(function (j) { return j.status === 'failed'; }).length;
|
||||
|
||||
var bar = document.getElementById('v2-queue-statusbar');
|
||||
var text = document.getElementById('v2-queue-status-text');
|
||||
if (!bar || !text) return;
|
||||
|
||||
var workers = qs.workers_running != null ? qs.workers_running : '?';
|
||||
var parts = [];
|
||||
if (processing > 0) parts.push('⏳ ' + processing + ' in Bearbeitung');
|
||||
if (queued > 0) parts.push('⏸ ' + queued + ' wartend');
|
||||
if (completed > 0) parts.push('✓ ' + completed + ' fertig');
|
||||
if (failed > 0) parts.push('✗ ' + failed + ' fehlgeschlagen');
|
||||
if (parts.length === 0) {
|
||||
parts.push('Queue leer · ' + workers + ' Worker bereit');
|
||||
}
|
||||
text.textContent = parts.join(' · ');
|
||||
|
||||
var tip = document.getElementById('v2-queue-tooltip');
|
||||
if (!tip) return;
|
||||
// Tooltip zeigt bevorzugt aktive Jobs, Stale als „letzter Lauf"-Block.
|
||||
var displayJobs = jobs.length ? jobs : allJobs;
|
||||
var rows = displayJobs.slice(0, 20).map(function (j) {
|
||||
var icon = j.status === 'completed' ? '✓'
|
||||
: j.status === 'processing' ? '⏳'
|
||||
: j.status === 'failed' ? '✗'
|
||||
: '⏸';
|
||||
var dur = j.duration ? (' · ' + j.duration + 's') : '';
|
||||
var bl = j.bundesland ? (' · ' + j.bundesland) : '';
|
||||
var ds = j.drucksache || '?';
|
||||
var dsLink = j.status === 'completed' && j.drucksache
|
||||
? '<a href="/antrag/' + encodeURIComponent(j.drucksache) + '" style="color:var(--ecg-blue);">' + ds + '</a>'
|
||||
: ds;
|
||||
return '<div style="padding:0.25rem 0;border-bottom:1px solid var(--ecg-light);">'
|
||||
+ '<span style="font-family:var(--font-mono);">' + icon + '</span> '
|
||||
+ dsLink
|
||||
+ '<span style="font-family:var(--font-mono);color:var(--ecg-text-muted);font-size:0.85em;">' + bl + dur + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
tip.innerHTML = '<div style="margin-bottom:0.5rem;font-weight:900;font-size:11px;letter-spacing:0.04em;text-transform:uppercase;color:var(--ecg-blue);">Queue · '
|
||||
+ workers + ' Worker</div>'
|
||||
+ (rows || '<div style="color:var(--ecg-text-muted);">leer</div>');
|
||||
})
|
||||
.catch(function () { /* still */ });
|
||||
}
|
||||
// erster Aufruf direkt + danach alle 5 s
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>
|
||||
@ -27,7 +27,9 @@
|
||||
{% endif %}
|
||||
{{ source }}
|
||||
{% if pdf_href %}
|
||||
· <a href="{{ pdf_href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
|
||||
{# Falls pdf_href noch keinen #page=…-Anker hat, aus seite= im Query-String einen anhaengen — Browser-PDF-Viewer ignorieren PDF-OpenAction, der Hash-Anker funktioniert zuverlaessig. #}
|
||||
{% set _href = pdf_href if '#page=' in pdf_href else (pdf_href ~ ('#page=' ~ (pdf_href.split('seite=')[1].split('&')[0] if 'seite=' in pdf_href else '1'))) %}
|
||||
· <a href="{{ _href }}" target="_blank" rel="noopener" style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">PDF öffnen</a>
|
||||
{% endif %}
|
||||
</cite>
|
||||
</div>
|
||||
|
||||
166
app/templates/v2/screens/abos.html
Normal file
166
app/templates/v2/screens/abos.html
Normal file
@ -0,0 +1,166 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Meine Abos — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "abos" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.abo-form {
|
||||
display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;
|
||||
margin-bottom: 24px; max-width: 760px;
|
||||
padding: 12px 14px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-bg-subtle);
|
||||
}
|
||||
.abo-form label {
|
||||
display: block; font-family: var(--font-mono); font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.abo-form select, .abo-form input[type="text"] {
|
||||
font-family: var(--font-mono); font-size: 13px;
|
||||
padding: 6px 8px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
|
||||
}
|
||||
.abo-form .submit {
|
||||
font-family: var(--font-display); font-size: 12px; font-weight: 700;
|
||||
padding: 7px 14px; background: var(--ecg-teal); color: #fff;
|
||||
border: none; border-radius: 4px; cursor: pointer; letter-spacing: 0.04em;
|
||||
}
|
||||
.abo-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--ecg-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.abo-row:last-child { border-bottom: none; }
|
||||
.abo-tag {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
padding: 3px 7px; border: 1px solid var(--ecg-border); border-radius: 3px;
|
||||
background: var(--ecg-card-bg);
|
||||
}
|
||||
.abo-del {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
padding: 4px 10px; background: none; border: 1px solid var(--ecg-border);
|
||||
border-radius: 3px; cursor: pointer; color: var(--redline-contra, #c00);
|
||||
}
|
||||
.abo-del:hover { background: var(--redline-contra-bg, #f9e6e6); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Meine E-Mail-Abos</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Tägliche Zusammenfassung neuer Bewertungen — gefiltert nach Bundesland und/oder Partei.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="abo-form" onsubmit="aboCreate(event)">
|
||||
<div>
|
||||
<label for="abo-bl">Bundesland</label>
|
||||
<select id="abo-bl">
|
||||
<option value="">— alle —</option>
|
||||
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="abo-partei">Partei</label>
|
||||
<select id="abo-partei">
|
||||
<option value="">— alle —</option>
|
||||
{% for p in parteien %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="abo-freq">Frequenz</label>
|
||||
<select id="abo-freq">
|
||||
<option value="daily">täglich</option>
|
||||
<option value="weekly">wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="submit">Abo anlegen</button>
|
||||
</form>
|
||||
|
||||
<div id="abo-status" style="margin-bottom:8px;font-family:var(--font-mono);font-size:12px;opacity:0.7;"></div>
|
||||
<div id="abo-list">Lade …</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
async function aboLoad() {
|
||||
var listEl = document.getElementById('abo-list');
|
||||
try {
|
||||
var r = await fetch('/api/subscriptions');
|
||||
if (r.status === 401) {
|
||||
listEl.innerHTML = '<p style="color:var(--ecg-dark);opacity:0.7;">Bitte erst anmelden.</p>';
|
||||
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
|
||||
return;
|
||||
}
|
||||
var subs = await r.json();
|
||||
if (!subs || !subs.length) {
|
||||
listEl.innerHTML = '<p style="opacity:0.6;font-style:italic;">Du hast noch keine Abos. Lege oben eines an.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = subs.map(function(s) {
|
||||
var bl = s.bundesland || '—';
|
||||
var p = s.partei || '—';
|
||||
var f = s.frequency || 'daily';
|
||||
var ls = s.last_sent ? ('zuletzt: ' + s.last_sent.substring(0,10)) : 'noch nie versandt';
|
||||
return '<div class="abo-row">'
|
||||
+ '<span class="abo-tag">BL ' + escHtml(bl) + '</span>'
|
||||
+ '<span class="abo-tag">Partei ' + escHtml(p) + '</span>'
|
||||
+ '<span class="abo-tag">' + escHtml(f) + '</span>'
|
||||
+ '<span style="flex:1;opacity:0.6;font-family:var(--font-mono);font-size:11px;">' + escHtml(ls) + '</span>'
|
||||
+ '<button class="abo-del" onclick="aboDelete(' + s.id + ')">✕ Löschen</button>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<p style="color:#c00;">Fehler: ' + escHtml(e.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function aboCreate(e) {
|
||||
e.preventDefault();
|
||||
var bl = document.getElementById('abo-bl').value;
|
||||
var part = document.getElementById('abo-partei').value.trim();
|
||||
var freq = document.getElementById('abo-freq').value;
|
||||
var fd = new FormData();
|
||||
if (bl) fd.append('bundesland', bl);
|
||||
if (part) fd.append('partei', part);
|
||||
fd.append('frequency', freq);
|
||||
var r = await fetch('/api/subscriptions', { method: 'POST', body: fd });
|
||||
if (r.status === 401) {
|
||||
if (window.v2AuthModalOpen) window.v2AuthModalOpen();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
var err = await r.json().catch(()=>({detail:'Fehler'}));
|
||||
setStatus('Fehler: ' + (err.detail || r.status), true);
|
||||
return;
|
||||
}
|
||||
setStatus('Abo angelegt.');
|
||||
document.getElementById('abo-partei').value = '';
|
||||
aboLoad();
|
||||
}
|
||||
|
||||
async function aboDelete(id) {
|
||||
if (!confirm('Abo wirklich löschen?')) return;
|
||||
var r = await fetch('/api/subscriptions/' + id, { method: 'DELETE' });
|
||||
if (r.ok) { setStatus('Abo gelöscht.'); aboLoad(); }
|
||||
else { setStatus('Löschen fehlgeschlagen.', true); }
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
var el = document.getElementById('abo-status');
|
||||
el.textContent = msg;
|
||||
el.style.color = isErr ? '#c00' : 'var(--ecg-teal)';
|
||||
setTimeout(function(){ el.textContent=''; }, 4000);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', aboLoad);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -342,22 +342,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Share-Block ──────────────────────────────────────────────── #}
|
||||
{# ── Share-Block (analog v1) ───────────────────────────────────── #}
|
||||
<div style="margin-top:20px;">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button onclick="v2DetailShareCopy()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
📋 Kopieren
|
||||
</button>
|
||||
<button onclick="v2DetailShare('threads')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
Threads
|
||||
</button>
|
||||
<button onclick="v2DetailShare('twitter')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
X
|
||||
𝕏
|
||||
</button>
|
||||
<button onclick="v2DetailShareMastodon()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
Mastodon
|
||||
</button>
|
||||
<button onclick="v2DetailShare('linkedin')"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
LinkedIn
|
||||
</button>
|
||||
<button onclick="v2DetailShareEmail()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
📧 E-Mail
|
||||
</button>
|
||||
<button onclick="v2DetailShareImage()"
|
||||
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
|
||||
🖼 Bild
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -490,6 +506,7 @@ window.v2ShowMatrixFieldInfo = function(field) {
|
||||
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
|
||||
var TITLE = {{ antrag.title | tojson }};
|
||||
var SCORE = {{ antrag.score | tojson }};
|
||||
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
|
||||
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
|
||||
|
||||
var currentUser = null;
|
||||
@ -633,11 +650,41 @@ window.v2ShowMatrixFieldInfo = function(field) {
|
||||
var text = buildShareText(platform) + '\n' + PERMALINK;
|
||||
var urls = {
|
||||
twitter: 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(text),
|
||||
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text)
|
||||
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text),
|
||||
linkedin: 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(PERMALINK)
|
||||
};
|
||||
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
|
||||
};
|
||||
|
||||
window.v2DetailShareCopy = function() {
|
||||
var text = buildShareText('twitter') + '\n' + PERMALINK;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// kleiner visueller Hinweis: Button-Text temporär
|
||||
var btn = event && event.currentTarget;
|
||||
if (btn) {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '✓ kopiert';
|
||||
setTimeout(function(){ btn.textContent = orig; }, 1500);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
prompt('Zum Kopieren markieren und Cmd/Strg-C drücken:', text);
|
||||
}
|
||||
};
|
||||
|
||||
window.v2DetailShareEmail = function() {
|
||||
var subject = 'GWÖ-Bewertung: ' + (TITLE.substring(0, 60));
|
||||
var body = (SHARE_THR || buildShareText('threads')) + '\n\n' + PERMALINK;
|
||||
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
|
||||
};
|
||||
|
||||
window.v2DetailShareImage = function() {
|
||||
var topics = (window.ANTRAG_TOPICS || []).slice(0, 2).join(' ');
|
||||
var query = (topics || TITLE.substring(0, 40)) + ' Politik';
|
||||
window.open('https://www.freepik.com/search?format=search&query=' + encodeURIComponent(query), '_blank', 'noopener');
|
||||
};
|
||||
|
||||
window.v2DetailShareMastodon = function() {
|
||||
var text = buildShareText('mastodon') + '\n' + PERMALINK;
|
||||
var instance = localStorage.getItem('mastodon_instance');
|
||||
|
||||
@ -181,13 +181,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
<option value="{{ wp }}">{{ wp }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="bl-filter">Bundesland:</label>
|
||||
<select id="bl-filter">
|
||||
<option value="">Alle</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="primary" onclick="loadBlMatrix()">Laden</button>
|
||||
<button onclick="window.location.href='/api/auswertungen/export.csv'">CSV-Export</button>
|
||||
</div>
|
||||
@ -200,14 +193,6 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
|
||||
<!-- Panel 2: Thema × Fraktion -->
|
||||
<div class="auswert-panel" id="panel-themen">
|
||||
<div class="controls-bar">
|
||||
<label for="themen-bl-filter">Bundesland:</label>
|
||||
<select id="themen-bl-filter" onchange="loadThemenMatrix()">
|
||||
<option value="">Alle</option>
|
||||
{% for code in bl_codes %}
|
||||
<option value="{{ code }}">{{ code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="themen-matrix-wrap" class="matrix-wrap">
|
||||
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Wählen Sie den Tab „Thema × Fraktion".</div>
|
||||
</div>
|
||||
@ -258,6 +243,16 @@ function switchTab(id, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bei BL-Wechsel aktive Panels neu laden
|
||||
window.addEventListener('v2-bl-changed', function () {
|
||||
var activePanel = document.querySelector('.auswert-panel.active');
|
||||
if (!activePanel) return;
|
||||
if (activePanel.id === 'panel-bl-partei') loadBlMatrix();
|
||||
if (activePanel.id === 'panel-themen') loadThemenMatrix();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () { loadBlMatrix(); });
|
||||
|
||||
function scoreClass(avg) {
|
||||
if (avg == null) return '';
|
||||
if (avg >= 6) return 's-high';
|
||||
@ -269,7 +264,8 @@ async function loadBlMatrix() {
|
||||
const wrap = document.getElementById('bl-matrix-wrap');
|
||||
const metaEl = document.getElementById('bl-matrix-meta');
|
||||
const wp = document.getElementById('wp-filter').value;
|
||||
const bl = document.getElementById('bl-filter').value;
|
||||
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
const bl = (blRaw === 'ALL') ? '' : blRaw;
|
||||
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Matrix …</div>';
|
||||
metaEl.textContent = '';
|
||||
@ -317,7 +313,8 @@ async function loadBlMatrix() {
|
||||
|
||||
async function loadThemenMatrix() {
|
||||
const wrap = document.getElementById('themen-matrix-wrap');
|
||||
const bl = document.getElementById('themen-bl-filter').value;
|
||||
const blRaw = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
const bl = (blRaw === 'ALL') ? '' : blRaw;
|
||||
wrap.innerHTML = '<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade Themen-Matrix …</div>';
|
||||
|
||||
let url = '/api/auswertungen/themen-matrix';
|
||||
|
||||
@ -9,13 +9,9 @@
|
||||
|
||||
{% block main %}
|
||||
|
||||
{# ── Toolbar: Bundesland-Filter + Suche ─────────────────────────── #}
|
||||
{# ── Toolbar: Suche ──────────────────────────────────────────────── #}
|
||||
{# BL-Filter läuft jetzt über den globalen Selector in der Topbar. #}
|
||||
<div class="v2-toolbar" id="v2-toolbar" role="toolbar" aria-label="Filter und Suche">
|
||||
<button class="v2-chip active" data-bl="ALL" onclick="v2SetBl(this,'ALL')">Bundesweit</button>
|
||||
{% for code in bl_codes %}
|
||||
<button class="v2-chip" data-bl="{{ code }}" onclick="v2SetBl(this,'{{ code }}')">{{ code }}</button>
|
||||
{% endfor %}
|
||||
<span class="v2-toolbar-sep"></span>
|
||||
<input class="v2-search"
|
||||
type="search"
|
||||
placeholder="Anträge durchsuchen …"
|
||||
@ -122,13 +118,11 @@
|
||||
if (empty) empty.style.display = (visible === 0) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
window.v2SetBl = function (btn, code) {
|
||||
activeBl = code;
|
||||
document.querySelectorAll('[data-bl]').forEach(function (b) {
|
||||
b.classList.toggle('active', b.dataset.bl === code);
|
||||
});
|
||||
/* BL-Filter: globaler Selector in der Topbar */
|
||||
window.addEventListener('v2-bl-changed', function (e) {
|
||||
activeBl = (e.detail && e.detail.bl) ? e.detail.bl : 'ALL';
|
||||
applyFilters();
|
||||
};
|
||||
});
|
||||
|
||||
window.v2SetBand = function (btn, band) {
|
||||
activeBand = band;
|
||||
@ -140,13 +134,19 @@
|
||||
|
||||
window.v2ResetFilters = function () {
|
||||
document.getElementById('v2-search-input').value = '';
|
||||
v2SetBl(null, 'ALL');
|
||||
// BL auf ALL zurücksetzen: globalen Selector aktualisieren
|
||||
var sel = document.getElementById('v2-global-bl');
|
||||
if (sel) sel.value = 'ALL';
|
||||
window.v2SetGlobalBl && window.v2SetGlobalBl('ALL');
|
||||
v2SetBand(null, 'ALL');
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.getElementById('v2-search-input');
|
||||
if (input) input.addEventListener('input', applyFilters);
|
||||
// Gespeicherten BL-Wert beim Laden anwenden
|
||||
activeBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
applyFilters();
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
125
app/templates/v2/screens/feed.html
Normal file
125
app/templates/v2/screens/feed.html
Normal file
@ -0,0 +1,125 @@
|
||||
{% extends "v2/base.html" %}
|
||||
|
||||
{% block title %}Atom-Feed — GWÖ-Antragsprüfer{% endblock %}
|
||||
|
||||
{% set v2_active_nav = "feed" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.feed-form {
|
||||
display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px;
|
||||
align-items: center; margin-bottom: 24px; max-width: 560px;
|
||||
padding: 14px; border: 1px solid var(--ecg-border); border-radius: 4px;
|
||||
background: var(--ecg-bg-subtle);
|
||||
}
|
||||
.feed-form label { font-family: var(--font-mono); font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.7; }
|
||||
.feed-form select, .feed-form input {
|
||||
font-family: var(--font-mono); font-size: 13px;
|
||||
padding: 6px 8px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-card-bg); color: var(--ecg-dark);
|
||||
}
|
||||
.feed-url-box {
|
||||
margin-top: 16px; padding: 14px; border: 1px solid var(--ecg-border);
|
||||
border-radius: 4px; background: var(--ecg-card-bg);
|
||||
}
|
||||
.feed-url {
|
||||
font-family: var(--font-mono); font-size: 12px; padding: 8px 10px;
|
||||
border: 1px solid var(--ecg-border); border-radius: 3px; word-break: break-all;
|
||||
background: var(--paper); color: var(--ecg-dark); display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.feed-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.feed-btn {
|
||||
font-family: var(--font-mono); font-size: 12px; padding: 6px 14px;
|
||||
background: var(--ecg-teal); color: #fff; border: none; border-radius: 3px;
|
||||
cursor: pointer; text-decoration: none; display: inline-flex;
|
||||
align-items: center; gap: 6px;
|
||||
}
|
||||
.feed-btn.secondary { background: none; color: var(--ecg-dark); border: 1px solid var(--ecg-border); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div style="padding:0 0 1rem;">
|
||||
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">Atom-Feed</h1>
|
||||
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
|
||||
Konfigurierbarer Feed der neuesten Bewertungen — abonnierbar mit jedem RSS/Atom-Reader.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="feed-form" onsubmit="event.preventDefault();feedUpdate();">
|
||||
<label for="feed-bl">Bundesland</label>
|
||||
<select id="feed-bl" onchange="feedUpdate()">
|
||||
<option value="">— alle —</option>
|
||||
{% for bl in v2_bundeslaender %}<option value="{{ bl.code }}">{{ bl.code }} — {{ bl.name }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="feed-partei">Partei</label>
|
||||
<select id="feed-partei" onchange="feedUpdate()">
|
||||
<option value="">— alle —</option>
|
||||
{% for p in parteien %}<option value="{{ p }}">{{ p }}</option>{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="feed-limit">Anzahl</label>
|
||||
<input type="number" id="feed-limit" min="1" max="200" value="50" oninput="feedUpdate()">
|
||||
</form>
|
||||
|
||||
<div class="feed-url-box">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.07em;opacity:0.7;">Feed-URL</div>
|
||||
<code class="feed-url" id="feed-url">/api/feed.xml</code>
|
||||
<div class="feed-actions">
|
||||
<a id="feed-open" href="/api/feed.xml" class="feed-btn" target="_blank" rel="noopener">📰 Öffnen</a>
|
||||
<button class="feed-btn secondary" onclick="feedCopy()">📋 URL kopieren</button>
|
||||
<a id="feed-reader" href="" class="feed-btn secondary" target="_blank" rel="noopener" title="In Feedly öffnen">In Feedly</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;font-size:12px;color:var(--ecg-dark);opacity:0.7;line-height:1.6;max-width:600px;">
|
||||
<p><strong>Hinweis:</strong> Du kannst die Feed-URL in jedem RSS-Reader (z.B. Feedly, NewsBlur, Inoreader, NetNewsWire, Thunderbird) abonnieren. Der Feed ist Atom 1.0 und liefert die letzten Bewertungen mit Score, Empfehlung und Kurzbegründung.</p>
|
||||
<p>Wenn du regelmäßige Mails statt Pull-Feed willst, lege ein <a href="/v2/abos" style="color:var(--ecg-teal);">E-Mail-Abo</a> an.</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
function feedUpdate() {
|
||||
var bl = document.getElementById('feed-bl').value;
|
||||
var part = document.getElementById('feed-partei').value.trim();
|
||||
var limit = document.getElementById('feed-limit').value;
|
||||
var qs = [];
|
||||
if (bl) qs.push('bundesland=' + encodeURIComponent(bl));
|
||||
if (part) qs.push('partei=' + encodeURIComponent(part));
|
||||
if (limit && limit !== '50') qs.push('limit=' + encodeURIComponent(limit));
|
||||
var path = '/api/feed.xml' + (qs.length ? ('?' + qs.join('&')) : '');
|
||||
var full = location.origin + path;
|
||||
document.getElementById('feed-url').textContent = full;
|
||||
document.getElementById('feed-open').href = path;
|
||||
document.getElementById('feed-reader').href = 'https://feedly.com/i/subscription/feed%2F' + encodeURIComponent(full);
|
||||
}
|
||||
|
||||
async function feedCopy() {
|
||||
var url = document.getElementById('feed-url').textContent;
|
||||
if (navigator.clipboard) await navigator.clipboard.writeText(url);
|
||||
else { prompt('Kopieren:', url); }
|
||||
}
|
||||
|
||||
// Bundesland aus globaler Auswahl als Default übernehmen
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var globalBl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
if (globalBl && globalBl !== 'ALL') {
|
||||
var sel = document.getElementById('feed-bl');
|
||||
if (sel) sel.value = globalBl;
|
||||
}
|
||||
feedUpdate();
|
||||
});
|
||||
window.addEventListener('v2-bl-changed', function(e) {
|
||||
var sel = document.getElementById('feed-bl');
|
||||
if (sel) {
|
||||
sel.value = (e.detail && e.detail.bl !== 'ALL') ? e.detail.bl : '';
|
||||
feedUpdate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -147,6 +147,9 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="ls-bl-hint" style="display:none;margin-bottom:12px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
|
||||
Bitte zuerst ein Bundesland im Header wählen.
|
||||
</div>
|
||||
<form class="ls-form" onsubmit="lsSearch(event)">
|
||||
<div class="ls-q">
|
||||
<label for="ls-q-input">Suchbegriff</label>
|
||||
@ -158,14 +161,6 @@
|
||||
required
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();lsSearch(event);}">
|
||||
</div>
|
||||
<div class="ls-bl">
|
||||
<label for="ls-bl-select">Bundesland</label>
|
||||
<select id="ls-bl-select" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="ls-submit" id="ls-btn">
|
||||
{{ icon("magnifying-glass-plus", 14) }} Suchen
|
||||
</button>
|
||||
@ -202,7 +197,14 @@ async function lsSearch(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
var q = (document.getElementById('ls-q-input').value || '').trim();
|
||||
var bl = document.getElementById('ls-bl-select').value;
|
||||
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
|
||||
if (bl === 'ALL') {
|
||||
document.getElementById('ls-bl-hint').style.display = '';
|
||||
document.getElementById('ls-status').textContent = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('ls-bl-hint').style.display = 'none';
|
||||
|
||||
if (q.length < 2) {
|
||||
document.getElementById('ls-status').textContent = 'Bitte mindestens 2 Zeichen eingeben.';
|
||||
@ -292,9 +294,50 @@ async function lsAnalyse(btn, drucksache, bundesland) {
|
||||
}
|
||||
var data = await resp.json();
|
||||
var ds = data.drucksache || drucksache;
|
||||
// Backend gibt {job_id, drucksache} zurück (Queue) — nicht direkt redirecten,
|
||||
// sondern auf /antrag/{ds} gehen, dort wird dann ggf. der Polling-Status sichtbar
|
||||
|
||||
// Falls bereits bewertet oder skipped: direkt redirecten
|
||||
if (data.status === 'already_checked') {
|
||||
window.location.href = '/antrag/' + encodeURIComponent(ds);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'skipped') {
|
||||
btn.textContent = 'Nicht abstimmbar';
|
||||
btn.title = (data.reason || ('Typ „' + (data.typ || 'unbekannt') + '" ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll'));
|
||||
btn.style.opacity = '0.55';
|
||||
btn.style.cursor = 'not-allowed';
|
||||
// Begründung sichtbar in der Zeile anzeigen
|
||||
var row = btn.closest('.ls-row');
|
||||
if (row) {
|
||||
var hint = document.createElement('div');
|
||||
hint.style.cssText = 'flex-basis:100%;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.7;margin-top:4px;font-style:italic;';
|
||||
hint.textContent = data.reason || ('Typ „' + (data.typ || 'unbekannt') + '" — keine Abstimmung, keine GWÖ-Bewertung');
|
||||
row.appendChild(hint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sonst Job-Polling bis fertig, dann redirect
|
||||
btn.textContent = 'Analysiere…';
|
||||
var jobId = data.job_id;
|
||||
if (!jobId) { window.location.href = '/antrag/' + encodeURIComponent(ds); return; }
|
||||
|
||||
var attempts = 0;
|
||||
var maxAttempts = 90; // 90 × 2s = 3 min
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(function (r) { setTimeout(r, 2000); });
|
||||
attempts++;
|
||||
var st = await fetch('/status/' + jobId).then(function (r) { return r.json(); }).catch(function () { return null; });
|
||||
if (!st) continue;
|
||||
if (st.status === 'completed') {
|
||||
window.location.href = '/antrag/' + encodeURIComponent(ds);
|
||||
return;
|
||||
}
|
||||
if (st.status === 'failed' || st.status === 'rejected') {
|
||||
throw new Error('Analyse fehlgeschlagen: ' + (st.error || 'unbekannt'));
|
||||
}
|
||||
btn.textContent = 'Analysiere… (' + (st.status || '...') + ')';
|
||||
}
|
||||
throw new Error('Analyse-Timeout (>3 min)');
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Fehler';
|
||||
@ -313,5 +356,15 @@ function escHtml(s) {
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var hint = document.getElementById('ls-bl-hint');
|
||||
function updateHint() {
|
||||
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none';
|
||||
}
|
||||
updateHint();
|
||||
window.addEventListener('v2-bl-changed', updateHint);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -92,18 +92,15 @@
|
||||
|
||||
<form class="neu-form" onsubmit="startAnalyse(event)">
|
||||
|
||||
<div id="neu-bl-hint" style="display:none;margin-bottom:14px;padding:8px 12px;background:color-mix(in srgb,var(--ecg-teal) 10%,transparent);border:1px solid var(--ecg-teal);border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-teal);">
|
||||
Bitte zuerst ein Bundesland im Header wählen.
|
||||
</div>
|
||||
|
||||
<label for="neu-drucksache">Drucksachen-Nummer</label>
|
||||
<input type="text" id="neu-drucksache" name="drucksache"
|
||||
placeholder="z. B. 18/12345 oder NRW-18/12345"
|
||||
required autocomplete="off">
|
||||
|
||||
<label for="neu-bl">Bundesland</label>
|
||||
<select id="neu-bl" name="bundesland">
|
||||
{% for bl in bundeslaender %}
|
||||
<option value="{{ bl.code }}"{% if bl.code == 'NRW' %} selected{% endif %}>{{ bl.name }} ({{ bl.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="neu-model">Modell</label>
|
||||
<select id="neu-model" name="model">
|
||||
<option value="">Standard ({{ default_model }})</option>
|
||||
@ -136,11 +133,17 @@ async function startAnalyse(e) {
|
||||
const errEl = document.getElementById('neu-error');
|
||||
|
||||
const drucksache = document.getElementById('neu-drucksache').value.trim();
|
||||
const bundesland = document.getElementById('neu-bl').value;
|
||||
const bundesland = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
const model = document.getElementById('neu-model').value;
|
||||
|
||||
if (!drucksache) return;
|
||||
|
||||
if (bundesland === 'ALL') {
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Bitte zuerst ein Bundesland im Header wählen.';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
statusEl.style.display = '';
|
||||
progEl.style.display = '';
|
||||
@ -154,16 +157,56 @@ async function startAnalyse(e) {
|
||||
if (model) fd.append('model', model);
|
||||
|
||||
const resp = await fetch('/api/analyze-drucksache', { method: 'POST', body: fd });
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
progEl.style.display = 'none';
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Sitzung abgelaufen — bitte erneut anmelden.';
|
||||
btn.disabled = false;
|
||||
if (typeof window.v2AuthModalOpen === 'function') window.v2AuthModalOpen();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || ('HTTP ' + resp.status));
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
// Redirect to result page
|
||||
const ds = data.drucksache || drucksache;
|
||||
progText.textContent = 'Analyse abgeschlossen. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 600);
|
||||
|
||||
if (data.status === 'already_checked') {
|
||||
progText.textContent = 'Bereits bewertet. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 400);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'skipped') {
|
||||
progEl.style.display = 'none';
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = 'Antrag-Typ "' + (data.typ || 'unbekannt') + '" ist nicht abstimmbar — keine GWÖ-Bewertung sinnvoll.';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Job-Polling bis Abschluss, dann redirect
|
||||
const jobId = data.job_id;
|
||||
if (!jobId) { window.location.href = '/antrag/' + encodeURIComponent(ds); return; }
|
||||
let attempts = 0;
|
||||
const maxAttempts = 90;
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
attempts++;
|
||||
const st = await fetch('/status/' + jobId).then(r => r.json()).catch(() => null);
|
||||
if (!st) continue;
|
||||
progText.textContent = 'Analyse läuft … (' + (st.status || '?') + ', ~' + (attempts * 2) + 's)';
|
||||
if (st.status === 'completed') {
|
||||
progText.textContent = 'Fertig. Weiterleitung …';
|
||||
setTimeout(() => { window.location.href = '/antrag/' + encodeURIComponent(ds); }, 400);
|
||||
return;
|
||||
}
|
||||
if (st.status === 'failed' || st.status === 'rejected') {
|
||||
throw new Error('Analyse fehlgeschlagen: ' + (st.error || 'unbekannt'));
|
||||
}
|
||||
}
|
||||
throw new Error('Analyse-Timeout (>3 min)');
|
||||
|
||||
} catch (err) {
|
||||
progEl.style.display = 'none';
|
||||
@ -172,5 +215,15 @@ async function startAnalyse(e) {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var hint = document.getElementById('neu-bl-hint');
|
||||
function updateHint() {
|
||||
var bl = (window.v2GetGlobalBl && window.v2GetGlobalBl()) || 'ALL';
|
||||
if (hint) hint.style.display = (bl === 'ALL') ? '' : 'none';
|
||||
}
|
||||
updateHint();
|
||||
window.addEventListener('v2-bl-changed', updateHint);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -23,6 +23,11 @@ services:
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-GWÖ-Antragsprüfer}
|
||||
- UNSUBSCRIBE_SECRET=${UNSUBSCRIBE_SECRET}
|
||||
- BASE_URL=${BASE_URL:-https://gwoe.toppyr.de}
|
||||
# Gitea-Anbindung fuer Feedback-Widget (#149-Folge)
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_API_URL=${GITEA_API_URL:-https://repo.toppyr.de/api/v1}
|
||||
- GITEA_REPO_OWNER=${GITEA_REPO_OWNER:-tobias}
|
||||
- GITEA_REPO_NAME=${GITEA_REPO_NAME:-gwoe-antragspruefer}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./reports:/app/reports
|
||||
|
||||
@ -42,23 +42,26 @@ echo " $(date -Iseconds)"
|
||||
echo "================================================================"
|
||||
|
||||
echo
|
||||
echo "[1] Hauptseiten erreichbar (alle 200)"
|
||||
echo "[1] Public-Seiten (200 ohne Auth)"
|
||||
check "v2 Default /" "200" "/"
|
||||
check "v2 Detail (echte DS)" "200" "/antrag/21/754S"
|
||||
check "Classic /classic" "200" "/classic"
|
||||
check "/auswertungen" "200" "/auswertungen"
|
||||
check "/methodik" "200" "/methodik"
|
||||
check "/quellen" "200" "/quellen"
|
||||
check "/impressum" "200" "/impressum"
|
||||
check "/datenschutz" "200" "/datenschutz"
|
||||
check "/v2/merkliste" "200" "/v2/merkliste"
|
||||
check "/v2/tags" "200" "/v2/tags"
|
||||
check "/v2/cluster" "200" "/v2/cluster"
|
||||
check "/v2/landtag-suche" "200" "/v2/landtag-suche"
|
||||
check "/v2/neu" "200" "/v2/neu"
|
||||
check "/v2/batch" "200" "/v2/batch"
|
||||
check "/health" "200" "/health"
|
||||
|
||||
echo
|
||||
echo "[1b] Auth-Routen (302/401 ohne Auth — Redirect zu Login)"
|
||||
check "/auswertungen (auth)" "401" "/auswertungen"
|
||||
check "/v2/merkliste (auth)" "401" "/v2/merkliste"
|
||||
check "/v2/landtag-suche (auth)" "401" "/v2/landtag-suche"
|
||||
check "/v2/neu (auth)" "401" "/v2/neu"
|
||||
check "/v2/cluster (admin)" "401" "/v2/cluster"
|
||||
check "/v2/batch (admin)" "401" "/v2/batch"
|
||||
|
||||
echo
|
||||
echo "[2] API-Endpoints (öffentlich)"
|
||||
check "/api/assessments" "200" "/api/assessments"
|
||||
|
||||
159
tests/test_feedback_endpoint.py
Normal file
159
tests/test_feedback_endpoint.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Unit-Tests für /api/feedback — gemockter Gitea-Call.
|
||||
|
||||
Prüft:
|
||||
- Issue-Body wird korrekt aus Eingaben + Audit-Trail zusammengebaut
|
||||
- Endpoint antwortet mit issue_id und issue_url
|
||||
- Rate-Limit-Decorator ist deklariert
|
||||
- Kein Token → 503
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# Skip falls die App-Abhängigkeiten nicht importierbar sind
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app, _strip_html, _gitea_ensure_label
|
||||
_HAS_APP = True
|
||||
except ImportError:
|
||||
_HAS_APP = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
|
||||
|
||||
|
||||
# ── _strip_html ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStripHtml:
|
||||
def test_removes_tags(self):
|
||||
assert _strip_html("<b>hallo</b>", 200) == "hallo"
|
||||
|
||||
def test_max_len(self):
|
||||
assert len(_strip_html("a" * 300, 100)) == 100
|
||||
|
||||
def test_empty(self):
|
||||
assert _strip_html("", 200) == ""
|
||||
|
||||
def test_no_tags(self):
|
||||
assert _strip_html("plain text", 200) == "plain text"
|
||||
|
||||
|
||||
# ── /api/feedback Endpoint ───────────────────────────────────────────────────
|
||||
|
||||
class TestFeedbackEndpoint:
|
||||
"""Smoke-Tests mit gemocktem httpx-Client + gemocktem gitea_token."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_gitea(self):
|
||||
"""Patches settings.gitea_token und httpx.AsyncClient."""
|
||||
# settings.gitea_token setzen
|
||||
with patch("app.main.settings") as mock_settings:
|
||||
mock_settings.gitea_token = "fake-token-123"
|
||||
mock_settings.gitea_api_url = "https://repo.example.com/api/v1"
|
||||
mock_settings.gitea_repo_owner = "testowner"
|
||||
mock_settings.gitea_repo_name = "testrepo"
|
||||
|
||||
# httpx.AsyncClient mocken
|
||||
mock_resp_labels = MagicMock()
|
||||
mock_resp_labels.status_code = 200
|
||||
mock_resp_labels.json.return_value = [{"id": 7, "name": "feedback"}]
|
||||
|
||||
mock_resp_issue = MagicMock()
|
||||
mock_resp_issue.status_code = 201
|
||||
mock_resp_issue.json.return_value = {
|
||||
"number": 42,
|
||||
"html_url": "https://repo.example.com/testowner/testrepo/issues/42",
|
||||
}
|
||||
|
||||
async_client = AsyncMock()
|
||||
async_client.get.return_value = mock_resp_labels
|
||||
async_client.post.return_value = mock_resp_issue
|
||||
async_client.__aenter__ = AsyncMock(return_value=async_client)
|
||||
async_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=async_client):
|
||||
self._async_client = async_client
|
||||
yield
|
||||
|
||||
def test_happy_path_returns_issue_url(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Test-Bug",
|
||||
"beschreibung": "Etwas ist kaputt",
|
||||
"url": "https://gwoe.toppyr.de/antrag/NRW-18/1234",
|
||||
"drucksache": "NRW-18/1234",
|
||||
"viewport": "1440x900",
|
||||
"user_agent": "TestAgent/1.0",
|
||||
"klicks_json": json.dumps([{"t": "2026-04-25T10:00:00Z", "el": "button.v2-nav-item", "txt": "Durchsuchen"}]),
|
||||
"errors_json": json.dumps([]),
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["issue_id"] == 42
|
||||
assert "issues/42" in data["issue_url"]
|
||||
|
||||
def test_issue_body_contains_drucksache(self):
|
||||
"""Stellt sicher, dass die Drucksachen-Nummer im POST-Payload auftaucht."""
|
||||
client = TestClient(app)
|
||||
client.post("/api/feedback", data={
|
||||
"titel": "Body-Check",
|
||||
"beschreibung": "Details",
|
||||
"drucksache": "BY-18/9999",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
# Zweiter Post-Call ist der Issue-Create-Call
|
||||
calls = self._async_client.post.call_args_list
|
||||
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
||||
assert issue_call is not None
|
||||
body_arg = issue_call.kwargs.get("json", {}).get("body", "")
|
||||
assert "BY-18/9999" in body_arg
|
||||
|
||||
def test_missing_titel_returns_422(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"beschreibung": "Ohne Titel",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_missing_beschreibung_returns_422(self):
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Ohne Beschreibung",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_html_stripped_from_titel(self):
|
||||
"""XSS im Titel wird entfernt."""
|
||||
client = TestClient(app)
|
||||
client.post("/api/feedback", data={
|
||||
"titel": "<script>alert(1)</script>Bug",
|
||||
"beschreibung": "XSS-Test",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
calls = self._async_client.post.call_args_list
|
||||
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
||||
if issue_call:
|
||||
title_arg = issue_call.kwargs.get("json", {}).get("title", "")
|
||||
assert "<script>" not in title_arg
|
||||
|
||||
def test_no_token_returns_503(self):
|
||||
"""Ohne konfiguriertes Token gibt es 503."""
|
||||
with patch("app.main.settings") as s:
|
||||
s.gitea_token = ""
|
||||
s.gitea_api_url = "https://repo.example.com/api/v1"
|
||||
s.gitea_repo_owner = "testowner"
|
||||
s.gitea_repo_name = "testrepo"
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.post("/api/feedback", data={
|
||||
"titel": "Test",
|
||||
"beschreibung": "Kein Token",
|
||||
"klicks_json": "[]",
|
||||
"errors_json": "[]",
|
||||
})
|
||||
assert resp.status_code == 503
|
||||
Loading…
Reference in New Issue
Block a user