gwoe-antragspruefer/app/auswertungen.py
Dotty Dotter 565849bd84 feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
  (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
  Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze

Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
  /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie

Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00

245 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Aggregations-Funktionen für die Auswertungen-Seite (#58).
Liest direkt aus ``data/gwoe-antraege.db`` (assessments-Tabelle) und baut
drei Sichten:
1. ``aggregate_matrix(filter_wp=None)`` — 2D-Matrix Bundesland × Partei
mit (n, Ø-GWÖ-Score). Filterbar nach Wahlperiode.
2. ``aggregate_zeitreihe(bundesland, partei)`` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs.
3. ``export_long_format()`` — Long-Format-Tabelle für CSV-Export
(deckt zusätzlich Issue #45 ab).
Partei-Auflösung läuft strikt über ``app.parteien.normalize_partei`` —
ohne den Mapper aus #55 würde z.B. BB-FW mit RP-FW in einen Topf
gerührt.
"""
from __future__ import annotations
import csv
import io
import json
import sqlite3
from collections import defaultdict
from pathlib import Path
from typing import Optional
from .config import settings
from .parteien import normalize_partei
from .wahlperioden import wahlperiode_for
# ─────────────────────────────────────────────────────────────────────────────
# Datenstrukturen
# ─────────────────────────────────────────────────────────────────────────────
def _load_assessments(db_path: Optional[Path] = None) -> list[dict]:
"""Lese alle Assessments aus der SQLite-DB. Kein Filter — die
Aggregations-Funktionen filtern selbst. Kein async, weil die
Sicht synchron berechnet werden kann."""
path = db_path or settings.db_path
if not Path(path).exists():
return []
conn = sqlite3.connect(str(path))
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT drucksache, bundesland, datum, fraktionen, gwoe_score
FROM assessments
WHERE gwoe_score IS NOT NULL
"""
).fetchall()
finally:
conn.close()
out: list[dict] = []
for r in rows:
try:
fraktionen = json.loads(r["fraktionen"]) if r["fraktionen"] else []
except (json.JSONDecodeError, TypeError):
fraktionen = []
out.append({
"drucksache": r["drucksache"],
"bundesland": r["bundesland"],
"datum": r["datum"] or "",
"fraktionen": fraktionen,
"gwoe_score": r["gwoe_score"],
})
return out
# ─────────────────────────────────────────────────────────────────────────────
# 1. Matrix Bundesland × Partei
# ─────────────────────────────────────────────────────────────────────────────
def aggregate_matrix(
filter_wp: Optional[str] = None,
filter_bl: Optional[str] = None,
db_path: Optional[Path] = None,
) -> dict:
"""Aggregate assessments to a 2D matrix.
Returns:
``{
"bundeslaender": [...],
"parteien": [...],
"cells": {
"<bl>": {"<partei>": {"n": int, "avg": float}}
},
"filter_wp": <filter_wp> | None,
"filter_bl": <filter_bl> | None,
"total": int,
}``
``filter_wp`` ist eine ``"<BL>-WP<n>"``-Kennung wie ``"NRW-WP18"``;
nur Assessments dieser Wahlperiode fließen ein. ``None`` = keine
WP-Einschränkung (alle WPs zusammen).
``filter_bl`` schränkt auf ein Bundesland ein (z.B. ``"NRW"``);
``None`` = alle Bundesländer.
"""
rows = _load_assessments(db_path)
bundeslaender: set[str] = set()
parteien: set[str] = set()
sums: defaultdict[tuple[str, str], float] = defaultdict(float)
counts: defaultdict[tuple[str, str], int] = defaultdict(int)
total = 0
for row in rows:
bl = row["bundesland"]
if not bl:
continue
if filter_bl is not None and bl != filter_bl:
continue
if filter_wp is not None:
wp = wahlperiode_for(row["datum"], bl)
if wp != filter_wp:
continue
bundeslaender.add(bl)
for raw_partei in row["fraktionen"]:
canonical = normalize_partei(raw_partei, bundesland=bl) or raw_partei
parteien.add(canonical)
key = (bl, canonical)
sums[key] += row["gwoe_score"]
counts[key] += 1
total += 1
cells: dict[str, dict[str, dict]] = {}
for (bl, partei), s in sums.items():
n = counts[(bl, partei)]
cells.setdefault(bl, {})[partei] = {
"n": n,
"avg": round(s / n, 2) if n else None,
}
return {
"bundeslaender": sorted(bundeslaender),
"parteien": sorted(parteien),
"cells": cells,
"filter_wp": filter_wp,
"filter_bl": filter_bl,
"total": total,
}
# ─────────────────────────────────────────────────────────────────────────────
# 1b. Hilfsfunktion: Liste aller bekannten Wahlperioden
# ─────────────────────────────────────────────────────────────────────────────
def get_wahlperioden(db_path: Optional[Path] = None) -> list[str]:
"""Gibt alle bekannten Wahlperioden aus den vorhandenen Assessments zurück,
aufsteigend sortiert."""
rows = _load_assessments(db_path)
wps: set[str] = set()
for r in rows:
wp = wahlperiode_for(r["drucksache"], r["bundesland"])
if wp:
wps.add(wp)
return sorted(wps)
# ─────────────────────────────────────────────────────────────────────────────
# 2. Zeitreihe pro (BL, Partei) über alle Wahlperioden
# ─────────────────────────────────────────────────────────────────────────────
def aggregate_zeitreihe(
bundesland: str,
partei: str,
db_path: Optional[Path] = None,
) -> dict:
"""Score-Verlauf einer (BL, Partei)-Kombination über alle WPs.
Returns:
``{
"bundesland": str,
"partei": str,
"wahlperioden": [
{"wp": "<BL>-WP<n>", "n": int, "avg": float},
...
]
}``
"""
rows = _load_assessments(db_path)
sums: defaultdict[str, float] = defaultdict(float)
counts: defaultdict[str, int] = defaultdict(int)
for row in rows:
if row["bundesland"] != bundesland:
continue
canonical_partei_in_row = {
normalize_partei(p, bundesland=bundesland) or p
for p in row["fraktionen"]
}
if partei not in canonical_partei_in_row:
continue
wp = wahlperiode_for(row["datum"], bundesland)
if wp is None:
continue
sums[wp] += row["gwoe_score"]
counts[wp] += 1
wps = sorted(sums.keys())
return {
"bundesland": bundesland,
"partei": partei,
"wahlperioden": [
{"wp": wp, "n": counts[wp], "avg": round(sums[wp] / counts[wp], 2)}
for wp in wps
],
}
# ─────────────────────────────────────────────────────────────────────────────
# 3. Long-Format-Export für CSV (deckt #45 mit ab)
# ─────────────────────────────────────────────────────────────────────────────
def export_long_format(db_path: Optional[Path] = None) -> str:
"""Long-Format-CSV-Export aller Assessments für externe Auswertung.
Spalten: ``drucksache,bundesland,wahlperiode,datum,partei,gwoe_score``.
Eine Zeile pro (drucksache, partei) — wenn ein Antrag mehrere
Fraktionen hat (Koalitionsanträge), erscheinen entsprechend mehrere
Zeilen mit identischer Drucksache.
"""
rows = _load_assessments(db_path)
buf = io.StringIO()
writer = csv.writer(buf, dialect="excel")
writer.writerow(["drucksache", "bundesland", "wahlperiode", "datum", "partei", "gwoe_score"])
for r in rows:
bl = r["bundesland"] or ""
wp = wahlperiode_for(r["datum"], bl) if bl else ""
for raw_partei in r["fraktionen"]:
canonical = normalize_partei(raw_partei, bundesland=bl) or raw_partei
writer.writerow([
r["drucksache"], bl, wp or "", r["datum"], canonical,
f"{r['gwoe_score']:.2f}",
])
return buf.getvalue()