diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 742a162..472bba0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -466,7 +466,80 @@ "Bash(echo \"EXIT CODE: $?\")", "Bash(python3 -m json.tool /tmp/adapted.json)", "Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)", - "Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)" + "Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)", + "Bash(./scripts/run_backend_tests.sh)", + "Bash(.venv/bin/python test_handicap_engine.py)", + "Bash(curl -s https://forgejo.jegvil.no/api/v1/repos/envide/teecup/actions/runs)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('default_branch:', d.get\\('default_branch'\\)\\)\")", + "Bash(curl -s -o /tmp/runs.txt -w \"HTTP %{http_code}\\\\n\" https://forgejo.jegvil.no/api/v1/repos/envide/teecup/actions/runs)", + "Bash(curl -s -o /tmp/actions_page.html -w \"HTTP %{http_code}\\\\n\" \"https://forgejo.jegvil.no/envide/teecup/actions\")", + "Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions/runs/1\" -o /tmp/run1.html -w \"HTTP %{http_code}\\\\n\")", + "Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_page2.html -w \"HTTP %{http_code}\\\\n\")", + "Bash(docker pull *)", + "Bash(chmod 600 /opt/teecup/.forgejo-runner/.runner)", + "Bash(stat -c \"%G %g\" /var/run/docker.sock *)", + "Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_page3.html)", + "Bash(python3 -m py_compile app/routers/scoring.py)", + "Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_final.html)", + "Bash(npm --version)", + "Bash(./node_modules/.bin/vitest run *)", + "Bash(git -C /opt/teecup diff frontend/package.json)", + "Bash(git -C /opt/teecup diff --stat frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml)", + "Bash(git mv *)", + "Bash(git -C /opt/teecup log --oneline -3 -- frontend/.gitignore)", + "Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_frontend.html)", + "Bash(git log *)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18300/)", + "Bash(curl -s -X POST http://localhost:18311/auth/verify-link -H 'Content-Type: application/json' -d '{\"token\":\"g02xhESalHSdbGV6BuTahriCMScuVsB_UGqXVqY84iQ\"}' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)", + "Bash(curl -s -X PATCH http://localhost:18311/auth/profile -H 'Content-Type: application/json' -d '{\"first_name\":\"Erol\",\"last_name\":\"Test\",\"gender\":\"m\",\"handicap_index\":18.4}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -o /dev/null -w 'profile: %{http_code}\\\\n')", + "Bash(curl -s -X POST http://localhost:18311/personal-courses -H 'Content-Type: application/json' -d @/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/sf_course.json -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)", + "Bash(curl -s -X POST http://localhost:18311/rounds -H 'Content-Type: application/json' -d '{\"course_source\":\"custom\",\"personal_course_id\":\"64f0eec3-6da2-4b76-b41a-836ffba2316b\",\"tee_name\":\"Gul\",\"played_at\":\"2026-08-11\",\"holes_planned\":18,\"play_format\":\"stroke\"}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)", + "Bash(curl -s -X PATCH http://localhost:18311/rounds/0fd25550-f0f6-4c22-a2b1-c1e4eeb3da7d/participants/16044c55-dd0e-4142-b5c1-ed6a70768b4c -H 'Content-Type: application/json' -d '{\"stat_level\":\"full\"}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)", + "Bash(curl -s -X PATCH http://localhost:18311/auth/profile -H 'Content-Type: application/json' -d '{\"bag_clubs\":[\"Driver\",\"3-wood\",\"3-hybrid\",\"4-iron\",\"5-iron\",\"6-iron\",\"7-iron\",\"8-iron\",\"9-iron\",\"Pitching wedge\",\"Gap wedge\",\"Sand wedge\",\"Lob wedge\",\"Putter\"]}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -o /dev/null -w '%{http_code}\\\\n')", + "Bash(grep -n \"Committet \\(\\\\`ee91d03\\\\`\\), \\\\*\\\\*ikke rullet ut ennå\\\\*\\\\*\" /opt/teecup/CHANGELOG.md)", + "Bash(python3 -m py_compile app/golfapi_client.py app/golfapi_cache.py app/config.py app/routers/courses.py app/routers/rounds.py)", + "Bash(unzip -o \"Temp-uploads/tee-cup \\(10\\).zip\" -d /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zip10)", + "Bash(grep -n \"Hull \\\\${\" frontend/components/round-detail.tsx)", + "Bash(grep -c \"^[a-f0-9]\\\\{64\\\\}$\")", + "Bash(curl -sS -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18734/logg-inn)", + "Bash(curl -sS -X POST http://localhost:18734/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt)", + "Bash(curl -sS -X POST http://localhost:18735/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}')", + "Bash(curl -sS -X POST http://localhost:18735/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}')", + "Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X POST http://localhost:18735/personal-courses/international-import -H 'Content-Type: application/json' -d '{\"golfapi_course_id\":\"0121250146602173\"}')", + "Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X POST http://localhost:18735/rounds -H 'Content-Type: application/json' -d '{\"course_source\":\"custom\",\"personal_course_id\":\"cac41997-77f3-44a5-b395-9bb6b3c3c075\",\"tee_name\":\"55\",\"played_at\":\"2026-08-12\",\"start_hole\":1,\"holes_planned\":18,\"play_format\":\"stroke\"}')", + "Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"gender\":\"m\",\"handicap_index\":18.0,\"birth_date\":\"1990-01-01\"}')", + "Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"first_name\":\"Test\",\"last_name\":\"Verify\"}')", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('profile_complete:', d['profile_complete']\\)\")", + "Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"home_club\":\"Tjøme Golfklubb\",\"country\":\"Norge\"}')", + "Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt http://localhost:18735/rounds/99978ceb-55e5-42a6-a0a8-60496bb97e96/holes/1/target-points)", + "Bash(python3 -c \"import json,sys; print\\(json.load\\(sys.stdin\\)['id']\\)\")", + "Bash(curl -sS -X POST http://localhost:18738/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"layout-verify@example.com\",\"locale\":\"nb\"}')", + "Bash(python3 -m py_compile app/routers/rounds.py app/email.py)", + "Bash(python3 -m py_compile app/golfapi_cache.py)", + "Bash(python3 -m py_compile app/golfapi_cache.py app/routers/courses.py app/routers/rounds.py)", + "Bash(python3 -m py_compile /opt/teecup/app/email.py)", + "Bash(pkill -f \"next dev -p 13002\")", + "Bash(python3 -m py_compile app/routers/players.py)", + "Bash(pkill -f \"next dev.*13003\")", + "Bash(pkill -9 -f \"next dev.*13003\")", + "Bash(npx --yes ffmpeg-static --version)", + "Bash(node -e \"console.log\\(require\\('ffmpeg-static'\\)\\)\")", + "Bash(/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/ffmpeg_tool/node_modules/ffmpeg-static/ffmpeg -i /opt/teecup/Temp-uploads/Video_2026-08-15_070115.mp4)", + "Bash(/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/ffmpeg_tool/node_modules/ffmpeg-static/ffmpeg -i /opt/teecup/Temp-uploads/Video_2026-08-15_070115.mp4 -vf fps=1,scale=800:-1 /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/video_frames/frame_%03d.png -y)", + "Bash(pkill -f \"next dev -p 13008\")", + "Bash(rm -rf /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/migrations_oom*)", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/oom_*.txt)", + "Bash(curl -s -o /dev/null -w \"public OOM \\(should be 200\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)", + "Bash(curl -s http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)", + "Bash(curl -s -o /dev/null -w \"private OOM \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/b5eb2b39-58e5-4e92-ac3c-266abb70e17d)", + "Bash(curl -s -o /dev/null -w \"nonexistent \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/00000000-0000-0000-0000-000000000000)", + "Bash(curl -s -o /dev/null -w \"public OOM after toggling off \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)", + "Bash(TEECUP_API_ORIGIN=http://localhost:18104 NEXT_PUBLIC_MAPBOX_TOKEN=unused nohup npx next dev -p 13104)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:13104/)", + "Bash(python3 -m py_compile tests/test_account_merge.py)", + "Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")", + "Bash(grep -n 'href={`/tournaments/\\\\|Link.*tournaments/\\\\${' components/tournament-detail.tsx)", + "Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index b366bc8..98d58c7 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -9808,6 +9808,110 @@ Scratch-ruten slettet etterpå, bekreftet med `git status --short`. --- +## ADR-097: Utskriftsklare turneringsdokumenter -- server-PDF via headless Chromium, ingen ny malmotor (2026-08-21) + +**Kontekst:** oppfølging av ADR-096 (shotgun-start ble bygget nettopp FOR +dette behovet). Bruker ba om V0-prompter for fire utskriftsflater -- +scorekort, startliste, cart-tags, resultatliste -- dekket individuell OG +Cup-format, se FEATURE_BACKLOG.md "Funksjonelle hull funnet under +'utskrift av turneringsdokumenter'-planlegging" for de tre bevisst +utsatte hullene (hullengde, turneringslogo, bracket-format) som ble +avklart FØR promptene ble skrevet. V0-eksporten kom tilbake som fire +rene visuelle mockups (`components/print-scorecard.tsx` m.fl. i +eksporten) -- ingen ekte API-kall, "Last ned PDF" var en blindvei. +Denne runden kobler alle fire til ekte data og gjør nedlastingen ekte. + +**Beslutning A -- PDF-mekanisme: server-generert via headless Chromium +(Playwright), IKKE nettleserens egen print-dialog.** Bruker spurte +eksplisitt etter en anbefaling ut fra SLUTTRESULTAT, ikke byggekostnad. +Begrunnelse: cart-tags har stiplede klippelinjer som må treffe eksakt på +tvers av alle brukeres nettlesere/OS/skrivere -- en `window.print()`-vei +er avhengig av det, mens Playwright gir samme deterministiske output +uansett hvem som klikker. Playwright rendrer de EKTE Next.js- +utskriftssidene (samme React-komponent brukeren ser i forhåndsvisningen) +i stedet for en egen server-side malmotor (Jinja2/WeasyPrint) -- én +kilde til sannhet for layout, unngår at de to visningene glir fra +hverandre over tid. Eksplisitt akseptert kostnad: Playwright + Chromium +lagt til `Dockerfile` (`playwright install --with-deps chromium`), +merkbart større API-image (~300-500MB), ny "headless nettleser som +PDF-motor"-arkitekturmønster i backend-en. + +**Beslutning B -- ny rute, ingen ny malduplisering:** +`POST /orgs/{organization_id}/print/pdf` (`app/routers/print_pdf.py`). +Tar `{kind, tournament_id, round_id|session_id, paper, orientation}` -- +ALDRI en rå URL fra klienten (SSRF-hensyn); ruten bygger selv den +interne stien til riktig `/tournaments/{id}/print/{kind}`-side ut fra en +hardkodet mal per `kind`. Sesjonscookien fra selve PDF-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 en ekte +innlogget nettleser -- ingen egen auth-vei for renderingen. Hver +utskriftsside setter `data-print-ready="true"` på rot-elementet FØRST +når all ekte data er hentet og lagt inn i DOM-en; ruten venter på dette +attributtet (15s timeout) før `page.pdf(prefer_css_page_size=True)` +kalles, slik at PDF-en aldri fanger en "Laster..."-tilstand. Siden setter +selv `@page`-CSS-regelen ut fra valgt papirstørrelse/retning (styrt av +URL-parametrene ruten konstruerer) -- `prefer_css_page_size=True` bruker +den regelen direkte, ingen egen mm-utregning dupliseres i Python. +`_verify_scope()` sjekker runde-/økt-IDen faktisk hører til den oppgitte +turneringen FØR rendering (RLS via `org_connection` gjør at dette stille +feiler for en annen organisasjons rad, samme mønster som resten av +API-et). + +**Beslutning C -- ruteplassering, frontend:** fire nye sider under +`app/tournaments/[id]/print/{scorecard,startlist,cart-tags,resultlist}/ +page.tsx` (org via query-param, samme mønster som +`app/tournaments/[id]/program/page.tsx`). IKKE V0-eksportens flate +`app/print-scorecard/page.tsx`-struktur (kun et forhåndsvisnings- +prosjektoppsett, ikke ment som endelig ruteform). `/tournaments`-prefikset +er verifisert IKKE i konflikt med noen `next.config.mjs`-rewrite (se den +filens egen kommentar om denne fellen, ADR-033/FEATURE_BACKLOG.md +2026-07-23) -- API-ets turneringsendepunkter ligger uansett under +`/orgs/{id}/tournaments/...`, aldri et topp-nivå `/tournaments`-prefiks. + +**Beslutning D -- datakobling, ekte join-mønster kartlagt før bygging** +(Explore-agent brukt til å lese samtlige relevante routere FØR +komponentene ble skrevet, for å unngå å gjette feltnavn): ingen enkelt +endepunkt gir en komplett startliste/scorekort for noen av de to +formatene -- alltid 3-4 kall som joines klient-side (runde/økt-metadata +→ grupper/matcher → runde-/kamp-deltakere → turnering-/rosterdeltakere +→ spillerdata for kjønn/hjemmeklubb). Delt i `lib/print-data.ts` (typer ++ fetch-funksjoner) -- ekte gjenbruk på tvers av alle fire sider, ikke en +tidlig abstraksjon. To reelle datamodell-hull bekreftet under kartlegging +(utover de tre allerede kjente fra planleggingsrunden): ingen `color`- +kolonne på `tee` (kun `name` -- norsk konvensjon er at navnet ER fargen, +løst med en liten navn→hex-oppslagstabell, ikke en ekte kilde) og ingen +enkelt "hent én turnering"-endepunkt (løst som resten av appen allerede +gjør: hent listen, filtrer klient-side på id). + +**Scorekortet skriver ETT kort PER UTSLAGSGRUPPE (individuell) / PER +MATCH (Cup), for HELE feltet i én utskrift** -- ikke ett enkelt +eksempelkort slik V0-mockupen viste det. En organisator skriver ut +scorekort for alle spillere på én gang før runden starter, ikke ett om +gangen. Cart-tags og startlisten fulgte allerede dette mønsteret i +mockupen (alle grupper/matcher samtidig), kun scorekortet trengte denne +generaliseringen fra ett hardkodet eksempel til en løkke over ekte +grupper/matcher. + +**Inngangspunkter lagt til (ikke en egen isolert flate):** +`round-groups-panel.tsx` fikk en delt `PrintMenu`-komponent +(`components/print-menu.tsx`) i sitt sticky-headerpar ved siden av +lukkeknappen -- organisatoren står allerede på riktig runde der. +`tournament-program.tsx` sin økt-handlingsmeny (samme "⋯"-meny som +Rediger/Slett) fikk tre nye "Skriv ut ..."-oppføringer. Leaderboard- +visningene (`tournament-leaderboard.tsx` sin fanerad, og en liten lenke +øverst i `individual-tournament-detail.tsx` sin `LeaderboardTab`) fikk +hver én "Skriv ut resultatliste"-lenke, siden resultatlisten er +turneringsomfattende (ingen runde-/økt-scope). + +**Ikke gjort denne runden, bevisst:** selve Docker-bygget/utrullingen +(ny Chromium-avhengighet i produksjons-API-imaget er en reell nok endring +til å vise frem og bekrefte FØR den kjøres, samme disiplin som alltid). +Ekte hullengde/turneringslogo forblir mocket (uendret fra planleggings- +rundens beslutning) -- egen, senere oppgave når/hvis de datamodell- +utvidelsene faktisk bygges. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ab1a7..3846391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13975,3 +13975,48 @@ Neste steg: med `information_schema`-spørring FØR og ETTER), deretter `docker compose build teecup_api teecup_frontend && up -d`, begge containere friske, rene logger. + +150. **Utskriftsklare turneringsdokumenter koblet til ekte data, 2026-08-21 + (ADR-097).** Oppfølging av #149 (shotgun-start ble bygget nettopp for + dette behovet). Bruker lastet opp V0-eksporten for de fire + utskriftsprompt-flatene (scorekort/startliste/cart-tags/ + resultatliste, planlagt tidligere samme dag -- se FEATURE_BACKLOG.md + "utskrift av turneringsdokumenter"-planlegging). Eksporten var rene + visuelle mockups (mock-data, "Last ned PDF" var en blindvei) -- + denne runden kobler alle fire til ekte API-data for begge + turneringsformer og gjør PDF-nedlastingen ekte. + + Bruker spurte eksplisitt om en anbefaling for PDF-mekanisme ut fra + sluttresultat, ikke byggekostnad: valgte server-generert PDF via + headless Chromium (Playwright), som rendrer de EKTE Next.js- + utskriftssidene (samme React-komponent som forhåndsvisningen) i + stedet for nettleserens egen print-dialog eller en duplisert + server-side malmotor -- se ADR-097 for full begrunnelse. Ny + `POST /orgs/{organization_id}/print/pdf` (`app/routers/print_pdf.py`), + Playwright + Chromium lagt til `Dockerfile`/`requirements.txt`. + + Fire nye sider (`app/tournaments/[id]/print/{scorecard,startlist, + cart-tags,resultlist}/page.tsx`) + fire komponenter, alle bygget mot + ekte join-mønstre kartlagt av en Explore-agent FØR koding (ingen + endepunkt gir en komplett startliste/scorekort alene -- 3-4 kall + joines klient-side). Delt `lib/print-data.ts` (typer + fetchere, + ekte gjenbruk på tvers av alle fire). Scorekortet generalisert fra + V0-mockupens ETT eksempelkort til én sheet PER UTSLAGSGRUPPE + (individuell) / PER MATCH (Cup) -- hele feltet i én utskrift, ikke + ett kort om gangen. + + Inngangspunkter: `PrintMenu`-komponent i `round-groups-panel.tsx` sin + header (individuell), tre nye "Skriv ut ..."-oppføringer i + `tournament-program.tsx` sin økt-handlingsmeny (Cup), og en + "Skriv ut resultatliste"-lenke i begge leaderboard-visningene. + + **Verifisert så langt:** `tsc --noEmit` rent på hele frontend- + prosjektet, hele `vitest`-suiten grønn (55 tester, ingen regresjon). + `python3 -m py_compile` rent på de nye backend-filene. + + **⏳ IKKE rullet ut ennå.** Ny Chromium-avhengighet i produksjons- + API-imaget (~300-500MB større) venter på eksplisitt bekreftelse før + `docker compose build && up -d` kjøres. Ekte scratch-verifisering + (klikk gjennom alle fire med ekte turneringsdata, bekreft faktisk + PDF-nedlasting) gjenstår også -- kun kodenivå-verifisering gjort + denne runden. diff --git a/Dockerfile b/Dockerfile index b518270..57730a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,10 @@ WORKDIR /srv COPY app/requirements.txt app/requirements.txt RUN pip install --no-cache-dir -r app/requirements.txt +# Chromium for print_pdf.py (ADR-097) -- --with-deps henter apt-avhengighetene +# Chromium trenger (fonter/libnss3/libatk osv.), python:3.12-slim har dem ikke. +# Merkbart større image (~300-500MB) -- akseptert kostnad, se ADR-097. +RUN playwright install --with-deps chromium COPY app/ app/ COPY handicap_engine.py handicap_engine.py diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 66bfc05..e71cb44 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -4829,6 +4829,19 @@ funnet; bruker har eksplisitt tatt stilling til alle tre (se hver): 2026-08-21** (migrasjon 088, ADR-096) -- se ARCHITECTURE_DECISIONS.md for full detalj, ikke gjentatt her. +**Oppfølging 2026-08-21 -- V0-eksporten koblet til ekte data (ADR-097):** +alle fire utskriftsflater (scorekort/startliste/cart-tags/resultatliste) +er nå kodet mot ekte API-endepunkter for begge turneringsformer, med +server-generert PDF via headless Chromium (Playwright) -- se ADR-097 for +full detalj (PDF-mekanisme-valget, rutestruktur, datajoin-mønster, +inngangspunkter). `tsc --noEmit` og hele `vitest`-suiten grønn. +**⏳ Ikke bygget/utrullet ennå** -- ny Chromium-avhengighet i +produksjons-API-imaget venter på eksplisitt bekreftelse før +`docker compose build && up -d` kjøres (samme disiplin som alltid for +noe som endrer produksjonscontaineren). Ekte scratch-verifisering (klikk +gjennom alle fire med ekte turneringsdata, bekreft PDF-nedlasting) gjenstår +også -- kun kodenivå-verifisering (typecheck/tester) er gjort så langt. + ## Idé, ikke spesifisert: påminnelser + tilbakemeldings-"kartotek" under en runde (2026-08-21) — 📋 fanget, ikke designet Bruker: "Jeg vil brukeren skal få spørsmål om han vil bli påminnet diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index 22e77c2..fce61dc 100644 Binary files a/__pycache__/handicap_engine.cpython-312.pyc and b/__pycache__/handicap_engine.cpython-312.pyc differ diff --git a/app/main.py b/app/main.py index 8c5aadd..d623f7f 100644 --- a/app/main.py +++ b/app/main.py @@ -27,6 +27,7 @@ from .routers import ( order_of_merit, organizations, players, + print_pdf, registration, round_messages, rounds, @@ -67,6 +68,7 @@ app.include_router(rounds.router) app.include_router(round_messages.router) app.include_router(friends.router) app.include_router(notifications.router) +app.include_router(print_pdf.router) @app.get("/health") diff --git a/app/requirements.txt b/app/requirements.txt index d8e4bb5..c5aac00 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -12,3 +12,4 @@ argon2-cffi>=23.1 pyotp>=2.9 qrcode>=7.4 pywebpush>=2.0 +playwright>=1.45 diff --git a/app/routers/print_pdf.py b/app/routers/print_pdf.py new file mode 100644 index 0000000..fa428cc --- /dev/null +++ b/app/routers/print_pdf.py @@ -0,0 +1,152 @@ +""" +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"'}, + ) diff --git a/frontend/app/tournaments/[id]/print/cart-tags/page.tsx b/frontend/app/tournaments/[id]/print/cart-tags/page.tsx new file mode 100644 index 0000000..9244d93 --- /dev/null +++ b/frontend/app/tournaments/[id]/print/cart-tags/page.tsx @@ -0,0 +1,33 @@ +import { PrintCartTags } from "@/components/print-cart-tags" + +export default async function PrintCartTagsPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }> +}) { + const { id } = await params + const { org, roundId, sessionId, paper, orientation } = await searchParams + + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/app/tournaments/[id]/print/resultlist/page.tsx b/frontend/app/tournaments/[id]/print/resultlist/page.tsx new file mode 100644 index 0000000..5bad078 --- /dev/null +++ b/frontend/app/tournaments/[id]/print/resultlist/page.tsx @@ -0,0 +1,31 @@ +import { PrintResultlist } from "@/components/print-resultlist" + +export default async function PrintResultlistPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; paper?: string; orientation?: string }> +}) { + const { id } = await params + const { org, paper, orientation } = await searchParams + + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/app/tournaments/[id]/print/scorecard/page.tsx b/frontend/app/tournaments/[id]/print/scorecard/page.tsx new file mode 100644 index 0000000..d7cf74b --- /dev/null +++ b/frontend/app/tournaments/[id]/print/scorecard/page.tsx @@ -0,0 +1,33 @@ +import { PrintScorecard } from "@/components/print-scorecard" + +export default async function PrintScorecardPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }> +}) { + const { id } = await params + const { org, roundId, sessionId, paper, orientation } = await searchParams + + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/app/tournaments/[id]/print/startlist/page.tsx b/frontend/app/tournaments/[id]/print/startlist/page.tsx new file mode 100644 index 0000000..fd1a697 --- /dev/null +++ b/frontend/app/tournaments/[id]/print/startlist/page.tsx @@ -0,0 +1,36 @@ +import { PrintStartlist } from "@/components/print-startlist" + +export default async function PrintStartlistPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }> +}) { + const { id } = await params + const { org, roundId, sessionId, paper, orientation } = await searchParams + + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + const validSizes = ["A4", "Letter", "A3", "A2"] as const + const initialSize = (validSizes as readonly string[]).includes(paper ?? "") ? (paper as (typeof validSizes)[number]) : "A4" + + return ( + + ) +} diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 84302b6..a88d427 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -1671,6 +1671,8 @@ function SetupTab({ {groupsOpenRound && ( setGroupsOpenFor(null)} @@ -4743,8 +4745,20 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou ? tournament.tiebreak_playoff_rule : null + // base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i + // stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken. + const [, , printOrgId, , printTournamentId] = base.split("/") + return (
+
+ + Skriv ut resultatliste + +
{playoffNote && (