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>
161 lines
7.5 KiB
Python
161 lines
7.5 KiB
Python
"""Parties and organisations (companies, public bodies, media owners, lobby groups)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from psycopg import Connection
|
|
|
|
|
|
def list_parties(conn: Connection) -> list[dict]:
|
|
return conn.execute(
|
|
"""
|
|
SELECT o.slug, o.name, o.short_name, o.color, o.logo_url,
|
|
(SELECT count(*) FROM person_status ps WHERE ps.status = 'active' AND o.id = ANY(ps.current_party_ids))
|
|
AS active_officials,
|
|
count(DISTINCT r.person_id) AS total_people
|
|
FROM organization o LEFT JOIN role r ON r.party_id = o.id
|
|
WHERE o.kind = 'party'
|
|
GROUP BY o.id
|
|
ORDER BY active_officials DESC, total_people DESC, o.name
|
|
"""
|
|
).fetchall()
|
|
|
|
|
|
def get_party(conn: Connection, slug: str) -> dict | None:
|
|
party = conn.execute(
|
|
"SELECT id, slug, name, short_name, color, logo_url, kind FROM organization WHERE slug = %s", (slug,)
|
|
).fetchone()
|
|
if not party:
|
|
return None
|
|
pid = party["id"]
|
|
party["members"] = conn.execute(
|
|
"""
|
|
SELECT DISTINCT ON (p.id) p.slug, p.full_name, p.photo_url, r.role, r.level, r.region, r.start_date,
|
|
ch.name AS body
|
|
FROM role r
|
|
JOIN claim c ON c.id = r.claim_id AND c.merged_into IS NULL
|
|
JOIN person p ON p.id = r.person_id
|
|
JOIN person_status ps ON ps.person_id = p.id AND ps.status = 'active'
|
|
LEFT JOIN organization ch ON ch.id = r.organization_id
|
|
WHERE r.party_id = %s AND (r.end_date IS NULL OR r.end_date >= current_date) AND r.level <> 'party'
|
|
AND r.role = ANY(ps.current_roles)
|
|
ORDER BY p.id, CASE r.level WHEN 'eu' THEN 0 WHEN 'federal' THEN 1 WHEN 'state' THEN 2 ELSE 3 END
|
|
""",
|
|
(pid,),
|
|
).fetchall()
|
|
party["former_members"] = conn.execute(
|
|
"""
|
|
SELECT p.slug, p.full_name, ps.last_office_end,
|
|
(SELECT max(c.occurred_at) FROM claim c WHERE c.person_id = p.id AND c.claim_type = 'media_mention')
|
|
AS last_mention
|
|
FROM person p JOIN person_status ps ON ps.person_id = p.id
|
|
WHERE ps.status IN ('former', 'party_member') AND EXISTS (SELECT 1 FROM role r WHERE r.person_id = p.id AND r.party_id = %s)
|
|
ORDER BY last_mention DESC NULLS LAST, ps.last_office_end DESC NULLS LAST LIMIT 50
|
|
""",
|
|
(pid,),
|
|
).fetchall()
|
|
party["money_by_year"] = conn.execute(
|
|
"""
|
|
SELECT COALESCE(extract(year FROM mf.paid_on)::int, left(mf.period, 4)::int) AS year, mf.kind,
|
|
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
|
|
WHERE (mf.payee_org_id = %(id)s OR c.organization_id = %(id)s)
|
|
AND mf.kind IN ('donation', 'sponsorship', 'party_ad', 'party_funding', 'loan')
|
|
AND COALESCE(mf.paid_on::text, mf.period) ~ '^\\d{4}'
|
|
GROUP BY 1, 2 ORDER BY 1 DESC, 2
|
|
""",
|
|
{"id": pid},
|
|
).fetchall()
|
|
party["finance"] = conn.execute(
|
|
"""SELECT c.data, c.valid_from FROM claim c WHERE c.organization_id = %s AND c.claim_type = 'party_finance'
|
|
AND c.merged_into IS NULL ORDER BY c.valid_from DESC NULLS LAST LIMIT 5""",
|
|
(pid,),
|
|
).fetchall()
|
|
party["vote_stats"] = conn.execute(
|
|
"SELECT vp.position, count(*) AS count FROM vote_position vp WHERE vp.organization_id = %s GROUP BY 1", (pid,)
|
|
).fetchall()
|
|
party["dissent"] = conn.execute(
|
|
"""
|
|
SELECT p.slug, p.full_name, count(*) AS dissenting_votes
|
|
FROM vote_position vp
|
|
JOIN person p ON p.id = vp.person_id
|
|
JOIN vote_position fp ON fp.vote_event_id = vp.vote_event_id AND fp.organization_id = vp.party_id
|
|
WHERE vp.party_id = %s AND vp.position <> fp.position
|
|
GROUP BY p.id ORDER BY dissenting_votes DESC LIMIT 20
|
|
""",
|
|
(pid,),
|
|
).fetchall()
|
|
party["related"] = conn.execute(
|
|
"""SELECT c.claim_type, o.slug, o.name, o.kind FROM claim c JOIN organization o ON o.id = c.counterpart_org_id
|
|
WHERE c.organization_id = %s AND c.claim_type IN ('ownership', 'affiliation') AND c.merged_into IS NULL
|
|
ORDER BY c.claim_type, o.name""",
|
|
(pid,),
|
|
).fetchall()
|
|
return party
|
|
|
|
|
|
def list_organizations(conn: Connection, *, q: str | None = None, kind: str | None = None, limit: int = 50,
|
|
offset: int = 0) -> list[dict]:
|
|
return conn.execute(
|
|
"""
|
|
SELECT o.slug, o.name, o.short_name, o.kind,
|
|
(SELECT sum(amount) FROM money_flow WHERE payer_org_id = o.id) AS paid,
|
|
(SELECT sum(amount) FROM money_flow WHERE payee_org_id = o.id) AS received
|
|
FROM organization o
|
|
WHERE (%(q)s::text IS NULL OR o.name ILIKE '%%' || %(q)s || '%%'
|
|
OR EXISTS (SELECT 1 FROM alias a WHERE a.entity_type = 'organization' AND a.entity_id = o.id
|
|
AND a.alias_norm LIKE '%%' || lower(unaccent(%(q)s)) || '%%'))
|
|
AND (%(kind)s::text IS NULL OR o.kind = %(kind)s)
|
|
ORDER BY COALESCE((SELECT sum(amount) FROM money_flow WHERE payer_org_id = o.id OR payee_org_id = o.id), 0) DESC,
|
|
o.name
|
|
LIMIT %(limit)s OFFSET %(offset)s
|
|
""",
|
|
{"q": q, "kind": kind, "limit": limit, "offset": offset},
|
|
).fetchall()
|
|
|
|
|
|
def get_organization(conn: Connection, slug: str) -> dict | None:
|
|
org = conn.execute(
|
|
"""SELECT o.id, o.slug, o.name, o.short_name, o.kind, parent.slug AS parent_slug, parent.name AS parent_name,
|
|
(SELECT jsonb_object_agg(i.scheme, i.value) FROM identifier i
|
|
WHERE i.entity_type = 'organization' AND i.entity_id = o.id) AS identifiers
|
|
FROM organization o LEFT JOIN organization parent ON parent.id = o.parent_id WHERE o.slug = %s""",
|
|
(slug,),
|
|
).fetchone()
|
|
if not org:
|
|
return None
|
|
oid = org["id"]
|
|
org["money"] = conn.execute(
|
|
"""
|
|
SELECT mf.kind, CASE WHEN mf.payer_org_id = %(id)s THEN 'paid' ELSE 'received' END AS direction,
|
|
sum(mf.amount) AS total, count(*) AS count, min(COALESCE(mf.paid_on::text, mf.period)) AS first,
|
|
max(COALESCE(mf.paid_on::text, mf.period)) AS last
|
|
FROM money_flow mf JOIN claim c ON c.id = mf.claim_id AND c.merged_into IS NULL
|
|
WHERE mf.payer_org_id = %(id)s OR mf.payee_org_id = %(id)s
|
|
GROUP BY 1, 2 ORDER BY 3 DESC NULLS LAST
|
|
""",
|
|
{"id": oid},
|
|
).fetchall()
|
|
org["counterparties"] = conn.execute(
|
|
"""
|
|
SELECT o.slug, o.name, o.kind, mf.kind AS money_kind,
|
|
CASE WHEN mf.payer_org_id = %(id)s THEN 'paid_to' ELSE 'received_from' END AS direction,
|
|
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 = CASE WHEN mf.payer_org_id = %(id)s THEN mf.payee_org_id ELSE mf.payer_org_id END
|
|
WHERE mf.payer_org_id = %(id)s OR mf.payee_org_id = %(id)s
|
|
GROUP BY 1, 2, 3, 4, 5 ORDER BY total DESC NULLS LAST LIMIT 50
|
|
""",
|
|
{"id": oid},
|
|
).fetchall()
|
|
org["people"] = conn.execute(
|
|
"""
|
|
SELECT DISTINCT ON (p.id, c.claim_type) p.slug, p.full_name, c.claim_type AS via, c.summary_de
|
|
FROM claim c JOIN person p ON p.id = c.person_id
|
|
WHERE c.counterpart_org_id = %(id)s AND c.merged_into IS NULL AND c.review_status = 'published'
|
|
AND c.claim_type IN ('side_job', 'career', 'meeting', 'role', 'lobbying')
|
|
ORDER BY p.id, c.claim_type LIMIT 100
|
|
""",
|
|
{"id": oid},
|
|
).fetchall()
|
|
return org
|