Files
politics-tracking/backend/politrack/queries/organizations.py
T
simonandClaude Opus 5.5 456f9816f4 Redesign the web app from a written design concept
Design concept and design system in docs/design/: principles (evidence
first, neutral, fair to the people shown, plain language, time as a
dimension, accessible and private), information architecture, core
patterns and a concept for every page; tokens with checked contrast,
component catalogue, content and accessibility rules.

web:
- neutral ink-indigo accent instead of flag red; party colours only as
  small markers next to the name; light/dark theme with a switcher
- stylesheets split into tokens, base, layout, components, print
- new header (search, filter count, language, theme, mobile menu) and
  footer; breadcrumbs, page headers, tabs with counts, filter bars that
  collapse on phones, pagination, notices and empty states
- claim cards with source meter, legal stage, innocence notice inside
  the card and a permalink; claim pages with citation (/angaben/{id})
- profiles with role timeline and key figures; party and organisation
  dashboards; vote list with position chips; calendar agenda on phones;
  tables that stack into cards; loading and error pages; favicon

backend:
- GET /api/v1/claims/{id} and MCP tool get_claim for permalinks
- parties list: evaluate person_status once instead of per party
  (30 s -> 1 s on the sample database, identical result)
- parliament calendar: HTML-decode titles ("84. Sitzung")

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

169 lines
7.8 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(
"""
-- person_status is an expensive view: evaluate it once, not once per party
WITH active AS (
SELECT party_id, count(DISTINCT ps.person_id) AS n
FROM person_status ps, unnest(ps.current_party_ids) AS party_id
WHERE ps.status = 'active'
GROUP BY party_id
)
SELECT o.slug, o.name, o.short_name, o.color, o.logo_url,
COALESCE(a.n, 0) AS active_officials,
count(DISTINCT r.person_id) AS total_people
FROM organization o
LEFT JOIN role r ON r.party_id = o.id
LEFT JOIN active a ON a.party_id = o.id
WHERE o.kind = 'party'
GROUP BY o.id, a.n
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