Files
politics-tracking/backend/politrack/queries/claims.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

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}