From 546900c2d6e6c6e640454dc033cfd31daeb332fc Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 21 Aug 2026 13:54:01 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20endringer=20i=20turneringsoppsettet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 75 +- ARCHITECTURE_DECISIONS.md | 104 +++ CHANGELOG.md | 45 + Dockerfile | 4 + FEATURE_BACKLOG.md | 13 + __pycache__/handicap_engine.cpython-312.pyc | Bin 45314 -> 50202 bytes app/main.py | 2 + app/requirements.txt | 1 + app/routers/print_pdf.py | 152 ++++ .../tournaments/[id]/print/cart-tags/page.tsx | 33 + .../[id]/print/resultlist/page.tsx | 31 + .../tournaments/[id]/print/scorecard/page.tsx | 33 + .../tournaments/[id]/print/startlist/page.tsx | 36 + .../individual-tournament-detail.tsx | 14 + frontend/components/print-cart-tags.tsx | 459 +++++++++++ frontend/components/print-menu.tsx | 49 ++ frontend/components/print-resultlist.tsx | 704 ++++++++++++++++ frontend/components/print-scorecard.tsx | 779 ++++++++++++++++++ frontend/components/print-startlist.tsx | 615 ++++++++++++++ frontend/components/round-groups-panel.tsx | 24 +- .../components/tournament-leaderboard.tsx | 6 + frontend/components/tournament-program.tsx | 31 + frontend/lib/print-data.ts | 268 ++++++ frontend/tsconfig.tsbuildinfo | 2 +- 24 files changed, 3470 insertions(+), 10 deletions(-) create mode 100644 app/routers/print_pdf.py create mode 100644 frontend/app/tournaments/[id]/print/cart-tags/page.tsx create mode 100644 frontend/app/tournaments/[id]/print/resultlist/page.tsx create mode 100644 frontend/app/tournaments/[id]/print/scorecard/page.tsx create mode 100644 frontend/app/tournaments/[id]/print/startlist/page.tsx create mode 100644 frontend/components/print-cart-tags.tsx create mode 100644 frontend/components/print-menu.tsx create mode 100644 frontend/components/print-resultlist.tsx create mode 100644 frontend/components/print-scorecard.tsx create mode 100644 frontend/components/print-startlist.tsx create mode 100644 frontend/lib/print-data.ts 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 22e77c2c8120049353b8e6c97185845a30e0149e..fce61dcf22390e1618d4ce0aead71a9ebd8fde17 100644 GIT binary patch delta 11599 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%0AS4Tl1WcHR2`UsSAzDL1cWa-MetnV5NL<%(v4> zZzGTcK;wa2DqEkixdpFMjquUMnWm%2_|gEO&u@yUMmcRIf=&P-&w?0uUz@>t;a#m5 z@kcEkG3847TECE7hWbx!+B^L3iIOU&$9uzWNYgB6pKXt+QFpbe&U$q-E>erfQr+TI z>u`CVyOLn<#$e=&=4e|c-)Ydra`GwT%M4H%0cu5n8a~O4f)n>if?mkD8pE1l&ehCC zPGZpeu0PWr>>BS1ZN!lNcL%6pUuOTCh?L_Acr_tKX8TtZyu%7aVGoa!U&%cXprivD zE}Vfs5LXTaaRmR<;aX z(F$o#2G1+W2}16u!(~hd1!_zM=PUW)K{EKoD|5C;*H4kdAPL1Ht;~Wp%t150EZ?tr z|KRH9QZrvtqCf#Fg^Kk#adtCb2Uhe|TJZ zD{==Xy4za4O>)IfvrsLHU8H=RKn@>KSi2qAv?m4@I9df8)dlIA#A9Sk7ijS~QGtq= zDPS$rJor~_m-Grff1i{_IkcOHH;$mSyGERb%-6b9`Zgoz8;ro(^M92P{X}gg*hwG= z=&OUE?+ao-0ev^{*Ukx|=>1r}!piqY`C8`>_9_Wphw#@^y>AowE&;u3i9ZtDCisGY zUTnnI1oTGYpP286qLLL+lpS591>Kwd*HXGpirEBoZ;%%dLFc=mgIvf{na)=KDaaY- zDCj^FbVvz0lLQ?Lf{p?~+q(ZqNv7`T>%?Ga8SeXSO=`I~gHrsjb3JSV^Bd25zX_Yj za&Cl03>dJz8J9SqU^@`1OGp_Y$$(jB3i~#ks2RHStZ%|oS=5cF>;VI|H_h<_3bq3# zUHp`R5TeXvM>+!$WEV-)a;8hQ41`T!)*BWy#P()%!hnJ;8fw*Ver04cVe#+QnC~jF JEJJ>d`9G28^(p`W 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 && (