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:
parent
648293e1f8
commit
baa1c42d24
10 changed files with 628 additions and 120 deletions
24
066_round_participant_completion.sql
Normal file
24
066_round_participant_completion.sql
Normal 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;
|
||||
26
067_golfapi_coordinate_more_poi_types.sql
Normal file
26
067_golfapi_coordinate_more_poi_types.sql
Normal 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'
|
||||
));
|
||||
18
068_golfapi_tee_men_rating_nullable.sql
Normal file
18
068_golfapi_tee_men_rating_nullable.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
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:
|
||||
|
||||
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)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
94
CHANGELOG.md
94
CHANGELOG.md
|
|
@ -10863,3 +10863,97 @@ Neste steg:
|
|||
**Rullet ut 2026-08-13**, samme økt. Ren frontend-endring --
|
||||
`docker compose build teecup_frontend && up -d teecup_frontend`.
|
||||
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.
|
||||
|
|
|
|||
91
app/email.py
91
app/email.py
|
|
@ -361,12 +361,26 @@ async def send_round_summary_email(
|
|||
round_label: str,
|
||||
holes: list[RoundSummaryHole],
|
||||
stat_lines: list[tuple[str, str]],
|
||||
raw_token: str,
|
||||
raw_token: str | None,
|
||||
is_linked_account: bool = False,
|
||||
round_id: str | None = None,
|
||||
) -> None:
|
||||
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert
|
||||
e-post, sendt ved fullføring (app/routers/rounds.py sin 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.
|
||||
"""Rundeoppsummering til en spiller med registrert e-post, sendt
|
||||
UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin
|
||||
update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele
|
||||
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` MÅ være satt -- lenken peker rett på rundesiden (vanlig
|
||||
innlogging gjelder), ingen konto-pitch (feil for noen som allerede
|
||||
har en).
|
||||
|
||||
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
|
||||
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
|
||||
finnes data for.
|
||||
|
||||
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten
|
||||
har ingen lagret språkpreferanse -- ingen konto ennå)."""
|
||||
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
||||
Alltid norsk, samme presedens som send_scorecard_invitations (verken en
|
||||
gjest eller en spiller midt i en runde har nødvendigvis en lagret
|
||||
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}"
|
||||
|
||||
text_lines = [
|
||||
|
|
@ -396,17 +414,24 @@ async def send_round_summary_email(
|
|||
text_lines.append("")
|
||||
for label, value in stat_lines:
|
||||
text_lines.append(f"{label}: {value}")
|
||||
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 "
|
||||
"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.",
|
||||
]
|
||||
if is_linked_account:
|
||||
text_lines += [
|
||||
"",
|
||||
"Se hele runden:",
|
||||
link,
|
||||
]
|
||||
else:
|
||||
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 "
|
||||
"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)
|
||||
|
||||
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>'
|
||||
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"""\
|
||||
<!doctype html>
|
||||
<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;">
|
||||
{stat_rows_html}
|
||||
</table>
|
||||
<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>
|
||||
{pitch_html}
|
||||
<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;
|
||||
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>
|
||||
<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>
|
||||
{footer_html}
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,21 @@ async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None:
|
|||
|
||||
for t in course.get("tees", []):
|
||||
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(
|
||||
"""
|
||||
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("teeColor"),
|
||||
lengths,
|
||||
t["courseRatingMen"],
|
||||
int(t["slopeMen"]),
|
||||
t.get("courseRatingWomen"),
|
||||
int(t["slopeWomen"]) if t.get("slopeWomen") is not None else None,
|
||||
t.get("courseRatingMenFront9"),
|
||||
t.get("courseRatingMenBack9"),
|
||||
t.get("courseRatingWomenFront9"),
|
||||
t.get("courseRatingWomenBack9"),
|
||||
_num("courseRatingMen"),
|
||||
int(slope_men) if slope_men is not None else None,
|
||||
_num("courseRatingWomen"),
|
||||
int(slope_women) if slope_women is not None else None,
|
||||
_num("courseRatingMenFront9"),
|
||||
_num("courseRatingMenBack9"),
|
||||
_num("courseRatingWomenFront9"),
|
||||
_num("courseRatingWomenBack9"),
|
||||
)
|
||||
|
||||
for c in coordinates:
|
||||
|
|
|
|||
|
|
@ -716,6 +716,14 @@ async def import_international_course(
|
|||
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
|
||||
if not tees:
|
||||
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)
|
||||
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:
|
||||
# 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(
|
||||
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
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:
|
||||
await conn.execute(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
if not tees:
|
||||
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)
|
||||
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"],
|
||||
)
|
||||
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(
|
||||
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
|
||||
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:
|
||||
await conn.execute(
|
||||
"""
|
||||
|
|
@ -1376,6 +1391,12 @@ class RoundParticipantOut(BaseModel):
|
|||
playing_handicap: int | None
|
||||
# Money Ball (2026-07-30) -- 0-3, null for alle andre formater.
|
||||
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):
|
||||
|
|
@ -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.score_differential::float AS score_differential, rp.stat_level,
|
||||
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
|
||||
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
|
||||
|
|
@ -2982,7 +3003,7 @@ async def add_participant(
|
|||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
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
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.id = $1
|
||||
|
|
@ -3195,6 +3216,12 @@ class ParticipantUpdate(BaseModel):
|
|||
# 'money_ball'. 0-3, unik per runde -- avgjør rotasjonsrekkefølgen
|
||||
# ((posisjon-1) % 4) for hvem som er "money ball" på hvilket hull.
|
||||
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 --
|
||||
|
|
@ -3204,6 +3231,13 @@ _OWNER_ONLY_PARTICIPANT_FIELDS = {
|
|||
"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)
|
||||
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,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
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.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
|
||||
JOIN round r ON r.id = rp.round_id
|
||||
WHERE rp.id = $1 AND rp.round_id = $2
|
||||
|
|
@ -3244,7 +3280,8 @@ async def update_participant(
|
|||
403, "NOT_AUTHORIZED",
|
||||
"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.")
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
# 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] = []
|
||||
values: list[object] = []
|
||||
|
||||
|
|
@ -3325,6 +3374,10 @@ async def update_participant(
|
|||
values.append(new_lineup_order)
|
||||
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 current["course_source"] == "teeoff":
|
||||
resolved = await _resolve_teeoff_course(
|
||||
|
|
@ -3418,7 +3471,7 @@ async def update_participant(
|
|||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
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
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.id = $1
|
||||
|
|
@ -3435,6 +3488,14 @@ async def update_participant(
|
|||
and current["completed_at"] is not None
|
||||
):
|
||||
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)
|
||||
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,
|
||||
handicap_index_snapshot::float AS handicap_index_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
|
||||
""",
|
||||
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)
|
||||
|
||||
# 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å
|
||||
# skrevet, regn på nytt for hver deltaker med egen konto (gjester
|
||||
# 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,
|
||||
# tredjeparts-venner kun tredjeparts-/spectator-visningen.
|
||||
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})."
|
||||
|
||||
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}",
|
||||
)
|
||||
|
||||
await _send_guest_round_summaries(conn, round_id, round_label)
|
||||
|
||||
result = await _load_round_out(conn, round_id, user.user_id)
|
||||
|
||||
await broadcast_round_update(round_id)
|
||||
return result
|
||||
|
||||
|
||||
async def _send_guest_round_summaries(conn, round_id: str, round_label: str) -> None:
|
||||
"""Rundeoppsummering til midlertidige spillere med registrert e-post
|
||||
(2026-08-03) -- ETT ekte magic-link-innloggingstoken per gjest, samme
|
||||
mønster som tournaments.py sin send_scorecard_invitations. 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."""
|
||||
guest_rows = await conn.fetch(
|
||||
async def _send_participant_round_summary(conn, round_id: str, participant_id: str, round_label: str) -> bool:
|
||||
"""Scorekort på e-post til ÉN deltaker. Returnerer True hvis en adresse
|
||||
fantes og sending faktisk ble forsøkt, False hvis deltakeren ikke har
|
||||
noen registrert e-post (kalleren kan bruke dette til å gi tydelig
|
||||
tilbakemelding ved en EKSPLISITT send-handling, se
|
||||
send_participant_scorecard under -- de to andre kallerne, update_participant
|
||||
og complete_round, ignorerer returverdien, samme "stille hvis ingen
|
||||
adresse"-oppførsel som før 2026-08-13).
|
||||
|
||||
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, PÅ NYTT-
|
||||
sending når som helst, ogsÅ for en runde som ble fullført for lenge
|
||||
siden (bekreftet med bruker: må kunne gå 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
|
||||
FROM round_participant
|
||||
WHERE round_id = $1 AND guest_email IS NOT NULL
|
||||
SELECT rp.id::text AS id, rp.guest_email, rp.guest_first_name,
|
||||
rp.round_side_id::text AS round_side_id, rp.stat_level,
|
||||
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:
|
||||
stat_lines: list[tuple[str, str]] = []
|
||||
if g["round_side_id"] is not None:
|
||||
side_holes = await _build_side_holes(conn, round_id, g["round_side_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 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)))
|
||||
if g is None:
|
||||
return False
|
||||
is_linked = g["user_id"] is not None
|
||||
email = (g["account_email"] if is_linked else g["guest_email"]) or None
|
||||
if email is None:
|
||||
return False # ingen registrert adresse -- ingenting å sende (vanligste tilfellet)
|
||||
first_name = (g["account_first_name"] if is_linked else g["guest_first_name"]) or ("Der" if is_linked else "Gjest")
|
||||
|
||||
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
|
||||
stat_lines: list[tuple[str, str]] = []
|
||||
if g["round_side_id"] is not None:
|
||||
side_holes = await _build_side_holes(conn, round_id, g["round_side_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 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)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
||||
# 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')",
|
||||
email, _hash_secret(raw_token), expires_at,
|
||||
)
|
||||
if settings.SMTP_CONFIGURED:
|
||||
try:
|
||||
await send_round_summary_email(
|
||||
email, g["guest_first_name"] or "Gjest", round_label, summary_holes, stat_lines, raw_token,
|
||||
)
|
||||
except Exception:
|
||||
# Se app/email.py sitt mønster -- en driftsfeil i selve
|
||||
# utsendingen skal aldri hindre fullføringen av runden.
|
||||
traceback.print_exc()
|
||||
elif settings.DEV_LOG_MAGIC_LINKS:
|
||||
if settings.SMTP_CONFIGURED:
|
||||
try:
|
||||
await send_round_summary_email(
|
||||
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
|
||||
# utsendingen skal aldri hindre fullføringen/avslutningen.
|
||||
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] 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: må kunne gå 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:
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ type ApiTargetPoint = {
|
|||
}
|
||||
|
||||
const _HAZARD_LABELS: Record<string, string> = {
|
||||
green_bunker: "Bunker (grønn)",
|
||||
green_bunker: "Bunker (green)",
|
||||
fairway_bunker: "Bunker (fairway)",
|
||||
water: "Vannhinder",
|
||||
rock: "Fjellknaus",
|
||||
}
|
||||
|
||||
export function HoleTargetDistance({
|
||||
|
|
|
|||
Loading…
Reference in a new issue