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>
160 lines
8.4 KiB
Python
160 lines
8.4 KiB
Python
"""Claims (the generic fact list), timeline histogram and calendar."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from psycopg import Connection
|
|
|
|
from politrack.queries.common import VISIBLE, SourceFilter
|
|
|
|
|
|
def list_claims(
|
|
conn: Connection,
|
|
*,
|
|
sources: SourceFilter,
|
|
person: str | None = None,
|
|
party: str | None = None,
|
|
organization: str | None = None,
|
|
claim_types: list[str] | None = None,
|
|
topics: list[str] | None = None,
|
|
since: date | None = None,
|
|
until: date | None = None,
|
|
q: str | None = None,
|
|
claim_id: int | None = None,
|
|
include_pending: bool = False,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
source_sql, params = sources.sql("s")
|
|
params.update({
|
|
"person": person, "party": party, "organization": organization, "claim_types": claim_types, "topics": topics, "since": since,
|
|
"until": until, "q": q, "claim_id": claim_id, "limit": limit, "offset": offset, "include_pending": include_pending,
|
|
})
|
|
return conn.execute(
|
|
f"""
|
|
SELECT c.id, c.claim_type, c.summary_de, c.summary_en, c.occurred_at, c.valid_from, c.valid_to, c.status,
|
|
c.sensitive, c.review_status, c.data,
|
|
p.slug AS person_slug, p.full_name AS person_name,
|
|
o.slug AS organization_slug, o.short_name AS organization_short, o.name AS organization_name,
|
|
lp.stage AS legal_stage, lp.offense AS legal_offense, lp.authority AS legal_authority,
|
|
cp.slug AS counterpart_slug, cp.name AS counterpart_name, cp.kind AS counterpart_kind,
|
|
cpp.slug AS counterpart_person_slug, cpp.full_name AS counterpart_person_name,
|
|
to_jsonb(mf) - 'claim_id' AS money, st.platform AS platform,
|
|
(SELECT jsonb_agg(jsonb_build_object('slug', t.slug, 'name_de', t.name_de, 'name_en', t.name_en))
|
|
FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id WHERE ta.claim_id = c.id) AS topics,
|
|
ev.evidence, ev.source_count
|
|
FROM claim c
|
|
LEFT JOIN person p ON p.id = c.person_id
|
|
LEFT JOIN organization o ON o.id = c.organization_id
|
|
LEFT JOIN legal_proceeding lp ON lp.claim_id = c.id
|
|
LEFT JOIN organization cp ON cp.id = c.counterpart_org_id
|
|
LEFT JOIN person cpp ON cpp.id = c.counterpart_person_id
|
|
LEFT JOIN money_flow mf ON mf.claim_id = c.id
|
|
LEFT JOIN statement st ON st.claim_id = c.id
|
|
CROSS JOIN LATERAL (
|
|
SELECT jsonb_agg(jsonb_build_object(
|
|
'url', d.url, 'title', d.title, 'source', s.slug, 'source_name', s.name, 'source_kind', s.kind,
|
|
'published_at', d.published_at, 'quote', e.quote, 'stance', e.stance, 'archive_url', d.archive_url)
|
|
ORDER BY s.default_trust DESC, d.published_at) AS evidence,
|
|
count(DISTINCT s.id) AS source_count
|
|
FROM claim_evidence e JOIN document d ON d.id = e.document_id JOIN source s ON s.id = d.source_id
|
|
WHERE e.claim_id = c.id AND {source_sql}
|
|
) ev
|
|
WHERE ev.evidence IS NOT NULL AND {VISIBLE}
|
|
AND (%(person)s::text IS NULL OR p.slug = %(person)s OR cpp.slug = %(person)s)
|
|
AND (%(party)s::text IS NULL OR o.slug = %(party)s
|
|
OR c.counterpart_org_id = (SELECT id FROM organization WHERE slug = %(party)s))
|
|
AND (%(organization)s::text IS NULL OR o.slug = %(organization)s
|
|
OR c.counterpart_org_id = (SELECT id FROM organization WHERE slug = %(organization)s))
|
|
AND (%(claim_types)s::text[] IS NULL OR c.claim_type = ANY(%(claim_types)s))
|
|
AND (%(topics)s::text[] IS NULL OR c.claim_type IN ('role', 'career') OR EXISTS (
|
|
SELECT 1 FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id
|
|
WHERE ta.claim_id = c.id AND t.slug = ANY(%(topics)s)))
|
|
AND (%(since)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_to, c.valid_from, current_date) >= %(since)s)
|
|
AND (%(until)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_from) <= %(until)s)
|
|
AND (%(q)s::text IS NULL OR c.summary_de ILIKE '%%' || %(q)s || '%%')
|
|
AND (%(claim_id)s::bigint IS NULL OR c.id = %(claim_id)s)
|
|
ORDER BY COALESCE(c.occurred_at, c.valid_from::timestamptz, c.created_at) DESC, c.id DESC
|
|
LIMIT %(limit)s OFFSET %(offset)s
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
|
|
def timeline_histogram(conn: Connection, *, sources: SourceFilter, person: str | None = None,
|
|
party: str | None = None) -> list[dict]:
|
|
"""Claim counts per month and type - drives the timeline overview."""
|
|
source_sql, params = sources.sql("s")
|
|
params.update({"person": person, "party": party, "include_pending": False})
|
|
return conn.execute(
|
|
f"""
|
|
SELECT to_char(date_trunc('month', COALESCE(c.occurred_at, c.valid_from::timestamptz)), 'YYYY-MM') AS month,
|
|
c.claim_type, count(DISTINCT c.id) AS count
|
|
FROM claim c
|
|
LEFT JOIN person p ON p.id = c.person_id
|
|
LEFT JOIN person cpp ON cpp.id = c.counterpart_person_id
|
|
LEFT JOIN organization o ON o.id = c.organization_id
|
|
JOIN claim_evidence e ON e.claim_id = c.id JOIN document d ON d.id = e.document_id
|
|
JOIN source s ON s.id = d.source_id
|
|
WHERE {VISIBLE} AND {source_sql} AND COALESCE(c.occurred_at, c.valid_from::timestamptz) IS NOT NULL
|
|
AND (%(person)s::text IS NULL OR p.slug = %(person)s OR cpp.slug = %(person)s)
|
|
AND (%(party)s::text IS NULL OR o.slug = %(party)s)
|
|
AND c.claim_type <> 'role'
|
|
GROUP BY 1, 2 ORDER BY 1
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
|
|
def calendar(conn: Connection, *, sources: SourceFilter, start: date, end: date, person: str | None = None,
|
|
party: str | None = None, kinds: list[str] | None = None) -> dict:
|
|
"""Events (sessions, trips, meetings, appearances) plus per-day claim counts in [start, end]."""
|
|
source_sql, params = sources.sql("s")
|
|
params.update({"start": start, "end": end, "person": person, "party": party, "kinds": kinds,
|
|
"include_pending": False})
|
|
events = conn.execute(
|
|
"""
|
|
SELECT ev.id, ev.kind, ev.title, ev.starts_at, ev.ends_at, ev.location_name, ev.country, ev.url,
|
|
ev.description, o.name AS body,
|
|
COALESCE(jsonb_agg(DISTINCT jsonb_build_object('slug', p.slug, 'name', p.full_name))
|
|
FILTER (WHERE p.id IS NOT NULL), '[]'::jsonb) AS participants
|
|
FROM event ev
|
|
LEFT JOIN organization o ON o.id = ev.organization_id
|
|
LEFT JOIN event_participant ep ON ep.event_id = ev.id
|
|
LEFT JOIN claim c ON c.id = ep.claim_id
|
|
LEFT JOIN person p ON p.id = ep.person_id AND c.merged_into IS NULL
|
|
WHERE ev.starts_at::date BETWEEN %(start)s AND %(end)s
|
|
AND (%(kinds)s::text[] IS NULL OR ev.kind = ANY(%(kinds)s))
|
|
AND (%(person)s::text IS NULL OR EXISTS (
|
|
SELECT 1 FROM event_participant ep2 JOIN person p2 ON p2.id = ep2.person_id
|
|
JOIN claim c2 ON c2.id = ep2.claim_id AND c2.merged_into IS NULL
|
|
WHERE ep2.event_id = ev.id AND p2.slug = %(person)s))
|
|
AND (%(party)s::text IS NULL OR EXISTS (
|
|
SELECT 1 FROM event_participant ep3 JOIN person_status ps3 ON ps3.person_id = ep3.person_id
|
|
JOIN organization o3 ON o3.id = ANY(ps3.current_party_ids)
|
|
WHERE ep3.event_id = ev.id AND o3.slug = %(party)s))
|
|
GROUP BY ev.id, o.name
|
|
ORDER BY ev.starts_at
|
|
LIMIT 2000
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
days = conn.execute(
|
|
f"""
|
|
SELECT c.occurred_at::date AS day, c.claim_type, count(DISTINCT c.id) AS count
|
|
FROM claim c
|
|
LEFT JOIN person p ON p.id = c.person_id
|
|
LEFT JOIN person cpp ON cpp.id = c.counterpart_person_id
|
|
LEFT JOIN organization o ON o.id = c.organization_id
|
|
JOIN claim_evidence e ON e.claim_id = c.id JOIN document d ON d.id = e.document_id
|
|
JOIN source s ON s.id = d.source_id
|
|
WHERE {VISIBLE} AND {source_sql} AND c.occurred_at::date BETWEEN %(start)s AND %(end)s
|
|
AND (%(person)s::text IS NULL OR p.slug = %(person)s OR cpp.slug = %(person)s)
|
|
AND (%(party)s::text IS NULL OR o.slug = %(party)s)
|
|
GROUP BY 1, 2 ORDER BY 1
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
return {"events": events, "days": days}
|