Per-deltaker-fullføring av runder + scorekort på e-post; Larvik/Nesbyen via GolfAPI

Backend (ADR-065): round_participant.completed_at (migrasjon 066),
flight-styrt PATCH-tilgang, scorekort-e-post til gjest/lenket bruker
ved individuell fullføring og ved rundefullføring, samt et nytt
on-demand send-scorecard-endepunkt som virker uansett fullføringsstatus.
Frontend-UI kommer i egen runde.

Nye baner Larvik (Seasidebanen) og Nesbyen (manuelt GPS-registrert,
124 punkter) importert til produksjon. Fant og fikset en reell
GolfAPI-datakvalitetsbug underveis: tomme strenger i stedet for
null/fraværende på course rating/slope for begge kjønn (migrasjon
067/068, defensiv parsing + import-endepunkter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-13 21:24:25 +02:00
parent 648293e1f8
commit baa1c42d24
10 changed files with 628 additions and 120 deletions

View file

@ -0,0 +1,24 @@
-- =====================================================================
-- TeeCup — migrasjon 066
-- Per-deltaker avslutning av frittstående runder
-- =====================================================================
-- round.completed_at er rundeomfattende -- hele runden må fullføres
-- samtidig for ALLE deltakere, noe som ikke holder når spillere i
-- samme flight går ulikt antall hull (f.eks. to spiller 18, én 9, én
-- må avslutte etter 14). Denne kolonnen lar EN deltaker markeres
-- ferdig uavhengig av resten av flighten -- stopper "mangler
-- score"-nagging for DEM, uten å låse scoreføring for de som
-- fortsatt spiller (som `round.completed_at` ville gjort).
--
-- Rundens EGEN fullføring (`round.completed_at`, HCP-beregning,
-- varsler) forblir bevisst en separat, manuell handling (bekreftet
-- med bruker) -- denne kolonnen er KUN en nagging-brems +
-- e-post-trigger, ingen auto-cascade.
--
-- Ingen CHECK mot round.completed_at -- applikasjonslaget garanterer
-- rekkefølgen (app/routers/rounds.py).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round_participant ADD COLUMN completed_at timestamptz;

View file

@ -0,0 +1,26 @@
-- =====================================================================
-- TeeCup — migrasjon 067
-- Flere POI-typer for manuelt registrerte banepunkter
-- =====================================================================
-- GolfAPIs 12 faste POI-koder dekker ikke alt en bane faktisk kan ha --
-- konkret anledning: Nesbyen (Nesfjellet Golfklubb) manglet GPS-data
-- hos GolfAPI (hasGPS=0), og brukeren registrerte 124 punkter manuelt
-- fra egen bane-befaring. To av navnene der ("fjellknaus"/rock outcrop,
-- "til fairway"/siktepunkt for utslaget) har ingen match i dagens sett.
--
-- `rock` telles som en HINDRING i avstandsvisningen (samme klasse som
-- bunker/vann -- noe å styre unna), `layup` er rent informativt (et
-- siktepunkt, ikke noe å unngå) -- se _HAZARD_LABELS i
-- frontend/components/hole-target-distance.tsx.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE golfapi_course_coordinate DROP CONSTRAINT golfapi_course_coordinate_poi_type_check;
ALTER TABLE golfapi_course_coordinate ADD CONSTRAINT golfapi_course_coordinate_poi_type_check
CHECK (poi_type IN (
'green', 'green_bunker', 'fairway_bunker', 'water',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back',
'rock', 'layup'
));

View file

@ -0,0 +1,18 @@
-- =====================================================================
-- TeeCup — migrasjon 068
-- golfapi_course_tee: menns rating/slope kan mangle
-- =====================================================================
-- Reell anledning (Nesfjellet Golfklubb/Nesbyen, 2026-08-13): migrasjon
-- 065 antok (som ADR-019s teeoff-import) at menns rating alltid finnes
-- hos kilden -- kvinners er valgfritt. GolfAPI viste seg å returnere en
-- TOM STRENG for `slopeMen` på minst ett utslag her, ikke bare for
-- kvinner. `golfapi_course_tee` sin cache må derfor tåle at BEGGE
-- kjønn kan mangle -- selve import/kopier-inn-stegene (courses.py/
-- rounds.py) avviser fortsatt (EXTERNAL_DATA_INCOMPLETE) dersom et
-- utslag ender opp UTEN noen brukbar rating i det hele tatt.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE golfapi_course_tee ALTER COLUMN course_rating_men DROP NOT NULL;
ALTER TABLE golfapi_course_tee ALTER COLUMN slope_men DROP NOT NULL;

View file

@ -6215,6 +6215,50 @@ runde"-veiviseren, ingen konsollfeil, søket bekreftet returnere reelle
treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg) treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg)
for full detalj. for full detalj.
**Tillegg 2026-08-13 — to nye baner (Larvik/Seasidebanen, Nesbyen/
Nesfjellet) + en reell GolfAPI-datakvalitetsbug funnet under importen.**
Nesbyen har `hasGPS=0` hos GolfAPI (ingen koordinater), så koordinatene
ble registrert MANUELT av bruker (124 punkter, feltbefart) og satt inn
direkte i den samme delte `golfapi_course_coordinate`-cachen som
Beslutning C beskriver — skjemaet er kilde-agnostisk, rangefinderen
fungerer identisk uansett om et punkt kom fra API-et eller ble hånd-
registrert. To nye `poi_type`-verdier lagt til (migrasjon 067): `rock`
(fjellknaus — regnes som hinder) og `layup` (til fairway — kun
informativt, ikke et hinder).
Under selve importen krasjet koden to ganger på et reelt GolfAPI-funn:
tjenesten returnerer noen ganger en TOM STRENG (`""`) i stedet for
`null`/fraværende felt for `courseRatingMen`/`slopeMen`/
`courseRatingWomen`/`slopeWomen` — ikke bare for kvinner, som ADR-019s
tilsvarende teeoff-antakelse forutsatte, Nesbyen manglet `slopeMen`
også. Fikset på tre nivåer: (1) `golfapi_cache.py` normaliserer nå
`""``None` for alle fire ratingfelt FØR insert, (2)
`golfapi_course_tee.course_rating_men`/`slope_men` gjort nullable
(migrasjon 068 — var feilaktig `NOT NULL`), (3) begge import-
endepunktene (`courses.py`s `international-import`, `rounds.py`s
personal-course-ekvivalent) bygger nå rating per utslag defensivt —
hopper over et utslag HELT (ingen `tee`/`personal_course_tee`-rad) hvis
verken herre- eller dame-rating er brukbar, og avviser HELE importen
med `EXTERNAL_DATA_INCOMPLETE` FØR transaksjonen starter hvis
bokstavelig talt intet utslag har noen brukbar rating (fail-fast,
samme ADR-019 Beslutning C-filosofi). Nesbyens 6 utslag hadde faktisk
INGEN rating i det hele tatt hos GolfAPI (ikke bare tomme strenger på
enkeltfelt) — brukeren oppga alle 6 utslags course rating/slope
(herre+dame, ett utslag uten damerating) manuelt fra klubbens eget
scorekort, satt inn direkte i `golfapi_course_tee`-cachen før import.
**Verifisert:** `python3 -m py_compile` rent på alle tre endrede
Python-filer, full `./scripts/run_backend_tests.sh` (31/31, migrasjon
068 bekreftet anvendbar). Selve importen kjørt direkte mot ekte
`teecup_db` via et engangsskript i `teecup_api`-containeren (speiler
`import_international_personal_course`s kopier-inn-logikk nøyaktig) —
etterpå bekreftet med en read-only spørring: Larvik (18 hull, 6 utslag,
11 tee-ratinger, 95 koordinater) og Nesbyen (18 hull, 6 utslag, 11
tee-ratinger, 124 koordinater), begge eid av Erol Haagenrud, `golfapi_
course.has_gps`/`num_coordinates` korrigert for Nesbyen. **Rullet ut
2026-08-13** — migrasjon 067+068 kjørt mot ekte `teecup_db`,
`teecup_api` bygget/omstartet, ren oppstartslogg.
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
@ -6265,6 +6309,102 @@ Disse må avklares før eller under de relevante fasene:
--- ---
## ADR-065: Per-deltaker-fullføring av frittstående runder + scorekort på e-post — 2026-08-13
Brukerens konkrete scenario: fire spillere sammen i én runde, to går 18
hull, én går 9, én må avslutte etter 14. Med dagens modell
(`round.completed_at`, rundeomfattende) kan runden ikke fullføres for
de ferdige spillerne uten samtidig å låse scoreføring for de som
fortsatt spiller (`readOnly = completed` var alt-eller-ingenting) — og
ingen kunne få scorekortet sitt på e-post før HELE runden var ferdig.
Bekreftet med bruker (AskUserQuestion) og ett tillegg i etterkant:
1. **Rundens egen fullføring forblir MANUELL** — eieren trykker
fortsatt "Fullfør runde" til slutt (HCP-beregning/varsler skjer
fortsatt der, uendret). Per-deltaker-fullføring er KUN en nagging-
brems + trigger for e-post, IKKE en auto-cascade til hele rundens
fullføring.
2. **Scorekort på e-post sendes UMIDDELBART** når en spiller selv blir
ferdig, ikke samlet ved rundefullføring — mest nyttig nettopp for
spilleren som går av tidlig.
3. **Tillegg:** må også kunne sende scorekort til en enkelt spiller i
ETTERTID, lenge etter at runden allerede er fullført (glemt/tapt
e-post, ønske om å sende på nytt) — et eget on-demand-endepunkt,
ikke bundet til selve fullførings-transisjonen.
**Bevisst utenfor omfang:** delt-ball-formater (foursome/scramble/
greensome, som fullfører per SIDE via `round_side`, ikke per deltaker)
— brukerens eksempel er individuell slagspill. Strukturelt symmetrisk
follow-up når/hvis etterspurt.
**Datamodell (migrasjon 066):** `round_participant.completed_at
timestamptz`, ingen CHECK mot `round.completed_at` — applikasjonslaget
garanterer rekkefølgen (deltaker-fullføring skjer alltid FØR/uavhengig
av rundens egen, aldri etter at runden er låst).
**Tilgang — `completed` er et FLIGHT-STYRT felt, ikke eier-/selv-
only.** Speiler `update_hole`s eksisterende begrunnelse ("en lenket
medspiller kan registrere score for HELE flighten") — enhver med
tilgang til runden (eier ELLER lenket medspiller) kan avslutte/
gjenåpne EN HVILKEN SOM HELST deltaker, akkurat som de allerede kan
føre score for en hvilken som helst deltaker. Ny
`_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}`-konstant i
`rounds.py` hopper eksplisitt over både eier-only-sjekken og
selv-only-sjekken KUN for dette feltet — uendret for alle andre felt i
samme kall. Avvist med `ALREADY_COMPLETED` (409) hvis `round.
completed_at` allerede er satt — ingen åpning/lukking av
enkeltdeltakere etter at hele runden er låst.
**E-post — én delt funksjon, to mottakertyper.**
`_send_participant_round_summary(conn, round_id, participant_id,
round_label) -> bool` erstatter den gamle gjeste-only
`_send_guest_round_summaries` — slår nå opp BÅDE gjest (`guest_email`)
OG lenket konto (`app_user.email` via LEFT JOIN), returnerer om en
adresse fantes. Kun den FØRSTE `null → true`-transisjonen av
`completed_at` trigger e-post (idempotent — re-sending `completed:
true` når allerede sann, eller `completed: false`, sender aldri på
nytt automatisk). `complete_round` har en fallback-løkke: enhver
deltaker som fortsatt er `completed_at IS NULL` når hele runden
fullføres, får det satt nå OG e-post sendt der også — dekker det
vanlige tilfellet (ingen trykket "avslutt" underveis) uten
dobbel-sending for de som allerede var individuelt avsluttet.
`app/email.py`s `send_round_summary_email` fikk `is_linked_account`/
`round_id`-parametre: lenkede mottakere får en direkte
`/my-rounds/{round_id}`-lenke (ingen magic-link, de har allerede
konto) og hilsen med `app_user.first_name` (navneformat-regelen i
CLAUDE.md), gjester beholder den opprinnelige magic-link-CTA-en.
**Nytt on-demand-endepunkt** (tillegg 3 over): `POST /rounds/{round_
id}/participants/{participant_id}/send-scorecard` — fungerer UANSETT
fullføringsstatus, også lenge etter at runden er fullført. Samme
flight-styrte tilgang som `completed`-feltet, men bevisst INGEN
`ALREADY_COMPLETED`-sperre (leser og sender kun, muterer aldri
databasetilstand). 204 ved suksess, `NO_EMAIL_ADDRESS` (400) hvis
deltakeren ikke har noen registrert adresse.
**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh`. Egen scratch-database: 4-spiller
frittstående slagspill-runde (blanding lenket bruker + gjest med
e-post), ulikt antall hull spilt, avsluttet to deltakere manuelt via
PATCH — bekreftet e-post trigges umiddelbart med riktig mottaker-
variant (gjest vs. lenket, ulik lenke/hilsen), `ALREADY_COMPLETED` ved
forsøk etter rundefullføring, `complete_round` sender KUN til
ikke-allerede-avsluttede deltakere, en lenket medspiller (ikke eier)
kan avslutte en ANNEN deltaker (flight-styrt tilgang bekreftet), angre
(`completed: false`) fungerer før rundens egen fullføring, og det nye
on-demand-endepunktet fungerer også lenge etter rundefullføring.
Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`-felt,
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper i
`PlayerHoleCards` og "Spillere og runde"-fanen, `allHolesEnteredFor
Everyone`-justering, ScoringWizard-spillerkjede-hopp for fullførte
deltakere) — backend er bygget og verifisert, men ikke koblet inn i UI
ennå.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -10863,3 +10863,97 @@ Neste steg:
**Rullet ut 2026-08-13**, samme økt. Ren frontend-endring -- **Rullet ut 2026-08-13**, samme økt. Ren frontend-endring --
`docker compose build teecup_frontend && up -d teecup_frontend`. `docker compose build teecup_frontend && up -d teecup_frontend`.
Ingen konsollfeil på `https://teecup.golf/logg-inn` etter omstart. Ingen konsollfeil på `https://teecup.golf/logg-inn` etter omstart.
78. **Per-deltaker-fullføring av frittstående runder + scorekort på
e-post — 2026-08-13, se ADR-065.** Bruker: fire spillere sammen,
to går 18 hull, en 9, en må avslutte etter 14 -- disse rundene
skulle kunne markeres fullført per spiller, uten å måtte fullføre
hele runden, og uten å låse scoreføring for de andre.
Migrasjon 066: `round_participant.completed_at timestamptz`.
Backend (`app/routers/rounds.py`): `ParticipantUpdate.completed`,
ny `_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}` -- speiler
`update_hole`s "lenket medspiller kan registrere for hele
flighten"-filosofi, IKKE eier-/selv-only. `ALREADY_COMPLETED` (409)
hvis `round.completed_at` allerede er satt. `_send_guest_round_
summaries` erstattet av `_send_participant_round_summary(conn,
round_id, participant_id, round_label) -> bool`, som nå dekker
BÅDE gjester og lenkede kontoer (LEFT JOIN `app_user`) -- kun
første `null -> true`-transisjon trigger e-post (idempotent).
`complete_round` har en fallback-løkke som fullfører+e-poster
enhver deltaker som IKKE allerede ble individuelt avsluttet, ingen
dobbel-sending for de som var det. `app/email.py`s `send_round_
summary_email` fikk `is_linked_account`/`round_id` -- lenkede
mottakere får direktelenke til `/my-rounds/{id}` og hilsen med
`first_name` (navneformat-regelen), gjester beholder magic-link.
**Tillegg samme dag under samtalen:** må også kunne sende
scorekort til en enkelt spiller i ETTERTID, lenge etter at runden
er fullført. Nytt endepunkt `POST /rounds/{id}/participants/{id}/
send-scorecard` -- samme flight-styrte tilgang, men bevisst INGEN
`ALREADY_COMPLETED`-sperre (leser/sender, muterer aldri
databasetilstand). 204 ved suksess, `NO_EMAIL_ADDRESS` (400) uten
registrert adresse.
**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh`. Egen scratch-database: 4-spiller
runde (lenket bruker + gjest med e-post), ulikt hull-antall,
avsluttet to deltakere via PATCH -- bekreftet e-post umiddelbart
med riktig mottaker-variant, `ALREADY_COMPLETED` etter
rundefullføring, fallback-løkken sender kun til ikke-avsluttede,
lenket medspiller (ikke eier) kan avslutte ANNEN deltaker
(flight-tilgang bekreftet), angre (`completed: false`) fungerer
før rundefullføring, on-demand-endepunktet fungerer også lenge
etter fullføring.
**Rullet ut 2026-08-13**, samme økt, sammen med migrasjon 067
(punkt 79 under). `docker compose build teecup_api && up -d`.
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`,
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper,
nagging-justering, ScoringWizard-spillerkjede-hopp) -- backend
bygget og verifisert, ikke koblet inn i UI ennå.
79. **To nye baner (Larvik/Seasidebanen, Nesbyen/Nesfjellet) via
GolfAPI + en reell datakvalitetsbug funnet under importen —
2026-08-13, se ADR-064-tillegg.** Bruker ba om å mappe Larvik
(Seasidebanen) og Nesbyen, sistnevnte manuelt GPS-registrert (124
feltbefarte punkter, CSV) siden GolfAPI selv mangler koordinater
for banen (`hasGPS=0`).
To nye `poi_type`-verdier (migrasjon 067): `rock` (fjellknaus --
hinder) og `layup` (til fairway -- kun informativt). Bruker
korrigerte terminologi underveis: "Bunker (green)" ikke "Bunker
(grønn)" (green er et låneord, ikke oversatt), og "Over vann"
skal telle som "Bakkant vann" (samme `poi_type`/`location`).
**Reell bug funnet under import:** GolfAPI returnerer noen ganger
tom STRENG (`""`) i stedet for `null`/fraværende for course
rating/slope-felt -- ikke bare for kvinner (ADR-019s tilsvarende
antakelse for teeoff), Nesbyen manglet `slopeMen` også. Fikset på
tre nivåer: `golfapi_cache.py` normaliserer `""` -> `None` for
alle fire ratingfelt, migrasjon 068 gjør `golfapi_course_tee.
course_rating_men`/`slope_men` nullable (var feilaktig NOT NULL),
og begge import-endepunktene (`courses.py`, `rounds.py`) bygger nå
rating per utslag defensivt -- hopper over et utslag uten brukbar
rating, avviser hele importen med `EXTERNAL_DATA_INCOMPLETE` FØR
transaksjonen hvis INGEN utslag har noen brukbar rating. Nesbyens
6 utslag hadde faktisk ingen rating i det hele tatt hos GolfAPI --
bruker oppga alle 6 (herre+dame, ett uten damerating) manuelt fra
klubbens scorekort, satt inn i `golfapi_course_tee`-cachen før
import fullførte.
**Verifisert:** `python3 -m py_compile` rent, full
`./scripts/run_backend_tests.sh` (31/31, migrasjon 068 bekreftet
anvendbar). Selve importen kjørt mot ekte `teecup_db` via et
engangsskript i `teecup_api`-containeren (speiler
`import_international_personal_course`s kopier-inn-logikk).
Etterpå bekreftet med read-only spørring: Larvik (18 hull, 6
utslag, 11 tee-ratinger, 95 koordinater) og Nesbyen (18 hull, 6
utslag, 11 tee-ratinger, 124 koordinater), begge eid av Erol
Haagenrud, `golfapi_course.has_gps`/`num_coordinates` korrigert
for Nesbyen.
**Rullet ut 2026-08-13**, samme økt. Migrasjon 067+068 kjørt mot
ekte `teecup_db`, `docker compose build teecup_api && up -d`, ren
oppstartslogg.

