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>
169 lines
7.8 KiB
Python
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
|