"""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}