diff --git a/app/database.py b/app/database.py index 651039a..5918d0c 100644 --- a/app/database.py +++ b/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 diff --git a/tests/test_database.py b/tests/test_database.py index 556c882..8bde599 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -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"]