feat(#106): plenum_vote_results-Tabelle + Repository
DB-Schema fuer fraktions-aggregierte Plenum-Abstimmungsergebnisse: - bundesland, drucksache, quelle_protokoll als Compound-PK (eine Drucksache kann mehrfach abgestimmt werden — Ausschuss-Empfehlung und finale Beschlussfassung leben nebeneinander) - ergebnis (angenommen/abgelehnt/ueberwiesen/...), einstimmig-Flag - fraktionen_ja/_nein/_enthaltung als JSON-Arrays - quelle_protokoll (z.B. 'MMP18-119') + optional quelle_url - Index auf (bundesland, drucksache) fuer Lookup-Path Repository-API: - upsert_plenum_vote(...) idempotent ueber Compound-PK - get_plenum_votes(bl, drucksache) → Liste, neueste zuerst 7 Tests fuer Roundtrip, einstimmig-Flag, Idempotenz, Multi-Protokoll-Erhalt, leere Queries, Unicode-Handling von 'GRÜNE'. Refs #106 — naechster Schritt: Ingest-CLI gegen NRW-PDFs.
This commit is contained in:
parent
d640734641
commit
ae3f48be41
111
app/database.py
111
app/database.py
@ -259,6 +259,32 @@ async def init_db():
|
||||
)
|
||||
""")
|
||||
|
||||
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen (#106).
|
||||
# Granularitaet: "GRUENE und SPD haben zugestimmt", nicht pro MP — das
|
||||
# ist der Datentyp, der aus deterministischen Parsern wie
|
||||
# protokoll_parser_nrw.py rauskommt.
|
||||
# Compound-PK ueber quelle_protokoll, weil eine Drucksache mehrfach
|
||||
# abgestimmt werden kann (Ausschuss-Empfehlung + Plenum-Beschluss).
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS plenum_vote_results (
|
||||
bundesland TEXT NOT NULL,
|
||||
drucksache TEXT NOT NULL,
|
||||
ergebnis TEXT NOT NULL,
|
||||
einstimmig INTEGER NOT NULL DEFAULT 0,
|
||||
fraktionen_ja TEXT NOT NULL DEFAULT '[]',
|
||||
fraktionen_nein TEXT NOT NULL DEFAULT '[]',
|
||||
fraktionen_enthaltung TEXT NOT NULL DEFAULT '[]',
|
||||
quelle_protokoll TEXT NOT NULL,
|
||||
quelle_url TEXT,
|
||||
parsed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (bundesland, drucksache, quelle_protokoll)
|
||||
)
|
||||
""")
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pvr_bl_ds "
|
||||
"ON plenum_vote_results(bundesland, drucksache)"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@ -1181,3 +1207,88 @@ async def get_monitoring_new_today(scan_date: str) -> list[dict]:
|
||||
pass
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ─── Plenum-Vote-Results (#106) ─────────────────────────────────────────────
|
||||
# Fraktions-aggregierte Abstimmungsergebnisse aus Plenarprotokollen.
|
||||
# Quelle: protokoll_parser_nrw.py (NRW). BL-uebergreifender Parser ist #126.
|
||||
|
||||
async def upsert_plenum_vote(
|
||||
*,
|
||||
bundesland: str,
|
||||
drucksache: str,
|
||||
ergebnis: str,
|
||||
einstimmig: bool,
|
||||
fraktionen_ja: list[str],
|
||||
fraktionen_nein: list[str],
|
||||
fraktionen_enthaltung: list[str],
|
||||
quelle_protokoll: str,
|
||||
quelle_url: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Schreibt ein Abstimmungsergebnis aus einem Plenarprotokoll.
|
||||
|
||||
Idempotent ueber den Compound-PK (bundesland, drucksache, quelle_protokoll):
|
||||
derselbe Eintrag aus demselben Protokoll wird upgesertet, mehrfach-Voten
|
||||
derselben Drucksache aus verschiedenen Protokollen behalten beide Eintraege.
|
||||
"""
|
||||
import json as _json
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO plenum_vote_results
|
||||
(bundesland, drucksache, ergebnis, einstimmig,
|
||||
fraktionen_ja, fraktionen_nein, fraktionen_enthaltung,
|
||||
quelle_protokoll, quelle_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(bundesland, drucksache, quelle_protokoll) DO UPDATE SET
|
||||
ergebnis = excluded.ergebnis,
|
||||
einstimmig = excluded.einstimmig,
|
||||
fraktionen_ja = excluded.fraktionen_ja,
|
||||
fraktionen_nein = excluded.fraktionen_nein,
|
||||
fraktionen_enthaltung = excluded.fraktionen_enthaltung,
|
||||
quelle_url = excluded.quelle_url,
|
||||
parsed_at = datetime('now')
|
||||
""",
|
||||
(
|
||||
bundesland,
|
||||
drucksache,
|
||||
ergebnis,
|
||||
1 if einstimmig else 0,
|
||||
_json.dumps(fraktionen_ja, ensure_ascii=False),
|
||||
_json.dumps(fraktionen_nein, ensure_ascii=False),
|
||||
_json.dumps(fraktionen_enthaltung, ensure_ascii=False),
|
||||
quelle_protokoll,
|
||||
quelle_url,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_plenum_votes(bundesland: str, drucksache: str) -> list[dict]:
|
||||
"""Alle Plenarprotokoll-Abstimmungen fuer eine Drucksache, neueste zuerst.
|
||||
|
||||
Eine Drucksache kann mehrfach abgestimmt werden (z.B. Ueberweisung +
|
||||
finale Beschlussfassung), deshalb Liste statt Single.
|
||||
"""
|
||||
import json as _json
|
||||
async with aiosqlite.connect(settings.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
rows = await db.execute(
|
||||
"""
|
||||
SELECT * FROM plenum_vote_results
|
||||
WHERE bundesland = ? AND drucksache = ?
|
||||
ORDER BY parsed_at DESC
|
||||
""",
|
||||
(bundesland, drucksache),
|
||||
)
|
||||
out = []
|
||||
for r in await rows.fetchall():
|
||||
d = dict(r)
|
||||
d["einstimmig"] = bool(d.get("einstimmig"))
|
||||
for key in ("fraktionen_ja", "fraktionen_nein", "fraktionen_enthaltung"):
|
||||
try:
|
||||
d[key] = _json.loads(d.get(key) or "[]")
|
||||
except Exception:
|
||||
d[key] = []
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@ -552,3 +552,110 @@ class TestMerkliste:
|
||||
assert count == 1
|
||||
listed = run(database.merkliste_list("user1"))
|
||||
assert len([e for e in listed if e["antrag_id"] == "18/9001"]) == 1
|
||||
|
||||
|
||||
# ─── Plenum-Vote-Results (#106) ──────────────────────────────────────────────
|
||||
|
||||
class TestPlenumVoteResults:
|
||||
def test_creates_table(self, db_path):
|
||||
import aiosqlite
|
||||
from app import database
|
||||
run(database.init_db())
|
||||
|
||||
async def check():
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
cur = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name='plenum_vote_results'"
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
assert run(check()) is not None
|
||||
|
||||
def test_upsert_and_get_roundtrip(self, initialized_db):
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW",
|
||||
drucksache="18/1234",
|
||||
ergebnis="angenommen",
|
||||
einstimmig=False,
|
||||
fraktionen_ja=["CDU", "GRÜNE"],
|
||||
fraktionen_nein=["SPD", "AfD"],
|
||||
fraktionen_enthaltung=[],
|
||||
quelle_protokoll="MMP18-119",
|
||||
quelle_url="https://landtag.nrw.de/MMP18-119.pdf",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/1234"))
|
||||
assert len(result) == 1
|
||||
r = result[0]
|
||||
assert r["ergebnis"] == "angenommen"
|
||||
assert r["einstimmig"] is False
|
||||
assert r["fraktionen_ja"] == ["CDU", "GRÜNE"]
|
||||
assert r["fraktionen_nein"] == ["SPD", "AfD"]
|
||||
assert r["fraktionen_enthaltung"] == []
|
||||
assert r["quelle_protokoll"] == "MMP18-119"
|
||||
|
||||
def test_einstimmig_flag_persisted(self, initialized_db):
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/100", ergebnis="überwiesen",
|
||||
einstimmig=True, fraktionen_ja=[], fraktionen_nein=[],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-100",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/100"))
|
||||
assert result[0]["einstimmig"] is True
|
||||
|
||||
def test_idempotent_upsert_same_protokoll(self, initialized_db):
|
||||
"""Zweiter Upsert mit demselben Protokoll → ein Eintrag, neue Werte."""
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/200", ergebnis="abgelehnt",
|
||||
einstimmig=False, fraktionen_ja=["AfD"], fraktionen_nein=["CDU", "SPD"],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
|
||||
))
|
||||
# Re-Parse mit aktualisiertem Ergebnis
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/200", ergebnis="zurückgezogen",
|
||||
einstimmig=False, fraktionen_ja=[], fraktionen_nein=[],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/200"))
|
||||
assert len(result) == 1
|
||||
assert result[0]["ergebnis"] == "zurückgezogen"
|
||||
|
||||
def test_multiple_protokolle_keep_separate_records(self, initialized_db):
|
||||
"""Eine Drucksache, zwei Protokolle (Ueberweisung + finale Abstimmung)
|
||||
muessen beide erhalten bleiben."""
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/300", ergebnis="überwiesen",
|
||||
einstimmig=True, fraktionen_ja=[], fraktionen_nein=[],
|
||||
fraktionen_enthaltung=[], quelle_protokoll="MMP18-50",
|
||||
))
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/300", ergebnis="angenommen",
|
||||
einstimmig=False, fraktionen_ja=["CDU", "SPD"], fraktionen_nein=["AfD"],
|
||||
fraktionen_enthaltung=["GRÜNE"], quelle_protokoll="MMP18-119",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/300"))
|
||||
assert len(result) == 2
|
||||
protokolle = {r["quelle_protokoll"] for r in result}
|
||||
assert protokolle == {"MMP18-50", "MMP18-119"}
|
||||
|
||||
def test_empty_query_returns_empty_list(self, initialized_db):
|
||||
from app import database
|
||||
result = run(database.get_plenum_votes("NRW", "99/9999"))
|
||||
assert result == []
|
||||
|
||||
def test_unicode_in_fraktionen_persisted(self, initialized_db):
|
||||
"""GRÜNE mit Umlaut darf nicht ASCII-kodiert werden."""
|
||||
from app import database
|
||||
run(database.upsert_plenum_vote(
|
||||
bundesland="NRW", drucksache="18/400", ergebnis="angenommen",
|
||||
einstimmig=False, fraktionen_ja=["GRÜNE", "BÜNDNIS"],
|
||||
fraktionen_nein=[], fraktionen_enthaltung=[],
|
||||
quelle_protokoll="MMP18-1",
|
||||
))
|
||||
result = run(database.get_plenum_votes("NRW", "18/400"))
|
||||
assert "GRÜNE" in result[0]["fraktionen_ja"]
|
||||
assert "BÜNDNIS" in result[0]["fraktionen_ja"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user