Backend er live og grundig verifisert med nøyaktig scenarioet du beskrev (10 hull spilt under feil bane, matchen avgjort, rettet til riktig bane — tee-er byttet, handicap regnet på nytt, matchstatus oppdatert, de faktiske slagene urørt). .md-filene er oppdatert.
Ikke bygget ennå: selve rediger-/slett-knappene i program-skjermen — det er ren backend-kapasitet nå. Vil du at jeg skriver en V0-prompt for det, eller er det noe annet du vil ta fatt på?
This commit is contained in:
parent
d51c0153bb
commit
459f3f8e14
6 changed files with 322 additions and 6 deletions
|
|
@ -265,7 +265,12 @@
|
||||||
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_scratch -c ' *)",
|
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_scratch -c ' *)",
|
||||||
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/lb_cookies.txt)",
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/lb_cookies.txt)",
|
||||||
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-8)",
|
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-8)",
|
||||||
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/lb2_cookies.txt)"
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/lb2_cookies.txt)",
|
||||||
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/idem_cookies.txt)",
|
||||||
|
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -v ON_ERROR_STOP=1 -c ' *)",
|
||||||
|
"Bash(python3 -m py_compile /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/scoring.py)",
|
||||||
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/patch_cookies.txt)",
|
||||||
|
"Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
75
CLAUDE.md
75
CLAUDE.md
|
|
@ -965,6 +965,81 @@ Ferdig og verifisert:
|
||||||
**Hele "bygg i rekkefølgen ting brukes"-serien for match-play-flyten er
|
**Hele "bygg i rekkefølgen ting brukes"-serien for match-play-flyten er
|
||||||
dermed komplett:** oppsett (lag/roster) → program → blind draw →
|
dermed komplett:** oppsett (lag/roster) → program → blind draw →
|
||||||
scorekort → leaderboard.
|
scorekort → leaderboard.
|
||||||
|
- **Offisiell bane-import: idempotent + tydeligere navn, LIVE (2026-07-18),
|
||||||
|
rapportert av brukeren som faktisk brukte funksjonen på ekte
|
||||||
|
produksjonsdata:** to reelle problemer, begge funnet ved å lese (kun
|
||||||
|
lesing) de faktiske dataene for "De Gamle er Eldst" FØR noe ble antatt.
|
||||||
|
1. **`POST .../courses/official-import` var IKKE idempotent** — å
|
||||||
|
importere samme bane på nytt (helt vanlig: flere økter spilles ofte
|
||||||
|
på samme bane) ga en 409 `DUPLICATE`-feil (migrasjon 010 sin sperre)
|
||||||
|
i stedet for å bare gi tilbake den allerede importerte banen. Fikset:
|
||||||
|
sjekker nå `external_course_ref` FØR noe teeoff-kall gjøres — finnes
|
||||||
|
banen fra før, returneres den eksisterende raden direkte (også
|
||||||
|
raskere, og robust mot at teeoff er nede akkurat da).
|
||||||
|
2. **Lagret navn var kun selve banens navn ("Hovedbanen"), ikke hvilken
|
||||||
|
klubb** — ubrukelig til å skille baner fra hverandre, siden mange
|
||||||
|
klubber navngir hovedbanen sin identisk. Fikset: navnet kombineres nå
|
||||||
|
til "{anlegg} – {bane}" (f.eks. "Tjøme Golfklubb – Hovedbanen") ved
|
||||||
|
import.
|
||||||
|
**Reell konsekvens av hull #1 funnet i produksjonsdata:** brukeren hadde,
|
||||||
|
mens hen forsøkte å søke opp Tjøme via det ØVERSTE banefeltet (som søker
|
||||||
|
organisasjonens EGNE baner, ikke teeoff), ved et uhell trigget «Opprett
|
||||||
|
ny bane: «Tj»»-snarveien og fått en tom, søppel `custom`-bane hengende på
|
||||||
|
Foursome-økten -- en ekte forvekslingsfelle mellom de to adskilte
|
||||||
|
bane-søkeflatene (eget vs. teeoff), ikke en kodefeil i seg selv.
|
||||||
|
**Data ryddet opp i EKTE `teecup_db`, bruker bekreftet eksplisitt:**
|
||||||
|
Foursome-økten pekt om til den allerede importerte, ekte Tjøme-banen
|
||||||
|
(samme bane som Fourball-økten allerede brukte -- nøyaktig det
|
||||||
|
organisatoren egentlig ønsket), søppel-«Tj»-banen slettet (bekreftet
|
||||||
|
ingen matcher/tee-er/hull hang på den FØR sletting), og den ekte banens
|
||||||
|
navn oppdatert til "Tjøme Golfklubb – Hovedbanen". Verifisert i etterkant
|
||||||
|
at begge økter nå peker til samme, korrekt navngitte bane.
|
||||||
|
**Verifisert mot ekte teeoff_api i scratch FØR utrulling:** importerte
|
||||||
|
Borregaard på nytt to ganger — andre kallet ga nøyaktig samme course-id
|
||||||
|
(ikke en duplikat-rad), navnet kom ut som "Borregaard Golfklubb –
|
||||||
|
Hovedbanen". `test_isolation.sql` 12/12. Ingen migrasjon, kun `teecup_api`
|
||||||
|
redeployet, `teeoff.no` upåvirket.
|
||||||
|
- **`PATCH`/`DELETE` for økter, LIVE (2026-07-18), samme dag:** brukeren
|
||||||
|
spurte rett etter opprydningen om det i det hele tatt var mulig å
|
||||||
|
rette/slette en feiloppsatt økt — det var det ikke (kun `POST`/`GET`
|
||||||
|
fantes). Ny `PATCH /orgs/{id}/sessions/{id}` (`app/routers/
|
||||||
|
tournaments.py`) for enkle felt (navn, klokkeslett/intervall, starthull,
|
||||||
|
poeng, handicap-brytere) via vanlig `exclude_unset`-mønster, PLUSS en egen
|
||||||
|
gren for bane-bytte. Ny `DELETE /orgs/{id}/sessions/{id}` — kun tomme
|
||||||
|
økter (ingen matcher), avviser med 409 ellers (bruk PATCH til å korrigere
|
||||||
|
i stedet).
|
||||||
|
**Banebytte-scenarioet brukeren selv reiste** ("5 hull spilt, oppdager
|
||||||
|
feil bane — slagene er ekte, utregningen er trolig feil") krevde egen
|
||||||
|
design: `match_participant.tee_id` peker til en tee som HØRER til den
|
||||||
|
gamle banen. Løst med `_remap_course()` — finner en tee med samme
|
||||||
|
navn+kjønn på den nye banen for hver allerede tillagte deltaker, flytter
|
||||||
|
dem dit, og avviser HELE bane-byttet tydelig (400, ingenting skrevet,
|
||||||
|
bekreftet transaksjonell rollback) hvis den nye banen mangler en
|
||||||
|
tilsvarende tee. Etter et vellykket bytte: handicap regnes om for alle
|
||||||
|
berørte deltakere, og matchstatus/poeng regnes om for HVER match i
|
||||||
|
økten — **bevisst uavhengig av om matchen allerede er avgjort** (brukeren
|
||||||
|
bekreftet eksplisitt at en bane-korrigering skal kunne endre et allerede
|
||||||
|
cachet resultat). `recompute_and_cache_match_state` i `app/routers/
|
||||||
|
scoring.py` gjort delt (fjernet ledende understrek) for gjenbruk fra
|
||||||
|
tournaments.py.
|
||||||
|
**Reelt, urelatert funn underveis i scratch-testingen, IKKE fikset:**
|
||||||
|
`tee_rating` lages i dag ALLTID kun med `full_18`-omfang (både ved
|
||||||
|
teeoff-import og manuell tee-opprettelse) — en økt satt til `front_9`/
|
||||||
|
`back_9` i `stroke`-modus kan derfor ALDRI få handicap beregnet
|
||||||
|
(`compute_and_store_side_handicaps` sin `tee_rating`-join finner aldri
|
||||||
|
noen rad), og dermed aldri avgjøre noen hull. `hole_result`-modus
|
||||||
|
upåvirket. Flagget til bruker, bevisst latt urørt denne runden.
|
||||||
|
**Verifisert grundig i scratch:** enkelt feltbytte (kun navn), fullt
|
||||||
|
banebytte-scenario bygget nøyaktig som brukerens eksempel (10 hull spilt
|
||||||
|
under feil bane, matchen allerede avgjort 10&8, PATCH til riktig bane →
|
||||||
|
tee-er ombyttet korrekt, handicap endret fra 7/9 til 10/13 under den nye
|
||||||
|
banens rating, matchstatus regnet på nytt med UENDREDE rå slagtall),
|
||||||
|
avvist bane-bytte ved manglende tee-match (bekreftet full rollback,
|
||||||
|
også av det urelaterte navnefeltet i samme kall), DELETE avvist på økt
|
||||||
|
med match (409) og godtatt på tom økt (204). `test_isolation.sql` 12/12.
|
||||||
|
Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` upåvirket.
|
||||||
|
Frontend (rediger-/slett-knapper i program-skjermen) ikke bygget ennå —
|
||||||
|
kun backend-kapasiteten denne runden.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||||
|
|
|
||||||
|
|
@ -564,6 +564,19 @@ andre skjermen når den ble generert i en egen prompt.
|
||||||
(`teecup_api`, `teecup_frontend`) bygget og redeployet, live sjekker OK
|
(`teecup_api`, `teecup_frontend`) bygget og redeployet, live sjekker OK
|
||||||
(`/health`, `/dashboard` → 200), `teeoff.no` upåvirket.
|
(`/health`, `/dashboard` → 200), `teeoff.no` upåvirket.
|
||||||
|
|
||||||
|
**Nytt 2026-07-18, ✅ BACKEND LIVE (frontend gjenstår):** `PATCH`/`DELETE`
|
||||||
|
for økter (`app/routers/tournaments.py`) — kunne tidligere verken rettes
|
||||||
|
eller slettes etter opprettelse. `DELETE` kun for tomme økter (409 hvis den
|
||||||
|
har matcher). `PATCH` dekker enkle felt fritt, pluss en egen, forsiktig
|
||||||
|
gren for bane-bytte (finner/flytter tilsvarende tee per allerede tillagt
|
||||||
|
deltaker, regner om handicap+matchstatus for hele økten etterpå — også for
|
||||||
|
allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
|
||||||
|
CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls
|
||||||
|
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
|
||||||
|
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
|
||||||
|
lages med `full_18`-omfang). Rediger-/slett-UI i program-skjermen ikke
|
||||||
|
bygget ennå.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Offisiell banedata fra teeoff — ✅ BYGGET OG LIVE 2026-07-18 (ADR-019)
|
### Offisiell banedata fra teeoff — ✅ BYGGET OG LIVE 2026-07-18 (ADR-019)
|
||||||
|
|
@ -610,6 +623,22 @@ vasket bort `?org=...`-parameteren fra URL-en. Fikset ved å fjerne det
|
||||||
indre `<form>`-elementet (vanlig `<div>` + Enter-tast/knapp-klikk i
|
indre `<form>`-elementet (vanlig `<div>` + Enter-tast/knapp-klikk i
|
||||||
stedet). Se CLAUDE.md-status for full root cause.
|
stedet). Se CLAUDE.md-status for full root cause.
|
||||||
|
|
||||||
|
**Nok en reell bug funnet og fikset samme uke, rapportert fra ekte bruk mot
|
||||||
|
`teecup.teeoff.no`:** import av samme teeoff-bane til flere økter (helt
|
||||||
|
normalt — flere runder spilles ofte på samme bane) ga en 409-feil i stedet
|
||||||
|
for å bare gjenbruke banen, og det lagrede navnet ("Hovedbanen" alene) ga
|
||||||
|
ingen måte å se hvilken klubb det gjaldt. Fikset: `POST .../courses/
|
||||||
|
official-import` er nå idempotent (gir tilbake eksisterende rad ved
|
||||||
|
reimport, sjekket FØR teeoff-kallet), og navnet lagres nå som
|
||||||
|
"{anlegg} – {bane}". Ekte produksjonsdata for "De Gamle er Eldst" ryddet
|
||||||
|
opp (en økt hadde ved et uhell fått en tom søppel-`custom`-bane — se
|
||||||
|
CLAUDE.md-status for full hendelse og rotårsak).
|
||||||
|
**Åpent, ikke løst i denne runden:** de to bane-søkeflatene (øverste felt
|
||||||
|
= organisasjonens egne baner + "opprett ny"-snarvei, "Hent bane fra
|
||||||
|
teeoff"-knappen lenger ned = offisielt søk) er lette å forveksle — det var
|
||||||
|
nettopp dette som forårsaket søppel-banen. Vurder en tydeligere UI-
|
||||||
|
sammenslåing eller rekkefølge-endring i en senere runde.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UX / frontend (senere fase)
|
## UX / frontend (senere fase)
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,23 @@ async def import_official_course(
|
||||||
body: OfficialCourseImport,
|
body: OfficialCourseImport,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
) -> Course:
|
) -> Course:
|
||||||
|
external_ref = f"{body.facility_slug}:{body.teeoff_course_id}"
|
||||||
|
|
||||||
|
# Idempotent: importerer organisatoren samme bane på nytt (vanlig --
|
||||||
|
# flere økter spilles ofte på samme bane), gi tilbake den EKSISTERENDE
|
||||||
|
# raden i stedet for en 409-feil. Sjekket FØR noe teeoff-kall gjøres,
|
||||||
|
# både raskere og mer robust mot at teeoff er nede akkurat da. Fant
|
||||||
|
# og fikset 2026-07-18 etter en reell brukerrapport: duplikat-sperren
|
||||||
|
# (migrasjon 010) var riktig i prinsippet, men ga ingen vei videre for
|
||||||
|
# den helt vanlige "bruk samme bane på runde 2" siden av saken.
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
f"SELECT {_COURSE_COLUMNS} FROM course WHERE external_course_ref = $1",
|
||||||
|
external_ref,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return Course(**dict(existing))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
facility = await teeoff_client.get_facility(body.facility_slug)
|
facility = await teeoff_client.get_facility(body.facility_slug)
|
||||||
except teeoff_client.TeeoffNotFoundError:
|
except teeoff_client.TeeoffNotFoundError:
|
||||||
|
|
@ -372,9 +389,13 @@ async def import_official_course(
|
||||||
"Banen har ingen tee med registrert rating/slope i teeoff ennå.",
|
"Banen har ingen tee med registrert rating/slope i teeoff ennå.",
|
||||||
)
|
)
|
||||||
|
|
||||||
external_ref = f"{body.facility_slug}:{body.teeoff_course_id}"
|
|
||||||
par_total = sum(h["par"] for h in holes)
|
par_total = sum(h["par"] for h in holes)
|
||||||
|
|
||||||
|
# Navn kombinerer anlegg + bane -- "Hovedbanen" alene er tvetydig (mange
|
||||||
|
# klubber navngir hovedbanen sin nøyaktig likt). Fant og fikset samme
|
||||||
|
# runde som idempotent-fiksen over, samme bakenforliggende brukerrapport.
|
||||||
|
course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}"
|
||||||
|
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
course_row = await conn.fetchrow(
|
course_row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -383,7 +404,7 @@ async def import_official_course(
|
||||||
RETURNING {_COURSE_COLUMNS}
|
RETURNING {_COURSE_COLUMNS}
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
course_data.get("name") or facility.get("name"),
|
course_name,
|
||||||
external_ref,
|
external_ref,
|
||||||
)
|
)
|
||||||
course_id = course_row["id"]
|
course_id = course_row["id"]
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def _recompute_and_cache_match_state(conn, match_id: str) -> None:
|
async def recompute_and_cache_match_state(conn, match_id: str) -> None:
|
||||||
match = await conn.fetchrow(
|
match = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT m.id::text, s.format, s.scoring_mode,
|
SELECT m.id::text, s.format, s.scoring_mode,
|
||||||
|
|
@ -311,7 +311,7 @@ async def submit_hole_score(
|
||||||
body.gross_strokes,
|
body.gross_strokes,
|
||||||
)
|
)
|
||||||
|
|
||||||
await _recompute_and_cache_match_state(conn, match_id)
|
await recompute_and_cache_match_state(conn, match_id)
|
||||||
return HoleScoreOut(**dict(row))
|
return HoleScoreOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -386,7 +386,7 @@ async def submit_hole_result(
|
||||||
body.winning_side,
|
body.winning_side,
|
||||||
)
|
)
|
||||||
|
|
||||||
await _recompute_and_cache_match_state(conn, match_id)
|
await recompute_and_cache_match_state(conn, match_id)
|
||||||
return HoleResultOut(**dict(row))
|
return HoleResultOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ from ..auth import get_authorized_org
|
||||||
from ..blind_draw import locked_team_ids
|
from ..blind_draw import locked_team_ids
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
|
from .scoring import recompute_and_cache_match_state
|
||||||
from .. import storage
|
from .. import storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -542,6 +544,190 @@ async def create_session(
|
||||||
return _session_out(row, set())
|
return _session_out(row, set())
|
||||||
|
|
||||||
|
|
||||||
|
class SessionUpdate(BaseModel):
|
||||||
|
"""Bevisst IKKE format/scoring_mode/hole_config ennå -- disse har dypere
|
||||||
|
strukturelle konsekvenser for allerede registrerte scores (bytter man
|
||||||
|
scoring_mode havner gamle oppføringer i feil tabell; bytter man format
|
||||||
|
endres reglene for match_participant) som fortjener egen, senere design.
|
||||||
|
course_id er den ENESTE feltet som krever spesialhåndtering her (se
|
||||||
|
_remap_course) -- resten er en vanlig exclude_unset-oppdatering."""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
scheduled_at: datetime | None = None
|
||||||
|
tee_interval_minutes: int | None = None
|
||||||
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||||
|
points_per_match: float | None = None
|
||||||
|
allowance_override: dict | None = None
|
||||||
|
course_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
|
||||||
|
"""Reist av brukeren (2026-07-18): en organisator oppdager MIDT I RUNDEN
|
||||||
|
at feil bane var satt opp -- de faktisk registrerte slagene er ekte og
|
||||||
|
skal IKKE røres, men `match_participant.tee_id` peker til en tee som
|
||||||
|
hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den
|
||||||
|
nye for at handicap-beregningen skal bli riktig videre.
|
||||||
|
|
||||||
|
Matcher på tee-navn + kjønn (case-insensitive). Finnes ingen tilsvarende
|
||||||
|
tee på den nye banen for én eller flere spillere, avvises HELE
|
||||||
|
bane-byttet tydelig FØR noe skrives -- heller stoppe enn å gjette feil
|
||||||
|
(samme "fail loudly" prinsipp som ADR-019 sin importvalidering).
|
||||||
|
"""
|
||||||
|
course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id)
|
||||||
|
if course_exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
|
||||||
|
|
||||||
|
participants = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT mp.id::text AS id, t.name AS tee_name, t.gender AS tee_gender
|
||||||
|
FROM match_participant mp
|
||||||
|
JOIN match m ON m.id = mp.match_id
|
||||||
|
JOIN tee t ON t.id = mp.tee_id
|
||||||
|
WHERE m.session_id = $1
|
||||||
|
""",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
remap: dict[str, str] = {}
|
||||||
|
missing: set[str] = set()
|
||||||
|
for p in participants:
|
||||||
|
new_tee_id = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT id::text FROM tee
|
||||||
|
WHERE course_id = $1 AND lower(name) = lower($2) AND gender IS NOT DISTINCT FROM $3
|
||||||
|
""",
|
||||||
|
new_course_id,
|
||||||
|
p["tee_name"],
|
||||||
|
p["tee_gender"],
|
||||||
|
)
|
||||||
|
if new_tee_id is None:
|
||||||
|
missing.add(p["tee_name"])
|
||||||
|
else:
|
||||||
|
remap[p["id"]] = new_tee_id
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Den nye banen mangler en tee som matcher: "
|
||||||
|
+ ", ".join(sorted(missing))
|
||||||
|
+ ". Legg til tilsvarende tee på den nye banen før du bytter.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for participant_id, new_tee_id in remap.items():
|
||||||
|
await conn.execute("UPDATE match_participant SET tee_id = $1 WHERE id = $2", new_tee_id, participant_id)
|
||||||
|
|
||||||
|
await conn.execute("UPDATE session SET course_id = $1 WHERE id = $2", new_course_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _recompute_session_matches(
|
||||||
|
conn, session_id: str, format_: str, hole_config: str, allowance_override: dict | None
|
||||||
|
) -> None:
|
||||||
|
"""Regner om handicap + matchstatus for HVER match i økten -- brukt etter
|
||||||
|
et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede
|
||||||
|
er avgjort (brukeren bekreftet eksplisitt: en bane-korrigering skal kunne
|
||||||
|
endre et allerede cachet resultat, ikke bare blokkeres av den vanlige
|
||||||
|
ALREADY_DECIDED-sperren som gjelder NYE slag-innsendinger)."""
|
||||||
|
config = parse_allowance_config(format_, allowance_override)
|
||||||
|
matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id)
|
||||||
|
for m in matches:
|
||||||
|
for side in ("a", "b"):
|
||||||
|
await compute_and_store_side_handicaps(conn, m["id"], side, format_, hole_config, config)
|
||||||
|
await recompute_and_cache_match_state(conn, m["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/orgs/{organization_id}/sessions/{session_id}", response_model=SessionOut)
|
||||||
|
async def update_session(
|
||||||
|
session_id: str,
|
||||||
|
body: SessionUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> SessionOut:
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
session = await conn.fetchrow(
|
||||||
|
"SELECT format, hole_config::text AS hole_config, course_id::text AS course_id FROM session WHERE id = $1",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
||||||
|
|
||||||
|
new_course_id = updates.pop("course_id", None)
|
||||||
|
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
set_clauses = []
|
||||||
|
values: list = []
|
||||||
|
for i, (key, value) in enumerate(updates.items(), start=1):
|
||||||
|
if key == "allowance_override":
|
||||||
|
set_clauses.append(f"allowance_override = ${i}::jsonb")
|
||||||
|
values.append(json.dumps(value) if value is not None else None)
|
||||||
|
else:
|
||||||
|
set_clauses.append(f"{key} = ${i}")
|
||||||
|
values.append(value)
|
||||||
|
values.append(session_id)
|
||||||
|
await conn.execute(
|
||||||
|
f"UPDATE session SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_course_id is not None and new_course_id != session["course_id"]:
|
||||||
|
await _remap_course(conn, session_id, new_course_id)
|
||||||
|
allowance_or_points_changed = True # banebytte krever alltid omregning
|
||||||
|
|
||||||
|
if allowance_or_points_changed:
|
||||||
|
current = await conn.fetchrow(
|
||||||
|
"SELECT allowance_override::text AS allowance_override FROM session WHERE id = $1",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None
|
||||||
|
await _recompute_session_matches(
|
||||||
|
conn, session_id, session["format"], session["hole_config"], current_override
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
|
scheduled_at, tee_interval_minutes, start_hole
|
||||||
|
FROM session WHERE id = $1
|
||||||
|
""",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
locked = await locked_team_ids(conn, session_id)
|
||||||
|
|
||||||
|
return _session_out(row, locked)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/orgs/{organization_id}/sessions/{session_id}", status_code=204)
|
||||||
|
async def delete_session(
|
||||||
|
session_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> None:
|
||||||
|
"""Kun tomme økter kan slettes (ingen matcher opprettet ennå) -- dekker
|
||||||
|
"jeg satte opp feil økt, fjern den" FØR den er tatt i bruk. En økt med
|
||||||
|
matcher (og dermed potensielt registrerte scores/poeng) må rettes med
|
||||||
|
PATCH (bane-/felt-korrigering) i stedet for å kunne slettes -- hindrer
|
||||||
|
at noen ved et uhell sletter ekte kamper/resultater."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
|
||||||
|
if session is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
||||||
|
|
||||||
|
match_count = await conn.fetchval("SELECT count(*) FROM match WHERE session_id = $1", session_id)
|
||||||
|
if match_count > 0:
|
||||||
|
raise app_error(
|
||||||
|
409,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Økten har allerede matcher og kan ikke slettes -- bruk PATCH for å korrigere den i stedet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute("DELETE FROM session WHERE id = $1", session_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
|
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue