feat(v3): Bürger:innen-Modus-Sandbox unter /v3/antrag/{drs}

Skelett für Issues #184 + #185 — minimal, nicht-disruptiv:

- v3/base.html extendet v2/base.html (Topbar/Sidebar/Footer geteilt)
- v3/screens/antrag_detail.html extendet vorerst v2-Screen 1:1 und
  injiziert nur Beta-Pill + Toggle "→ Profi-Modus"
- v2/screens/antrag_detail.html bekommt Topbar-Link "→ Bürger:innen-
  Modus (v3 Beta)" → /v3/antrag/<drs>
- _render_antrag_detail() teilt DB-Reads/Context zwischen v2 + v3 —
  Datenbasis garantiert in Sync, Unterschied ist nur template_name
- _MATRIX_EXPLANATIONS auf Modul-Ebene ausgelagert (war bisher
  inline im v2-Route, jetzt von beiden Modi referenziert)
- v3.css als Add-On nach v2.css (lädt im v3/base head)

Was v3 noch NICHT tut: Score-Hero-Vereinfachung, Matrix→5-Werte,
Glossar-Tooltips, Default-Collapsing der Profi-Blöcke (Verbesserungen,
Kommentare). Diese Iterationen folgen pro PR — v2 bleibt unberührt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 09:55:06 +02:00
parent c4f8ce398a
commit c4750d3274
5 changed files with 174 additions and 13 deletions

View File

