Files
politics-tracking/backend/politrack/queries/money.py
T
simonandClaude Opus 5.5 583ccba31c Politrack: track Austrian politicians from public sources
Initial import of Politrack, a self-hosted service that tracks Austrian
politicians from federal to municipal level - roles, votes, money flows,
statements, meetings, social media and legal proceedings - with every claim
linked to its sources.

- backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse ->
  load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive
  for reparsing, and a claim/evidence model with corroboration
- sources: Parliament, Wikidata, EU Parliament/Commission, Council of
  Ministers, Rechnungshof party reports and donations, procurement, RTR media
  transparency, political ad libraries, WKStA, news feeds, Wikipedia edits,
  Bluesky, Mastodon, public Telegram channels, YouTube and X (official API)
- web: Next.js frontend in German and English with typed API client
- mcp: MCP server for AI agents and RAG
- docs: architecture, data model, sources, legal notes, Unraid deployment and
  contributor guides; Docker Compose setup for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:02:01 +01:00

115 lines
6.3 KiB
Python

"""Money flows (donations, contracts, government ads, party funding, political ads), social posts, wiki edits."""
from __future__ import annotations
from datetime import date
from psycopg import Connection
from politrack.queries.common import VISIBLE, SourceFilter, evidence_json
def list_money(conn: Connection, *, sources: SourceFilter, kinds: list[str] | None = None, org: str | None = None,
person: str | None = None, q: str | None = None, since: date | None = None, until: date | None = None,
min_amount: float | None = None, limit: int = 50, offset: int = 0) -> list[dict]:
"""Money flows, filterable by kind, organisation (either side), connected person and period.
`person` = flows of organisations connected to that person (side jobs, careers, party) - the
"follow the money" view.
"""
source_sql, params = sources.sql("s")
params.update({"kinds": kinds, "org": org, "person": person, "q": q, "since": since, "until": until,
"min_amount": min_amount, "limit": limit, "offset": offset, "include_pending": False})
return conn.execute(
f"""
WITH person_orgs AS (
SELECT c.counterpart_org_id AS org_id FROM claim c JOIN person p ON p.id = c.person_id
WHERE p.slug = %(person)s AND c.counterpart_org_id IS NOT NULL AND c.merged_into IS NULL
UNION SELECT r.party_id FROM role r JOIN person p ON p.id = r.person_id WHERE p.slug = %(person)s
)
SELECT c.id, c.summary_de, c.summary_en, c.occurred_at, c.status, c.data, mf.kind, mf.amount, mf.amount_category,
mf.currency, mf.paid_on, mf.period, mf.reference, mf.category, mf.payer_name, mf.payee_name,
pa.slug AS payer_slug, pa.kind AS payer_kind, pe.slug AS payee_slug, pe.kind AS payee_kind,
ev.evidence, ev.source_count
FROM money_flow mf
JOIN claim c ON c.id = mf.claim_id
LEFT JOIN organization pa ON pa.id = mf.payer_org_id
LEFT JOIN organization pe ON pe.id = mf.payee_org_id
{evidence_json(source_sql)}
WHERE ev.evidence IS NOT NULL AND {VISIBLE}
AND (%(kinds)s::text[] IS NULL OR mf.kind = ANY(%(kinds)s))
AND (%(org)s::text IS NULL OR pa.slug = %(org)s OR pe.slug = %(org)s)
AND (%(person)s::text IS NULL OR mf.payer_org_id IN (SELECT org_id FROM person_orgs)
OR mf.payee_org_id IN (SELECT org_id FROM person_orgs))
AND (%(q)s::text IS NULL OR mf.payer_name ILIKE '%%' || %(q)s || '%%' OR mf.payee_name ILIKE '%%' || %(q)s || '%%'
OR c.summary_de ILIKE '%%' || %(q)s || '%%')
AND (%(since)s::date IS NULL OR COALESCE(mf.paid_on, c.occurred_at::date, c.valid_from) >= %(since)s)
AND (%(until)s::date IS NULL OR COALESCE(mf.paid_on, c.occurred_at::date, c.valid_from) <= %(until)s)
AND (%(min_amount)s::numeric IS NULL OR mf.amount >= %(min_amount)s)
ORDER BY COALESCE(mf.paid_on, c.occurred_at::date, c.valid_from) DESC NULLS LAST, mf.amount DESC NULLS LAST
LIMIT %(limit)s OFFSET %(offset)s
""",
params,
).fetchall()
def money_summary(conn: Connection, *, kinds: list[str] | None = None, since: date | None = None) -> list[dict]:
"""Top payers/payees per kind (for the money overview page)."""
return conn.execute(
"""
SELECT mf.kind, o.slug, o.name, o.kind AS org_kind,
CASE WHEN o.id = mf.payer_org_id THEN 'payer' ELSE 'payee' END AS side,
sum(mf.amount) AS total, count(*) AS count
FROM money_flow mf JOIN claim c ON c.id = mf.claim_id AND c.merged_into IS NULL
JOIN organization o ON o.id IN (mf.payer_org_id, mf.payee_org_id)
WHERE (%(kinds)s::text[] IS NULL OR mf.kind = ANY(%(kinds)s))
AND (%(since)s::date IS NULL OR COALESCE(mf.paid_on, c.valid_from) >= %(since)s)
GROUP BY 1, 2, 3, 4, 5
ORDER BY total DESC NULLS LAST LIMIT 100
""",
{"kinds": kinds, "since": since},
).fetchall()
def social_feed(conn: Connection, *, sources: SourceFilter, person: str | None = None, party: str | None = None,
platforms: list[str] | None = None, limit: int = 50, offset: int = 0) -> list[dict]:
"""Public posts of politicians and of party/institution channels (`party` = the party's own channels
plus posts of its current members)."""
source_sql, params = sources.sql("s")
params.update({"person": person, "party": party, "platforms": platforms, "limit": limit, "offset": offset,
"include_pending": False})
return conn.execute(
f"""
SELECT c.id, st.platform, st.said_at, st.excerpt, st.url, st.title, c.data, p.slug AS person_slug,
p.full_name AS person_name, o.slug AS org_slug, o.name AS org_name, ev.evidence
FROM statement st JOIN claim c ON c.id = st.claim_id
LEFT JOIN person p ON p.id = st.person_id
LEFT JOIN organization o ON o.id = c.organization_id AND st.person_id IS NULL
{evidence_json(source_sql)}
WHERE st.context = 'social_post' AND ev.evidence IS NOT NULL AND {VISIBLE}
AND (%(person)s::text IS NULL OR p.slug = %(person)s)
AND (%(platforms)s::text[] IS NULL OR st.platform = ANY(%(platforms)s))
AND (%(party)s::text IS NULL OR o.slug = %(party)s OR EXISTS (
SELECT 1 FROM person_status ps JOIN organization po ON po.id = ANY(ps.current_party_ids)
WHERE ps.person_id = p.id AND po.slug = %(party)s))
ORDER BY st.said_at DESC NULLS LAST
LIMIT %(limit)s OFFSET %(offset)s
""",
params,
).fetchall()
def wiki_edits(conn: Connection, *, limit: int = 100, offset: int = 0) -> list[dict]:
return conn.execute(
"""
SELECT c.id, c.occurred_at, c.summary_de, c.summary_en, c.data, o.slug AS org_slug, o.name AS org_name,
p.slug AS person_slug, p.full_name AS person_name,
(SELECT d.url FROM claim_evidence e JOIN document d ON d.id = e.document_id WHERE e.claim_id = c.id LIMIT 1) AS url
FROM claim c JOIN organization o ON o.id = c.organization_id
LEFT JOIN person p ON p.id = c.counterpart_person_id
WHERE c.claim_type = 'wiki_edit' AND c.merged_into IS NULL
ORDER BY c.occurred_at DESC NULLS LAST LIMIT %s OFFSET %s
""",
(limit, offset),
).fetchall()