Files
politics-tracking/backend/politrack/api/main.py
T
Simon DiesenreiterandClaude Opus 5.5 9da5a1dc48 Politrack: track Austrian politicians from public sources
Initial import of Politrack, a self-hosted service that tracks Austrian
politicians from federal to municipal level - roles, votes, money flows,
statements, meetings, social media and legal proceedings - with every claim
linked to its sources.

- backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse ->
  load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive
  for reparsing, and a claim/evidence model with corroboration
- sources: Parliament, Wikidata, EU Parliament/Commission, Council of
  Ministers, Rechnungshof party reports and donations, procurement, RTR media
  transparency, political ad libraries, WKStA, news feeds, Wikipedia edits,
  Bluesky, Mastodon, public Telegram channels, YouTube and X (official API)
- web: Next.js frontend in German and English with typed API client
- mcp: MCP server for AI agents and RAG
- docs: architecture, data model, sources, legal notes, Unraid deployment and
  contributor guides; Docker Compose setup for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:47:14 +01:00

254 lines
10 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/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}