@ -285,13 +285,23 @@ async def index(request: Request, current_user: Optional[dict] = Depends(get_cur
}) })
@app.get("/antrag/{drucksache:path}", response_class=HTMLResponse) async def _render_antrag_detail(
async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)): request: Request,
"""v2-Antragsdetail-Route — Server-Side-Rendering mit echten DB-Daten.""" drucksache: str,
current_user: Optional[dict],
template_name: str,
) -> HTMLResponse:
"""Gemeinsame Render-Logik fuer v2- und v3-Antrag-Detail-Routes.
Der einzige Unterschied zwischen den Modi ist `template_name` die
DB-Reads, _row_to_detail-Aufbereitung, Plenum-Votes-Anreicherung und
der Template-Context sind identisch. So bleibt die Datenbasis fuer
beide Modi automatisch in Sync.
"""
try: try:
drucksache = validate_drucksache(drucksache) drucksache = validate_drucksache(drucksache)
except Exception: except Exception:
return templates.TemplateResponse("v2/screens/antrag_detail.html", { return templates.TemplateResponse(template_name, {
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"error": f"Ungültige Drucksachen-ID: {drucksache}", "error": f"Ungültige Drucksachen-ID: {drucksache}",
@ -300,7 +310,7 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
row = await get_assessment(drucksache) row = await get_assessment(drucksache)
if not row: if not row:
return templates.TemplateResponse("v2/screens/antrag_detail.html", { return templates.TemplateResponse(template_name, {
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"error": f"Antrag {drucksache} wurde nicht gefunden.", "error": f"Antrag {drucksache} wurde nicht gefunden.",
@ -308,13 +318,11 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
}, status_code=404) }, status_code=404)
antrag = _row_to_detail(row) antrag = _row_to_detail(row)
# #106 Phase 1: namentliche Abstimmungsdaten ergänzen (optional, kann None sein)
try: try:
antrag["abstimmungsverhalten"] = await get_abstimmungsverhalten(drucksache) antrag["abstimmungsverhalten"] = await get_abstimmungsverhalten(drucksache)
except Exception: except Exception:
logger.exception("Fehler beim Laden von Abstimmungsverhalten für %s", drucksache) logger.exception("Fehler beim Laden von Abstimmungsverhalten für %s", drucksache)
antrag["abstimmungsverhalten"] = None antrag["abstimmungsverhalten"] = None
# #106 Phase 2: fraktions-aggregierte Plenum-Abstimmungen aus Plenarprotokollen
try: try:
from .database import get_plenum_votes as _gpv from .database import get_plenum_votes as _gpv
antrag["plenum_votes"] = await _gpv(antrag.get("bundesland") or "NRW", drucksache) antrag["plenum_votes"] = await _gpv(antrag.get("bundesland") or "NRW", drucksache)
@ -322,12 +330,22 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
logger.exception("Fehler beim Laden plenum_vote_results für %s", drucksache) logger.exception("Fehler beim Laden plenum_vote_results für %s", drucksache)
antrag["plenum_votes"] = [] antrag["plenum_votes"] = []
from .models import MATRIX_LABELS from .models import MATRIX_LABELS
return templates.TemplateResponse("v2/screens/antrag_detail.html", { return templates.TemplateResponse(template_name, {
"request": request, "request": request,
"v2_active_nav": "durchsuchen", "v2_active_nav": "durchsuchen",
"antrag": antrag, "antrag": antrag,
"assessment_count": None, "assessment_count": None,
"matrix_explanations": { "matrix_explanations": _MATRIX_EXPLANATIONS,
"matrix_labels": MATRIX_LABELS,
**_v2_template_context(current_user),
})
# Allgemeine Felderklärungen für die GWÖ-Matrix — alltagssprachlich, nicht
# antragsspezifisch (die antragsspezifische Begründung kommt aus dem
# LLM-Output via _row_to_detail/matrix_dict). Geteilt zwischen / (Index)
# und /antrag/ /v3/antrag/.
_MATRIX_EXPLANATIONS = {
"A1": "Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt? Oder in einer Fabrik, in der Arbeiter:innen ausgebeutet werden? Hier geht es darum, ob die öffentliche Hand beim Einkauf auf Menschenrechte achtet.", "A1": "Wenn Ihre Stadt Büromöbel kauft: Wurden die unter menschenwürdigen Bedingungen hergestellt? Oder in einer Fabrik, in der Arbeiter:innen ausgebeutet werden? Hier geht es darum, ob die öffentliche Hand beim Einkauf auf Menschenrechte achtet.",
"A2": "Beauftragt die Stadt den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleibt das Geld in der Region und schafft Arbeitsplätze vor Ort?", "A2": "Beauftragt die Stadt den Handwerksbetrieb aus dem Ort — oder den billigsten Konzern aus dem Ausland? Bleibt das Geld in der Region und schafft Arbeitsplätze vor Ort?",
"A3": "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa gekarrt?", "A3": "Werden bei öffentlichen Aufträgen Klimastandards verlangt? Kommt das Schulessen von regionalen Bauernhöfen oder wird es quer durch Europa gekarrt?",
@ -353,10 +371,28 @@ async def antrag_detail(request: Request, drucksache: str, current_user: Optiona
"E3": "Denkt Ihre Kommune beim Einkauf auch an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten?", "E3": "Denkt Ihre Kommune beim Einkauf auch an den CO₂-Fußabdruck? An die Abholzung von Regenwäldern für billiges Papier? An den Wasserverbrauch in Dürregebieten?",
"E4": "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden?", "E4": "Unterstützt Ihre Stadt strukturschwache Regionen? Gibt es Partnerschaften mit Kommunen im globalen Süden?",
"E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?", "E5": "Setzt sich Ihre Kommune für mehr Demokratie ein — auch auf Landes- und Bundesebene? Werden internationale Abkommen unterstützt?",
}, }
"matrix_labels": MATRIX_LABELS,
**_v2_template_context(current_user),
}) @app.get("/antrag/{drucksache:path}", response_class=HTMLResponse)
async def antrag_detail(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)):
"""v2-Antrag-Detail (Profi-Modus): volles GWÖ-Dashboard pro Drucksache."""
return await _render_antrag_detail(
request, drucksache, current_user, "v2/screens/antrag_detail.html"
)
@app.get("/v3/antrag/{drucksache:path}", response_class=HTMLResponse)
async def antrag_detail_v3(request: Request, drucksache: str, current_user: Optional[dict] = Depends(get_current_user)):
"""v3-Antrag-Detail (Bürger:innen-Modus, Beta): vereinfachte Vorschau.
Sandbox fuer Issues #184 (CD-Konformitaet) und #185 (Bürgerinnen-
Perspektive). Initial identisch zu v2; iterative Vereinfachungen
folgen pro PR. v2-Endpoint bleibt unangetastet.
"""
return await _render_antrag_detail(
request, drucksache, current_user, "v3/screens/antrag_detail.html"
)
def _v2_template_context(current_user=None) -> dict: def _v2_template_context(current_user=None) -> dict:

46
app/static/v3/v3.css Normal file
View File

@ -0,0 +1,46 @@
/*
* v3.css Bürgerinnen-Modus-Erweiterung über v2.css
*
* v3 = Sandbox für Issues #184 (CD-Konformität) und #185 (Bürgerinnen-
* Perspektive). Lädt nach v2.css und überschreibt selektiv. v2-Endpoints
* bleiben unverändert.
*
* Konvention:
* - Klassen mit Präfix `v3-` sind v3-spezifisch.
* - v2-Klassen werden nur überschrieben, wenn das Citizen-Bedürfnis es
* verlangt (Wort-Etiketten statt Symbol, weniger Tiefe als Default).
* - Inline-Styles in v3-Templates sind verboten (Lint-Hook folgt in #184).
*/
/* ── v3-Beta-Indikator in der Topbar ────────────────────────────────── */
.v3-beta-badge {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 9px;
background: var(--ecg-green);
color: #fff;
font-weight: 700;
}
/* ── Modus-Toggle: zwischen Profi (/antrag/...) und Bürgerin (/v3/...) ── */
.v3-modus-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ecg-dark);
opacity: 0.85;
border-bottom: 1px solid rgba(0, 157, 165, 0.35);
padding-bottom: 1px;
}
.v3-modus-toggle:hover {
opacity: 1;
color: var(--ecg-blue);
}

View File

@ -649,6 +649,21 @@ document.addEventListener('keydown', function (e) {
history.back(); history.back();
} }
}); });
/* v3-Toggle in der Topbar: nur bei vorhandenem Antrag, Link auf
/v3/antrag/<drs>. Injektion via JS, damit v2/base.html unangetastet bleibt. */
{% if antrag is defined and antrag and antrag.drucksache %}
(function () {
var bar = document.querySelector('.v2-topbar');
if (!bar) return;
var link = document.createElement('a');
link.href = '/v3/antrag/' + encodeURIComponent({{ antrag.drucksache | tojson }});
link.textContent = '→ Bürger:innen-Modus · v3 Beta';
link.title = 'Vereinfachte Vorschau für Erst-Leser:innen (v3 Beta).';
link.style.marginLeft = 'auto';
bar.appendChild(link);
})();
{% endif %}
</script> </script>
{# Matrix-Erklärungen als JSON in den Browser übertragen #} {# Matrix-Erklärungen als JSON in den Browser übertragen #}

View File

@ -0,0 +1,17 @@
{# ─────────────────────────────────────────────────────────────────────
v3/base.html — Bürgerinnen-Modus-Shell
Erweitert v2/base.html und kann perspektivisch Sidebar/Topbar/Footer
ueberschreiben. Initial: ein zusaetzliches v3.css fuer Bürgerinnen-
spezifische Komponenten + ein Beta-Indikator in der Topbar.
Der v3-Endpoint (Routes: /v3/antrag/...) ist die Sandbox fuer Issue
#184 (CD-Konformitaet) und #185 (Buergerinnen-Perspektive). v2 bleibt
als Profi-Modus unangetastet.
───────────────────────────────────────────────────────────────────── #}
{% extends "v2/base.html" %}
{% block head_extra %}
{{ super() }}
<link rel="stylesheet" href="/static/v3/v3.css?v={{ app_version|default('1') }}">
{% endblock %}

View File

@ -0,0 +1,47 @@
{# ─────────────────────────────────────────────────────────────────────
v3/screens/antrag_detail.html — Bürgerinnen-Modus, Schritt 0
Initial extendet diese Datei das v2-Antrag-Detail unverändert und
fügt nur den v3-Beta-Indikator + Modus-Toggle in die Topbar ein.
Folgende Iterationen ersetzen Profi-Blöcke durch Bürgerinnen-
Varianten (siehe #185):
- Score-Hero: Wort-Etikett statt 010-Zahl
- Matrix 5×5 → 5 Werte (Berührungsgruppen kollabiert)
- Heuchelei/Opportunismus-Marker mit Klartext-Tooltip
- Verbesserungsvorschläge default kollabiert
- Glossar-Tooltips auf Schlüsselbegriffen
───────────────────────────────────────────────────────────────────── #}
{% extends "v2/screens/antrag_detail.html" %}
{# v3-Indikator + Toggle: erscheint im topbar-Slot, der in v2/base.html
aktuell nicht als Block exponiert ist. Wir nutzen daher head_extra
als Eingangstor und injizieren via JS einen kleinen Topbar-Pill.
Das vermeidet ein Refactor von v2/base.html, der v2 beruehren wuerde. #}
{% block head_extra %}
{{ super() }}
<link rel="stylesheet" href="/static/v3/v3.css?v={{ app_version|default('1') }}">
{% endblock %}
{% block body_scripts %}
{{ super() }}
<script>
/* v3-Topbar-Pill + Toggle zurueck zu Profi-Modus injizieren */
(function () {
var bar = document.querySelector('.v2-topbar');
if (!bar) return;
var pill = document.createElement('span');
pill.className = 'v3-beta-badge';
pill.textContent = 'Bürger:innen-Modus · Beta';
pill.title = 'Vereinfachte Ansicht für Erst-Leser:innen. v3 ist eine frühe Vorschau.';
var toggle = document.createElement('a');
toggle.className = 'v3-modus-toggle';
toggle.href = '/antrag/' + encodeURIComponent({{ antrag.drucksache | tojson }});
toggle.textContent = '→ Profi-Modus';
toggle.title = 'Volle GWÖ-Detailansicht (v2) öffnen';
bar.appendChild(pill);
bar.appendChild(toggle);
})();
</script>
{% endblock %}