153 lines
6.2 KiB
Python
153 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"'},
|
||
|
|
)
|