teecup/app/routers/print_pdf.py
Erol Haagenrud 546900c2d6
Some checks failed
Backend-tester / test (push) Successful in 1m8s
Frontend-tester / test (push) Failing after 20s
Før endringer i turneringsoppsettet
2026-08-21 13:54:01 +02:00

152 lines
6.2 KiB
Python

"""
Server-generert PDF for utskriftsklare turneringsdokumenter (scorekort,
startliste, cart-tags, resultatliste). Se ARCHITECTURE_DECISIONS.md ADR-097.
Rendrer den EKTE Next.js-utskriftssiden i en headless Chromium (Playwright)
i stedet for å duplisere layouten i en egen server-side malmotor -- én kilde
til sannhet for visuell layout (samme React-komponent brukeren allerede ser
i forhåndsvisningen). Siden selv setter @page-CSS-regelen ut fra valgt
papirstørrelse/retning (styrt av URL-parametrene denne ruten konstruerer);
Playwright sin prefer_css_page_size=True bruker den regelen direkte -- ingen
egen mm-utregning her, dupliseres ikke.
Sesjonscookien fra DENNE forespørselen videreføres til Playwright sin
nettleserkontekst før navigering, slik at siden gjør sine egne fetch()-kall
til API-et akkurat som om en ekte innlogget nettleser lastet den -- ingen
egen auth-vei for renderingen.
`path` konstrueres ALDRI fra klient-input direkte (SSRF-hensyn) -- kun
`kind` + verifiserte IDer, malen for hver `kind` er hardkodet her.
"""
from typing import Literal
from fastapi import APIRouter, Depends, Request, Response
from playwright.async_api import async_playwright
from pydantic import BaseModel
from ..auth import SESSION_COOKIE_NAME, get_authorized_org
from ..db import org_connection
from ..errors import app_error
router = APIRouter()
# Samme Docker-nettverk som teecup_api (docker-compose.yml) -- internt
# tjenestenavn, aldri en offentlig URL.
_FRONTEND_ORIGIN = "http://teecup_frontend:3000"
_FRONTEND_HOST = "teecup_frontend"
PrintKind = Literal["scorecard", "startlist", "cart-tags", "resultlist"]
Paper = Literal["A4", "Letter", "A5", "A3", "A2"]
Orientation = Literal["portrait", "landscape"]
# resultlist er turneringsomfattende (leaderboard-endepunktene), trenger
# verken runde eller økt. De tre andre trenger ETT av (runde ELLER økt) --
# hvilket avhenger av om turneringen er individuell eller Cup-format, ikke
# noe denne ruten selv vet -- frontend sender riktig ID.
_SCOPED_KINDS: set[PrintKind] = {"scorecard", "startlist", "cart-tags"}
class PrintPdfRequest(BaseModel):
kind: PrintKind
tournament_id: str
round_id: str | None = None
session_id: str | None = None
paper: Paper = "A4"
orientation: Orientation = "portrait"
async def _verify_scope(conn, tournament_id: str, round_id: str | None, session_id: str | None) -> None:
# org_connection har allerede satt app.current_org -- RLS gjør at disse
# EXISTS-sjekkene stille returnerer usann for en rad i en ANNEN
# organisasjon, selv om raden faktisk finnes. Samme mønster som resten
# av API-et stoler på (ADR-001/003), ikke en egen sjekk her.
tournament_exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM tournament WHERE id = $1)", tournament_id
)
if not tournament_exists:
raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne IDen i organisasjonen.")
if round_id is not None:
exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM tournament_round WHERE id = $1 AND tournament_id = $2)",
round_id,
tournament_id,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Fant ingen runde med denne IDen i turneringen.")
if session_id is not None:
exists = await conn.fetchval(
'SELECT EXISTS (SELECT 1 FROM session WHERE id = $1 AND tournament_id = $2)',
session_id,
tournament_id,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Fant ingen økt med denne IDen i turneringen.")
def _build_path(organization_id: str, body: PrintPdfRequest) -> str:
params = [f"org={organization_id}", f"paper={body.paper}", f"orientation={body.orientation}"]
if body.round_id:
params.append(f"roundId={body.round_id}")
if body.session_id:
params.append(f"sessionId={body.session_id}")
return f"/tournaments/{body.tournament_id}/print/{body.kind}?{'&'.join(params)}"
@router.post("/orgs/{organization_id}/print/pdf")
async def render_print_pdf(
body: PrintPdfRequest,
request: Request,
organization_id: str = Depends(get_authorized_org),
) -> Response:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
if body.kind in _SCOPED_KINDS and not body.round_id and not body.session_id:
raise app_error(400, "MISSING_SCOPE", "Mangler runde- eller økt-ID for denne utskriftstypen.")
async with org_connection(organization_id) as conn:
await _verify_scope(conn, body.tournament_id, body.round_id, body.session_id)
url = f"{_FRONTEND_ORIGIN}{_build_path(organization_id, body)}"
async with async_playwright() as pw:
browser = await pw.chromium.launch()
try:
context = await browser.new_context()
await context.add_cookies(
[
{
"name": SESSION_COOKIE_NAME,
"value": token,
"domain": _FRONTEND_HOST,
"path": "/",
"httpOnly": True,
"secure": False,
}
]
)
page = await context.new_page()
await page.goto(url, wait_until="networkidle")
try:
# Hver utskriftsside setter dette attributtet på <body> først
# NÅR all data er hentet OG lagt inn i DOM-en -- printer vi
# før det, får vi enten "Laster..."-tilstanden eller mock-
# dataene siden startet med, ikke ekte innhold.
await page.wait_for_selector("[data-print-ready='true']", timeout=15000)
except Exception:
raise app_error(
504,
"PRINT_RENDER_TIMEOUT",
"Utskriftssiden ble ikke klar i tide. Prøv igjen.",
)
pdf_bytes = await page.pdf(print_background=True, prefer_css_page_size=True)
finally:
await browser.close()
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="teecup-{body.kind}.pdf"'},
)