View file

@ -361,12 +361,26 @@ async def send_round_summary_email(
round_label: str, round_label: str,
holes: list[RoundSummaryHole], holes: list[RoundSummaryHole],
stat_lines: list[tuple[str, str]], stat_lines: list[tuple[str, str]],
raw_token: str, raw_token: str | None,
is_linked_account: bool = False,
round_id: str | None = None,
) -> None: ) -> None:
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert """Rundeoppsummering til en spiller med registrert e-post, sendt
e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round, UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin
2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele
_send_sync sin nye html_body-parameter, med en ekte tekst-fallback. rundens fullføring, complete_round, 2026-08-03). FØRSTE HTML-e-post i
appen -- multipart/alternative via _send_sync sin nye html_body-
parameter, med en ekte tekst-fallback.
To mottakertyper (migrasjon 066 utvidet fra kun gjester):
- Gjest (`is_linked_account=False`): `raw_token` er et ekte magic-link-
innloggingstoken (samme mønster som tournaments.py sin
send_scorecard_invitations) -- ingen konto finnes ennå, e-posten
pitcher én.
- Lenket bruker (`is_linked_account=True`): `raw_token` er None,
`round_id` være satt -- lenken peker rett rundesiden (vanlig
innlogging gjelder), ingen konto-pitch (feil for noen som allerede
har en).
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn. mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
@ -378,9 +392,13 @@ async def send_round_summary_email(
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
finnes data for. finnes data for.
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten Alltid norsk, samme presedens som send_scorecard_invitations (verken en
har ingen lagret språkpreferanse -- ingen konto ennå).""" gjest eller en spiller midt i en runde har nødvendigvis en lagret
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}" språkpreferanse tilgjengelig her)."""
if is_linked_account:
link = f"{settings.PUBLIC_BASE_URL}/my-rounds/{round_id}"
else:
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = f"Scorekortet ditt fra {round_label}" subject = f"Scorekortet ditt fra {round_label}"
text_lines = [ text_lines = [
@ -396,17 +414,24 @@ async def send_round_summary_email(
text_lines.append("") text_lines.append("")
for label, value in stat_lines: for label, value in stat_lines:
text_lines.append(f"{label}: {value}") text_lines.append(f"{label}: {value}")
text_lines += [ if is_linked_account:
"", text_lines += [
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- " "",
"du trenger ingen konto for å se dette scorekortet, men med én kan du " "Se hele runden:",
"føre score selv, følge egen statistikk og handicap-utvikling over tid.", link,
"", ]
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):", else:
link, text_lines += [
"", "",
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.", "Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
] "du trenger ingen konto for å se dette scorekortet, men med én kan du "
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
"",
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
link,
"",
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
]
body = "\n".join(text_lines) body = "\n".join(text_lines)
hole_rows_html = "\n".join( hole_rows_html = "\n".join(
@ -421,6 +446,21 @@ async def send_round_summary_email(
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>' f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
for label, value in stat_lines for label, value in stat_lines
) )
if is_linked_account:
pitch_html = ""
cta_label = "Se hele runden"
footer_html = ""
else:
pitch_html = f"""<p style="margin:0 0 20px;font-size:14px;color:#424941;">
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
statistikk og handicap-utvikling over tid.
</p>"""
cta_label = "Logg inn og se hele runden"
footer_html = f"""<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
e-posten -- den brukes ikke til noe annet.
</p>"""
html_body = f"""\ html_body = f"""\
<!doctype html> <!doctype html>
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;"> <html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
@ -443,19 +483,12 @@ async def send_round_summary_email(
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;">
{stat_rows_html} {stat_rows_html}
</table> </table>
<p style="margin:0 0 20px;font-size:14px;color:#424941;"> {pitch_html}
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
statistikk og handicap-utvikling over tid.
</p>
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;"> <table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px; <a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
font-weight:700;text-decoration:none;">Logg inn og se hele runden</a> font-weight:700;text-decoration:none;">{cta_label}</a>
</td></tr></table> </td></tr></table>
<p style="margin:20px 0 0;font-size:12px;color:#767f75;"> {footer_html}
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
e-posten -- den brukes ikke til noe annet.
</p>
</td></tr> </td></tr>
</table> </table>
</td></tr> </td></tr>

View file

@ -70,6 +70,21 @@ async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None:
for t in course.get("tees", []): for t in course.get("tees", []):
lengths = [t.get(f"length{n}") for n in range(1, num_holes + 1)] lengths = [t.get(f"length{n}") for n in range(1, num_holes + 1)]
# Reelt funn (Larvik/Nesbyen, 2026-08-13): GolfAPI returnerer
# tomme STRENGER ("") for manglende rating-felt på enkelte
# baner -- IKKE bare for kvinner (opprinnelig antakelse, speilet
# fra ADR-019s teeoff-import), Nesbyen manglet `slopeMen` også.
# `.get(...) is not None` alene lot int()/numeric-kolonnene
# krasje på disse. Normaliser "" -> None FØR bruk, for BEGGE
# kjønn (migrasjon 068 gjorde course_rating_men/slope_men
# nullable av samme grunn -- kolonnene tåler nå det cachen her
# faktisk kan produsere).
def _num(key: str):
v = t.get(key)
return None if v is None or v == "" else v
slope_men = _num("slopeMen")
slope_women = _num("slopeWomen")
await conn.execute( await conn.execute(
""" """
INSERT INTO golfapi_course_tee INSERT INTO golfapi_course_tee
@ -84,14 +99,14 @@ async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None:
t.get("teeName") or "Tee", t.get("teeName") or "Tee",
t.get("teeColor"), t.get("teeColor"),
lengths, lengths,
t["courseRatingMen"], _num("courseRatingMen"),
int(t["slopeMen"]), int(slope_men) if slope_men is not None else None,
t.get("courseRatingWomen"), _num("courseRatingWomen"),
int(t["slopeWomen"]) if t.get("slopeWomen") is not None else None, int(slope_women) if slope_women is not None else None,
t.get("courseRatingMenFront9"), _num("courseRatingMenFront9"),
t.get("courseRatingMenBack9"), _num("courseRatingMenBack9"),
t.get("courseRatingWomenFront9"), _num("courseRatingWomenFront9"),
t.get("courseRatingWomenBack9"), _num("courseRatingWomenBack9"),
) )
for c in coordinates: for c in coordinates:

View file

@ -716,6 +716,14 @@ async def import_international_course(
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.") raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
if not tees: if not tees:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.") raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
if not any(
(t["course_rating_men"] is not None and t["slope_men"] is not None)
or (t["course_rating_women"] is not None and t["slope_women"] is not None)
for t in tees
):
raise app_error(
400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI."
)
par_total = sum(h["par_men"] for h in holes) par_total = sum(h["par_men"] for h in holes)
course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"] course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"]
@ -739,13 +747,22 @@ async def import_international_course(
) )
for t in tees: for t in tees:
# Reelt funn (Nesbyen, 2026-08-13): GolfAPI kan mangle RATING
# FOR BEGGE kjønn på et utslag (opprinnelig antatt at menns alltid
# finnes, speilet fra ADR-019s teeoff-import) -- begge sjekkes nå
# individuelt, samme mønster som teeoff-importen allerede bruker.
# Utslag uten NOEN brukbar rating hoppes over (usable_tees under).
ratings: list[tuple[str, float, int]] = []
if t["course_rating_men"] is not None and t["slope_men"] is not None:
ratings.append(("m", float(t["course_rating_men"]), int(t["slope_men"])))
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"])))
if not ratings:
continue
tee_row = await conn.fetchrow( tee_row = await conn.fetchrow(
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id", "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
organization_id, course_id, t["name"], organization_id, course_id, t["name"],
) )
ratings: list[tuple[str, float, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]))]
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"])))
for gender, course_rating, slope_rating in ratings: for gender, course_rating, slope_rating in ratings:
await conn.execute( await conn.execute(
""" """

View file

@ -321,6 +321,14 @@ async def import_international_personal_course(
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.") raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
if not tees: if not tees:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.") raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
if not any(
(t["course_rating_men"] is not None and t["slope_men"] is not None)
or (t["course_rating_women"] is not None and t["slope_women"] is not None)
for t in tees
):
raise app_error(
400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI."
)
par_total = sum(h["par_men"] for h in holes) par_total = sum(h["par_men"] for h in holes)
course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"] course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"]
@ -342,13 +350,20 @@ async def import_international_personal_course(
course_row["id"], h["hole_number"], h["par_men"], h["index_men"], course_row["id"], h["hole_number"], h["par_men"], h["index_men"],
) )
for t in tees: for t in tees:
# Reelt funn (Nesbyen, 2026-08-13): se tilsvarende kommentar i
# courses.py sin international-import -- begge kjønn sjekkes
# individuelt, utslag uten NOEN brukbar rating hoppes over.
ratings: list[tuple[str, float, int, int]] = []
if t["course_rating_men"] is not None and t["slope_men"] is not None:
ratings.append(("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total))
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
if not ratings:
continue
tee_row = await conn.fetchrow( tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id", "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
course_row["id"], t["name"], course_row["id"], t["name"],
) )
ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)]
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
for gender, course_rating, slope_rating, par in ratings: for gender, course_rating, slope_rating, par in ratings:
await conn.execute( await conn.execute(
""" """
@ -1376,6 +1391,12 @@ class RoundParticipantOut(BaseModel):
playing_handicap: int | None playing_handicap: int | None
# Money Ball (2026-07-30) -- 0-3, null for alle andre formater. # Money Ball (2026-07-30) -- 0-3, null for alle andre formater.
lineup_order: int | None lineup_order: int | None
# Per-deltaker avslutning (migrasjon 066) -- UAVHENGIG av round.completed_at
# (som forblir manuell/rundeomfattende). Sann etter PATCH {completed: true}
# (se update_participant) ELLER etter complete_round sin fallback for
# deltakere som aldri ble individuelt avsluttet. Brukes til å stoppe
# "mangler score"-nagging for DENNE spilleren uten å låse resten av flighten.
completed: bool
class RoundSideOut(BaseModel): class RoundSideOut(BaseModel):
@ -1474,7 +1495,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
rp.course_handicap_snapshot, rp.counts_for_handicap, rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level, rp.score_differential::float AS score_differential, rp.stat_level,
rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
rp.playing_handicap, rp.lineup_order rp.playing_handicap, rp.lineup_order, (rp.completed_at IS NOT NULL) AS completed
FROM round_participant rp FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
@ -2982,7 +3003,7 @@ async def add_participant(
rp.course_handicap_snapshot, rp.counts_for_handicap, rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level, rp.score_differential::float AS score_differential, rp.stat_level,
rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
rp.playing_handicap, rp.lineup_order rp.playing_handicap, rp.lineup_order, (rp.completed_at IS NOT NULL) AS completed
FROM round_participant rp FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 WHERE rp.id = $1
@ -3195,6 +3216,12 @@ class ParticipantUpdate(BaseModel):
# 'money_ball'. 0-3, unik per runde -- avgjør rotasjonsrekkefølgen # 'money_ball'. 0-3, unik per runde -- avgjør rotasjonsrekkefølgen
# ((posisjon-1) % 4) for hvem som er "money ball" på hvilket hull. # ((posisjon-1) % 4) for hvem som er "money ball" på hvilket hull.
lineup_order: int | None = Field(default=None, ge=0, le=3) lineup_order: int | None = Field(default=None, ge=0, le=3)
# Per-deltaker avslutning (migrasjon 066) -- FLIGHT-STYRT, ikke
# eier-only/selv-only som resten av feltene her (se
# _FLIGHT_MANAGED_FIELDS/update_participant): en lenket medspiller kan
# avslutte EN HVILKEN SOM HELST deltaker, samme begrunnelse som at de
# allerede kan føre score for hele flighten (update_hole).
completed: bool | None = None
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder -- # Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
@ -3204,6 +3231,13 @@ _OWNER_ONLY_PARTICIPANT_FIELDS = {
"round_side_id", "lineup_order", "round_side_id", "lineup_order",
} }
# Motsatt av _OWNER_ONLY_PARTICIPANT_FIELDS over -- IKKE begrenset til
# eieren ELLER egen rad. Samme begrunnelse som update_hole (linje ~5375):
# en lenket medspiller registrerer allerede score for HELE flighten, så
# å avslutte en ANNEN deltaker er samme klasse handling, ikke en sensitiv
# profil-endring.
_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut) @router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
async def update_participant( async def update_participant(
@ -3225,9 +3259,11 @@ async def update_participant(
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot, SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.counts_for_handicap, rp.guest_first_name, rp.guest_last_name, rp.counts_for_handicap, rp.guest_first_name, rp.guest_last_name,
rp.completed_at AS participant_completed_at,
r.owner_user_id::text AS round_owner_user_id, r.owner_user_id::text AS round_owner_user_id,
r.completed_at, r.course_source, r.teeoff_facility_slug, r.completed_at, r.course_source, r.teeoff_facility_slug,
r.teeoff_course_id, r.personal_course_id, r.play_format r.teeoff_course_id, r.personal_course_id, r.play_format, r.name AS round_name,
r.course_name_snapshot
FROM round_participant rp FROM round_participant rp
JOIN round r ON r.id = rp.round_id JOIN round r ON r.id = rp.round_id
WHERE rp.id = $1 AND rp.round_id = $2 WHERE rp.id = $1 AND rp.round_id = $2
@ -3244,7 +3280,8 @@ async def update_participant(
403, "NOT_AUTHORIZED", 403, "NOT_AUTHORIZED",
"Du kan kun endre om DIN egen deltakelse skal telle mot ditt faktiske HCP her.", "Du kan kun endre om DIN egen deltakelse skal telle mot ditt faktiske HCP her.",
) )
if current["user_id"] != user.user_id: non_flight_fields = updates.keys() - _FLIGHT_MANAGED_PARTICIPANT_FIELDS
if non_flight_fields and current["user_id"] != user.user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.") raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.")
guest_only_fields = {"guest_first_name", "guest_last_name", "gender", "guest_email"} guest_only_fields = {"guest_first_name", "guest_last_name", "gender", "guest_email"}
@ -3261,6 +3298,18 @@ async def update_participant(
"Runden er allerede fullført -- kan ikke endre utslag, HCP eller kjønn i etterkant.", "Runden er allerede fullført -- kan ikke endre utslag, HCP eller kjønn i etterkant.",
) )
# Per-deltaker avslutning (migrasjon 066) -- kan verken settes ELLER
# angres etter at RUNDEN som helhet er fullført (HCP-differensialer
# er da allerede beregnet og frosset for alle deltakere samlet).
newly_completed = False
if "completed" in updates:
if current["completed_at"] is not None:
raise app_error(
409, "ALREADY_COMPLETED",
"Runden er allerede fullført -- kan ikke avslutte/gjenåpne en enkeltdeltaker i etterkant.",
)
newly_completed = updates["completed"] and current["participant_completed_at"] is None
set_clauses: list[str] = [] set_clauses: list[str] = []
values: list[object] = [] values: list[object] = []
@ -3325,6 +3374,10 @@ async def update_participant(
values.append(new_lineup_order) values.append(new_lineup_order)
set_clauses.append(f"lineup_order = ${len(values)}") set_clauses.append(f"lineup_order = ${len(values)}")
if "completed" in updates:
values.append(datetime.now(timezone.utc) if updates["completed"] else None)
set_clauses.append(f"completed_at = ${len(values)}")
if rating_changed: if rating_changed:
if current["course_source"] == "teeoff": if current["course_source"] == "teeoff":
resolved = await _resolve_teeoff_course( resolved = await _resolve_teeoff_course(
@ -3418,7 +3471,7 @@ async def update_participant(
rp.course_handicap_snapshot, rp.counts_for_handicap, rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level, rp.score_differential::float AS score_differential, rp.stat_level,
rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
rp.playing_handicap, rp.lineup_order rp.playing_handicap, rp.lineup_order, (rp.completed_at IS NOT NULL) AS completed
FROM round_participant rp FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 WHERE rp.id = $1
@ -3435,6 +3488,14 @@ async def update_participant(
and current["completed_at"] is not None and current["completed_at"] is not None
): ):
await _recompute_computed_handicap_index(conn, current["user_id"]) await _recompute_computed_handicap_index(conn, current["user_id"])
# Scorekort på e-post UMIDDELBART når DENNE spilleren blir ferdig
# (bekreftet med bruker 2026-08-13) -- ikke samlet ved rundens egen
# fullføring (complete_round har sin egen fallback for deltakere
# som ALDRI ble individuelt avsluttet, se der).
if newly_completed:
round_label = (current["round_name"] or "").strip() or current["course_name_snapshot"]
await _send_participant_round_summary(conn, round_id, participant_id, round_label)
await broadcast_round_update(round_id) await broadcast_round_update(round_id)
return RoundParticipantOut(**dict(row)) return RoundParticipantOut(**dict(row))
@ -5811,7 +5872,7 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
SELECT id::text AS id, user_id::text AS user_id, SELECT id::text AS id, user_id::text AS user_id,
handicap_index_snapshot::float AS handicap_index_snapshot, handicap_index_snapshot::float AS handicap_index_snapshot,
course_rating_snapshot::float AS course_rating_snapshot, course_rating_snapshot::float AS course_rating_snapshot,
slope_rating_snapshot, tee_par_snapshot slope_rating_snapshot, tee_par_snapshot, completed_at
FROM round_participant WHERE round_id = $1 FROM round_participant WHERE round_id = $1
""", """,
round_id, round_id,
@ -5848,6 +5909,20 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id) await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
# Per-deltaker avslutning (migrasjon 066) -- fallback for deltakere
# som ALDRI ble individuelt avsluttet underveis (det vanlige
# tilfellet: alle spilte alle hull, ingen trykket "avslutt for meg"
# på egen hånd). Setter completed_at + sender scorekort-e-post NÅ
# for dem -- deltakere som ALLEREDE ble avsluttet+e-postet tidligere
# (completed_at allerede satt) røres ikke, ingen dobbel-sending.
round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
for p in participants:
if p["completed_at"] is None:
await conn.execute(
"UPDATE round_participant SET completed_at = now() WHERE id = $1", p["id"]
)
await _send_participant_round_summary(conn, round_id, p["id"], round_label)
# Faktisk HCP (ADR-038) -- alle differensialer for runden er nå # Faktisk HCP (ADR-038) -- alle differensialer for runden er nå
# skrevet, regn på nytt for hver deltaker med egen konto (gjester # skrevet, regn på nytt for hver deltaker med egen konto (gjester
# har ingen computed_handicap_index å oppdatere). # har ingen computed_handicap_index å oppdatere).
@ -5860,7 +5935,6 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
# tilgang: lenkede medspillere ser den fulle autentiserte siden, # tilgang: lenkede medspillere ser den fulle autentiserte siden,
# tredjeparts-venner kun tredjeparts-/spectator-visningen. # tredjeparts-venner kun tredjeparts-/spectator-visningen.
actor_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) actor_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
message = f"{actor_name} fullførte runden ({round_label})." message = f"{actor_name} fullførte runden ({round_label})."
linked_ids = {p["user_id"] for p in participants if p["user_id"] is not None} linked_ids = {p["user_id"] for p in participants if p["user_id"] is not None}
@ -5878,75 +5952,103 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}", conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}",
) )
await _send_guest_round_summaries(conn, round_id, round_label)
result = await _load_round_out(conn, round_id, user.user_id) result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id) await broadcast_round_update(round_id)
return result return result
async def _send_guest_round_summaries(conn, round_id: str, round_label: str) -> None: async def _send_participant_round_summary(conn, round_id: str, participant_id: str, round_label: str) -> bool:
"""Rundeoppsummering til midlertidige spillere med registrert e-post """Scorekort på e-post til ÉN deltaker. Returnerer True hvis en adresse
(2026-08-03) -- ETT ekte magic-link-innloggingstoken per gjest, samme fantes og sending faktisk ble forsøkt, False hvis deltakeren ikke har
mønster som tournaments.py sin send_scorecard_invitations. Graderer seg noen registrert e-post (kalleren kan bruke dette til å gi tydelig
naturlig etter individuell-ball (egne slag/putter) vs. delt-ball tilbakemelding ved en EKSPLISITT send-handling, se
(kun sidens felles slag, ADR-039 Beslutning C) -- ingen egen send_participant_scorecard under -- de to andre kallerne, update_participant
format-sjekk trengs, feltene som ikke finnes er rett og slett fraværende og complete_round, ignorerer returverdien, samme "stille hvis ingen
i kildedataene.""" adresse"-oppførsel som før 2026-08-13).
guest_rows = await conn.fetch(
Tre kallere (migrasjon 066, 2026-08-13):
1. UMIDDELBART når DENNE deltakeren avsluttes (update_participant).
2. complete_round sin fallback for deltakere som aldri ble individuelt
avsluttet.
3. `POST .../participants/{id}/send-scorecard` -- eksplisitt, NYTT-
sending når som helst, ogsÅ for en runde som ble fullført for lenge
siden (bekreftet med bruker: kunne inn i tidligere fullførte
runder og sende scorekort til enkeltspillere).
Dekker BÅDE gjester (guest_email) OG lenkede brukere (app_user.email) --
opprinnelig (2026-08-03) kun gjester. Graderer seg naturlig etter
individuell-ball (egne slag/putter) vs. delt-ball (kun sidens felles
slag, ADR-039 Beslutning C) -- ingen egen format-sjekk trengs, feltene
som ikke finnes er rett og slett fraværende i kildedataene."""
g = await conn.fetchrow(
""" """
SELECT id::text AS id, guest_email, guest_first_name, round_side_id::text AS round_side_id, stat_level SELECT rp.id::text AS id, rp.guest_email, rp.guest_first_name,
FROM round_participant rp.round_side_id::text AS round_side_id, rp.stat_level,
WHERE round_id = $1 AND guest_email IS NOT NULL rp.user_id::text AS user_id, au.email::text AS account_email, au.first_name AS account_first_name
FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 AND rp.round_id = $2
""", """,
round_id, participant_id, round_id,
) )
for g in guest_rows: if g is None:
stat_lines: list[tuple[str, str]] = [] return False
if g["round_side_id"] is not None: is_linked = g["user_id"] is not None
side_holes = await _build_side_holes(conn, round_id, g["round_side_id"]) email = (g["account_email"] if is_linked else g["guest_email"]) or None
summary_holes = [ if email is None:
RoundSummaryHole( return False # ingen registrert adresse -- ingenting å sende (vanligste tilfellet)
hole_number=h.hole_number, first_name = (g["account_first_name"] if is_linked else g["guest_first_name"]) or ("Der" if is_linked else "Gjest")
par=h.par,
score=h.score if h.played else None,
net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None
else None,
)
for h in side_holes
]
else:
p_holes = await _build_participant_holes(conn, round_id, g["id"])
summary_holes = [
RoundSummaryHole(
hole_number=h.hole_number,
par=h.par,
score=h.score if h.played else None,
net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None
else None,
)
for h in p_holes
]
if g["stat_level"] != "strokes_only":
total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None)
if total_putts:
stat_lines.append(("Putter totalt", str(total_putts)))
played_holes = [h for h in summary_holes if h.score is not None] stat_lines: list[tuple[str, str]] = []
if played_holes: if g["round_side_id"] is not None:
total_score = sum(h.score for h in played_holes) side_holes = await _build_side_holes(conn, round_id, g["round_side_id"])
to_par = total_score - sum(h.par for h in played_holes) summary_holes = [
lead_lines = [("Slag totalt", str(total_score)), ("Til par", _signed(to_par))] RoundSummaryHole(
net_holes = [h for h in played_holes if h.net is not None] hole_number=h.hole_number,
if net_holes: par=h.par,
net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes) score=h.score if h.played else None,
lead_lines.append(("Netto til par", _signed(net_to_par))) net=h.score - h.strokes_received
stat_lines = lead_lines + stat_lines if h.played and h.score is not None and h.strokes_received is not None
else None,
)
for h in side_holes
]
else:
p_holes = await _build_participant_holes(conn, round_id, g["id"])
summary_holes = [
RoundSummaryHole(
hole_number=h.hole_number,
par=h.par,
score=h.score if h.played else None,
net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None
else None,
)
for h in p_holes
]
if g["stat_level"] != "strokes_only":
total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None)
if total_putts:
stat_lines.append(("Putter totalt", str(total_putts)))
email = g["guest_email"].strip().lower() played_holes = [h for h in summary_holes if h.score is not None]
if played_holes:
total_score = sum(h.score for h in played_holes)
to_par = total_score - sum(h.par for h in played_holes)
lead_lines = [("Slag totalt", str(total_score)), ("Til par", _signed(to_par))]
net_holes = [h for h in played_holes if h.net is not None]
if net_holes:
net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes)
lead_lines.append(("Netto til par", _signed(net_to_par)))
stat_lines = lead_lines + stat_lines
email = email.strip().lower()
raw_token: str | None = None
if not is_linked:
# Magic-link kun for gjester -- en lenket bruker har allerede en
# konto og logger inn som vanlig, se send_round_summary_email sin
# is_linked_account-gren.
raw_token = secrets.token_urlsafe(32) raw_token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som # Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
@ -5958,18 +6060,56 @@ async def _send_guest_round_summaries(conn, round_id: str, round_label: str) ->
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')", "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
email, _hash_secret(raw_token), expires_at, email, _hash_secret(raw_token), expires_at,
) )
if settings.SMTP_CONFIGURED: if settings.SMTP_CONFIGURED:
try: try:
await send_round_summary_email( await send_round_summary_email(
email, g["guest_first_name"] or "Gjest", round_label, summary_holes, stat_lines, raw_token, email, first_name, round_label, summary_holes, stat_lines, raw_token,
) is_linked_account=is_linked, round_id=round_id,
except Exception: )
# Se app/email.py sitt mønster -- en driftsfeil i selve except Exception:
# utsendingen skal aldri hindre fullføringen av runden. # Se app/email.py sitt mønster -- en driftsfeil i selve
traceback.print_exc() # utsendingen skal aldri hindre fullføringen/avslutningen.
elif settings.DEV_LOG_MAGIC_LINKS: traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
if raw_token:
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True) print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
print(f"[DEV] Rundeoppsummering til {email}: {round_label}, {len(played_holes)} hull spilt", flush=True) print(f"[DEV] Rundeoppsummering til {email}: {round_label}, {len(played_holes)} hull spilt", flush=True)
return True
@router.post("/rounds/{round_id}/participants/{participant_id}/send-scorecard", status_code=204)
async def send_participant_scorecard(
round_id: str,
participant_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Eksplisitt, på nytt-sending av scorekort-e-post -- NÅR SOM HELST,
også for en runde som ble fullført for lenge siden (bekreftet med
bruker 2026-08-13: kunne inn i tidligere fullførte runder og
sende scorekort til enkeltspillere). Ingen ALREADY_COMPLETED-sperre
her (den gjelder kun selve completed-TOGGELEN, se update_participant)
-- denne handlingen endrer ingenting i databasen, kun en e-post ut.
Samme flight-styrte tilgang som update_participant sin `completed`-gren
(owner ELLER en hvilken som helst lenket medspiller kan sende for en
hvilken som helst deltaker, ikke bare seg selv)."""
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT name, course_name_snapshot FROM round WHERE id = $1", round_id
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
participant_exists = await conn.fetchval(
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id
)
if not participant_exists:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
sent = await _send_participant_round_summary(conn, round_id, participant_id, round_label)
if not sent:
raise app_error(
400, "NO_EMAIL_ADDRESS", "Denne spilleren har ingen registrert e-postadresse å sende til."
)
def _signed(value: int) -> str: def _signed(value: int) -> str:

View file

@ -37,9 +37,10 @@ type ApiTargetPoint = {
} }
const _HAZARD_LABELS: Record<string, string> = { const _HAZARD_LABELS: Record<string, string> = {
green_bunker: "Bunker (grønn)", green_bunker: "Bunker (green)",
fairway_bunker: "Bunker (fairway)", fairway_bunker: "Bunker (fairway)",
water: "Vannhinder", water: "Vannhinder",
rock: "Fjellknaus",
} }
export function HoleTargetDistance({ export function HoleTargetDistance({