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>
261 lines
11 KiB
Python
261 lines
11 KiB
Python
"""Public read API (JSON). OpenAPI docs at /api/docs; schema at /api/openapi.json.
|
|
|
|
Conventions: every list endpoint takes limit/offset; claim-like endpoints take the source filter
|
|
(exclude_sources, min_trust) and topic/date filters; slugs identify persons, parties and organisations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from datetime import date
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from psycopg import Connection
|
|
|
|
from politrack import queries
|
|
from politrack.api import schemas as s
|
|
from politrack.config import get_settings
|
|
from politrack.db import connection
|
|
from politrack.queries import SourceFilter
|
|
|
|
app = FastAPI(
|
|
title="Politrack API",
|
|
version="0.2.0",
|
|
description="Evidence-backed information on Austrian politicians and parties. Every claim links to its sources.",
|
|
docs_url="/api/docs",
|
|
openapi_url="/api/openapi.json",
|
|
)
|
|
app.add_middleware(CORSMiddleware, allow_origins=get_settings().api_cors_origins, allow_methods=["GET", "POST"],
|
|
allow_headers=["*"])
|
|
|
|
|
|
def db() -> Iterator[Connection]:
|
|
with connection() as conn:
|
|
yield conn
|
|
|
|
|
|
def csv_list(value: str | None) -> list[str] | None:
|
|
items = [v.strip() for v in (value or "").split(",") if v.strip()]
|
|
return items or None
|
|
|
|
|
|
def source_filter(
|
|
exclude_sources: Annotated[str | None, Query(description="Comma-separated source slugs to ignore")] = None,
|
|
min_trust: Annotated[int, Query(ge=0, le=100, description="Ignore sources below this trust score")] = 0,
|
|
) -> SourceFilter:
|
|
return SourceFilter(exclude_sources=csv_list(exclude_sources) or [], min_trust=min_trust)
|
|
|
|
|
|
DB = Annotated[Connection, Depends(db)]
|
|
Sources = Annotated[SourceFilter, Depends(source_filter)]
|
|
Limit = Annotated[int, Query(ge=1, le=500)]
|
|
Offset = Annotated[int, Query(ge=0)]
|
|
|
|
|
|
def not_found(value, what: str):
|
|
if not value:
|
|
raise HTTPException(404, f"{what} not found")
|
|
return value
|
|
|
|
|
|
@app.get("/healthz", include_in_schema=False)
|
|
def healthz(conn: DB) -> dict:
|
|
conn.execute("SELECT 1")
|
|
return {"ok": True}
|
|
|
|
|
|
# -- reference data -----------------------------------------------------------------------
|
|
|
|
@app.get("/api/v1/stats", response_model=s.Stats)
|
|
def get_stats(conn: DB):
|
|
return queries.stats(conn)
|
|
|
|
|
|
@app.get("/api/v1/sources", response_model=list[s.Source])
|
|
def get_sources(conn: DB):
|
|
return queries.list_sources(conn)
|
|
|
|
|
|
@app.get("/api/v1/topics", response_model=list[s.Topic])
|
|
def get_topics(conn: DB):
|
|
return queries.list_topics(conn)
|
|
|
|
|
|
# -- parties & organisations ----------------------------------------------------------------
|
|
|
|
@app.get("/api/v1/parties", response_model=list[s.Party])
|
|
def get_parties(conn: DB):
|
|
return queries.list_parties(conn)
|
|
|
|
|
|
@app.get("/api/v1/parties/{slug}", response_model=s.PartyDetail)
|
|
def get_party(slug: str, conn: DB):
|
|
return not_found(queries.get_party(conn, slug), "party")
|
|
|
|
|
|
@app.get("/api/v1/organizations", response_model=list[s.OrganizationListItem])
|
|
def get_organizations(conn: DB, q: str | None = None, kind: str | None = None, limit: Limit = 50, offset: Offset = 0):
|
|
return queries.list_organizations(conn, q=q, kind=kind, limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/organizations/{slug}", response_model=s.OrganizationDetail)
|
|
def get_organization(slug: str, conn: DB):
|
|
return not_found(queries.get_organization(conn, slug), "organization")
|
|
|
|
|
|
# -- persons --------------------------------------------------------------------------------
|
|
|
|
@app.get("/api/v1/persons", response_model=list[s.PersonListItem])
|
|
def get_persons(
|
|
conn: DB,
|
|
q: str | None = None,
|
|
party: str | None = None,
|
|
level: Annotated[str | None, Query(pattern="^(eu|federal|state|district|municipal|party|other)$")] = None,
|
|
status: Annotated[str, Query(pattern="^(active|former|party_member|deceased|all)$")] = "active",
|
|
limit: Limit = 50,
|
|
offset: Offset = 0,
|
|
):
|
|
return queries.list_persons(conn, q=q, party=party, level=level, status=None if status == "all" else status,
|
|
limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/former", response_model=list[s.FormerPerson])
|
|
def get_former(conn: DB, party: str | None = None, limit: Limit = 50, offset: Offset = 0):
|
|
"""Former politicians, most recently in the news first."""
|
|
return queries.former_overview(conn, party=party, limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/persons/{slug}", response_model=s.PersonDetail)
|
|
def get_person(slug: str, conn: DB):
|
|
return not_found(queries.get_person(conn, slug), "person")
|
|
|
|
|
|
@app.get("/api/v1/persons/{slug}/votes", response_model=list[s.PersonVote])
|
|
def get_person_votes(slug: str, conn: DB, topics: str | None = None, limit: Limit = 100, offset: Offset = 0):
|
|
return queries.person_votes(conn, slug, topics=csv_list(topics), limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/persons/{slug}/network", response_model=s.Network)
|
|
def get_person_network(slug: str, conn: DB):
|
|
"""Organisations connected to the person (side jobs, careers, party, meetings) and their money flows."""
|
|
return queries.person_network(conn, slug)
|
|
|
|
|
|
# -- claims, timeline, calendar ----------------------------------------------------------------
|
|
|
|
@app.get("/api/v1/claims", response_model=list[s.Claim])
|
|
def get_claims(
|
|
conn: DB,
|
|
sources: Sources,
|
|
person: str | None = None,
|
|
party: str | None = None,
|
|
organization: str | None = None,
|
|
types: Annotated[str | None, Query(description="Comma-separated claim types")] = None,
|
|
topics: Annotated[str | None, Query(description="Comma-separated topic slugs")] = None,
|
|
since: date | None = None,
|
|
until: date | None = None,
|
|
q: str | None = None,
|
|
limit: Limit = 50,
|
|
offset: Offset = 0,
|
|
):
|
|
return queries.list_claims(conn, sources=sources, person=person, party=party, organization=organization,
|
|
claim_types=csv_list(types), topics=csv_list(topics), since=since, until=until, q=q,
|
|
limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/claims/{claim_id}", response_model=s.Claim)
|
|
def get_claim(claim_id: int, conn: DB, sources: Sources):
|
|
"""One claim with all its evidence - the target of permalinks (callers normally omit the source filter)."""
|
|
rows = queries.list_claims(conn, sources=sources, claim_id=claim_id, limit=1)
|
|
return not_found(rows[0] if rows else None, "claim")
|
|
|
|
|
|
@app.get("/api/v1/timeline", response_model=list[s.TimelineBucket])
|
|
def get_timeline(conn: DB, sources: Sources, person: str | None = None, party: str | None = None):
|
|
return queries.timeline_histogram(conn, sources=sources, person=person, party=party)
|
|
|
|
|
|
@app.get("/api/v1/calendar", response_model=s.CalendarData)
|
|
def get_calendar(conn: DB, sources: Sources, start: date, end: date, person: str | None = None,
|
|
party: str | None = None, kinds: str | None = None):
|
|
if (end - start).days > 400:
|
|
raise HTTPException(400, "range too large (max 400 days)")
|
|
return queries.calendar(conn, sources=sources, start=start, end=end, person=person, party=party,
|
|
kinds=csv_list(kinds))
|
|
|
|
|
|
# -- money, social, wikipedia ---------------------------------------------------------------------
|
|
|
|
@app.get("/api/v1/money", response_model=list[s.MoneyFlow])
|
|
def get_money(conn: DB, sources: Sources, kinds: str | None = None, org: str | None = None, person: str | None = None,
|
|
q: str | None = None, since: date | None = None, until: date | None = None,
|
|
min_amount: float | None = None, limit: Limit = 50, offset: Offset = 0):
|
|
"""Donations, contracts, government ads, party funding, sponsoring, political ads."""
|
|
return queries.list_money(conn, sources=sources, kinds=csv_list(kinds), org=org, person=person, q=q, since=since,
|
|
until=until, min_amount=min_amount, limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/money/summary", response_model=list[s.MoneySummaryRow])
|
|
def get_money_summary(conn: DB, kinds: str | None = None, since: date | None = None):
|
|
return queries.money_summary(conn, kinds=csv_list(kinds), since=since)
|
|
|
|
|
|
@app.get("/api/v1/social", response_model=list[s.SocialPost])
|
|
def get_social(conn: DB, sources: Sources, person: str | None = None, party: str | None = None,
|
|
platforms: str | None = None, limit: Limit = 50, offset: Offset = 0):
|
|
return queries.social_feed(conn, sources=sources, person=person, party=party, platforms=csv_list(platforms),
|
|
limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/wiki-edits", response_model=list[s.WikiEdit])
|
|
def get_wiki_edits(conn: DB, limit: Limit = 100, offset: Offset = 0):
|
|
return queries.wiki_edits(conn, limit=limit, offset=offset)
|
|
|
|
|
|
# -- votes, search ------------------------------------------------------------------------------
|
|
|
|
@app.get("/api/v1/votes", response_model=list[s.VoteItem])
|
|
def get_votes(conn: DB, party: str | None = None, topics: str | None = None, roll_call: bool | None = None,
|
|
since: date | None = None, until: date | None = None, limit: Limit = 50, offset: Offset = 0):
|
|
return queries.list_votes(conn, party=party, topics=csv_list(topics), roll_call=roll_call, since=since,
|
|
until=until, limit=limit, offset=offset)
|
|
|
|
|
|
@app.get("/api/v1/votes/{vote_id}", response_model=s.VoteDetail)
|
|
def get_vote(vote_id: int, conn: DB):
|
|
return not_found(queries.vote_detail(conn, vote_id), "vote")
|
|
|
|
|
|
@app.get("/api/v1/search", response_model=list[s.SearchHit])
|
|
def get_search(conn: DB, sources: Sources, q: Annotated[str, Query(min_length=2)], since: date | None = None,
|
|
until: date | None = None, limit: Limit = 20):
|
|
return queries.search(conn, q, sources=sources, since=since, until=until, limit=limit)
|
|
|
|
|
|
# -- admin (POLITRACK_ADMIN_TOKEN) ------------------------------------------------------------------
|
|
|
|
def require_admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
|
token = get_settings().admin_token
|
|
if not token or authorization != f"Bearer {token}":
|
|
raise HTTPException(401, "admin token required")
|
|
return "admin-api"
|
|
|
|
|
|
@app.get("/api/v1/admin/runs", response_model=list[s.Run], include_in_schema=False)
|
|
def get_runs(conn: DB, _: Annotated[str, Depends(require_admin)], limit: Limit = 50):
|
|
return queries.recent_runs(conn, limit=limit)
|
|
|
|
|
|
@app.get("/api/v1/review", response_model=list[s.PendingClaim], include_in_schema=False)
|
|
def get_review_queue(conn: DB, _: Annotated[str, Depends(require_admin)], limit: Limit = 50):
|
|
return queries.pending_claims(conn, limit=limit)
|
|
|
|
|
|
@app.post("/api/v1/review/{claim_id}", include_in_schema=False)
|
|
def post_review(claim_id: int, decision: Annotated[str, Query(pattern="^(published|rejected)$")], conn: DB,
|
|
reviewer: Annotated[str, Depends(require_admin)]) -> dict:
|
|
queries.set_review(conn, claim_id, decision, reviewer)
|
|
return {"ok": True}
|