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()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@ -1181,3 +1207,88 @@ async def get_monitoring_new_today(scan_date: str) -> list[dict]:
|
|||||||
pass
|
pass
|
||||||
result.append(d)
|
result.append(d)
|
||||||
return result
|
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
|
assert count == 1
|
||||||
listed = run(database.merkliste_list("user1"))
|
listed = run(database.merkliste_list("user1"))
|
||||||
assert len([e for e in listed if e["antrag_id"] == "18/9001"]) == 1
|
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