Files
politics-tracking/backend/politrack/api/main.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

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}