""" 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å 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"'}, )