teecup/app/routers/rounds.py
Erol Haagenrud 3270d044de Migrasjonen er kjørt, og jeg har fortsatt helt til et komplett, verifisert API-lag.
Underveis fant og fikset jeg et reelt hull: round_participant manglet kolonner for selve rating-tallene (kun tee-navnet var lagret) — løst med migrasjon 021, kjørt mot ekte database sammen med resten.

Bygget og rullet ut:

app/routers/rounds.py — opprett/liste/hent/slett runde, legg til/fjern gjestedeltakere, registrer statistikk hull for hull, og en fullfør-endepunkt som kjører hele kjeden (Adjusted Gross Score → Score Differential → tellende-vurdering).
26 scratch-sjekker grønt, pluss en egen test mot den ekte kjørende teeoff-tjenesten som bekreftet at live-oppslaget fungerer nøyaktig som tiltenkt (ingen bane-kopi skrives noe sted).
Begge migrasjoner + ny kode er nå live mot ekte teecup_db/teecup_api. teeoff.no upåvirket.
Jeg la også inn /rounds og /personal-courses i frontendens proxy-oppsett proaktivt, siden prosjektet har blitt bitt av glemte proxy-ruter før — ikke deployet ennå, tas med neste gang frontend faktisk bygges.

Notatet om slaglengde-måling og avstand-til-punkter-på-banen er lagret i CLAUDE.md og FEATURE_BACKLOG.md, med en tydelig flagget avhengighet: dette krever geografiske/GPS-data som ingen nåværende kilde (verken teeoff eller den nye banekatalogen) har.
2026-07-22 11:31:08 +02:00

619 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Frittstående rundeføring med detaljert statistikk (ADR-033).
Eid av en BRUKER (`app_user.id`), ikke en organisasjon -- INGEN RLS på
`round`/`round_participant`/`round_hole`/`personal_course*` (Beslutning A).
Samme mønster som personlig profil/HCP-historikk/sekundær e-post: kun
`plain_connection()`, autorisasjon håndheves eksplisitt her med
`WHERE owner_user_id = $1` (eller via round_id -> owner-kjeden).
Banedata (Beslutning C): offisielle teeoff-baner slås opp LIVE ved hver
runde-opprettelse/deltaker-tilføyelse (ingen lokal kopi) via samme
`teeoff_client` som den org-scopede import-flyten i courses.py bruker --
men her INGEN `course`/`hole`/`tee`-rader skrives, kun et navn-snapshot
på selve runden og et rating-snapshot per deltaker.
v1-avgrensning (eksplisitt notert i ADR-033, ikke løst her): en deltaker
med `user_id` satt får IKKE egen tilgang til runden -- derfor støtter
DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte
kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for
runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå.
HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer
IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført,
tellende resultat lagres kun som `round_participant.score_differential`.
"""
from __future__ import annotations
from datetime import date
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from .. import teeoff_client
from ..auth import CurrentUser, get_current_user
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from handicap_engine import (
adjusted_gross_score,
allocate_strokes_by_index,
course_handicap,
round_counts_for_handicap,
score_differential,
)
router = APIRouter(tags=["rounds"])
# ---------------------------------------------------------------------------
# Global banekatalog for egendefinerte (ikke-teeoff) baner
# ---------------------------------------------------------------------------
class PersonalCourseHoleIn(BaseModel):
hole_number: int = Field(ge=1, le=18)
par: int = Field(ge=3, le=6)
stroke_index: int = Field(ge=1, le=18)
class PersonalCourseTeeRatingIn(BaseModel):
gender: Literal["m", "f"]
course_rating: float
slope_rating: int = Field(ge=55, le=155)
par: int
class PersonalCourseTeeIn(BaseModel):
name: str
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
class PersonalCourseCreate(BaseModel):
name: str
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
class PersonalCourseOut(BaseModel):
id: str
name: str
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
async def search_personal_courses(
q: str = "",
user: CurrentUser = Depends(get_current_user),
) -> list[PersonalCourseOut]:
async with plain_connection() as conn:
rows = await conn.fetch(
"SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20",
f"%{q.strip()}%",
)
return [PersonalCourseOut(**dict(r)) for r in rows]
@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
async def create_personal_course(
body: PersonalCourseCreate,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
numbers = sorted(h.hole_number for h in body.holes)
indexes = sorted(h.stroke_index for h in body.holes)
if numbers != list(range(1, 19)) or indexes != list(range(1, 19)):
raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")
async with plain_connection() as conn, translate_db_errors():
async with conn.transaction():
course_row = await conn.fetchrow(
"INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name",
body.name.strip(),
user.user_id,
)
for h in body.holes:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
course_row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
course_row["id"],
t.name.strip(),
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
return PersonalCourseOut(**dict(course_row))
# ---------------------------------------------------------------------------
# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker)
# ---------------------------------------------------------------------------
#
# Returnerer (par_per_hole, stroke_index_per_hole, tee_names) og en
# funksjon for å slå opp (course_rating, slope_rating, par) for en gitt
# tee+kjønn -- felles for BÅDE teeoff (live) og personal_course (lagret).
class _ResolvedCourse:
def __init__(self, holes: list[tuple[int, int, int]], course_name: str):
# holes: liste av (hole_number, par, stroke_index), sortert 1..18
self.holes = holes
self.course_name = course_name
self._ratings: dict[tuple[str, str], tuple[float, int, int]] = {}
def add_rating(self, tee_name: str, gender: str, course_rating: float, slope_rating: int, par: int) -> None:
self._ratings[(tee_name, gender)] = (course_rating, slope_rating, par)
def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
return self._ratings.get((tee_name, gender))
async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse:
try:
facility = await teeoff_client.get_facility(facility_slug)
except teeoff_client.TeeoffNotFoundError:
raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
except teeoff_client.TeeoffUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")
course_data = next((c for c in facility.get("courses", []) if c.get("id") == teeoff_course_id), None)
if course_data is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.")
raw_holes = course_data.get("holes") or []
if len(raw_holes) != 18:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå.")
for h in raw_holes:
if h.get("par") is None or h.get("hcp_index") is None:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull.")
course_name = f"{facility.get('name')} {course_data.get('name') or 'Bane'}"
resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes),
course_name=course_name,
)
for t in course_data.get("tees") or []:
name = t.get("name") or "Tee"
if t.get("cr_men") is not None and t.get("slope_men") is not None:
resolved.add_rating(name, "m", float(t["cr_men"]), int(t["slope_men"]), sum(h[1] for h in resolved.holes))
if t.get("cr_women") is not None and t.get("slope_women") is not None:
resolved.add_rating(name, "f", float(t["cr_women"]), int(t["slope_women"]), sum(h[1] for h in resolved.holes))
return resolved
async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
async with plain_connection() as conn:
course_row = await conn.fetchrow("SELECT name FROM personal_course WHERE id = $1", personal_course_id)
if course_row is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM personal_course_hole WHERE personal_course_id = $1",
personal_course_id,
)
tee_rows = await conn.fetch(
"""
SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
FROM personal_course_tee t
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
WHERE t.personal_course_id = $1
""",
personal_course_id,
)
resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["stroke_index"]) for h in hole_rows),
course_name=course_row["name"],
)
for t in tee_rows:
resolved.add_rating(t["tee_name"], t["gender"], float(t["course_rating"]), t["slope_rating"], t["par"])
return resolved
# ---------------------------------------------------------------------------
# Runder
# ---------------------------------------------------------------------------
class RoundCreate(BaseModel):
course_source: Literal["teeoff", "custom"]
teeoff_facility_slug: str | None = None
teeoff_course_id: int | None = None
personal_course_id: str | None = None
tee_name: str
played_at: date
start_hole: int = Field(default=1, ge=1, le=18)
holes_planned: Literal[9, 18] = 18
class RoundParticipantOut(BaseModel):
id: str
user_id: str | None
guest_name: str | None
is_owner: bool
gender: str
handicap_index_snapshot: float | None
course_handicap_snapshot: int | None
counts_for_handicap: bool
score_differential: float | None
class RoundOut(BaseModel):
id: str
course_source: str
course_name_snapshot: str
tee_name_snapshot: str
played_at: date
start_hole: int
holes_planned: int
completed_at: str | None
participants: list[RoundParticipantOut]
async def _load_round_out(conn, round_id: str) -> RoundOut:
round_row = await conn.fetchrow(
"""
SELECT id::text AS id, course_source, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, completed_at
FROM round WHERE id = $1
""",
round_id,
)
participant_rows = await conn.fetch(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential
FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
""",
round_id,
)
return RoundOut(
id=round_row["id"],
course_source=round_row["course_source"],
course_name_snapshot=round_row["course_name_snapshot"],
tee_name_snapshot=round_row["tee_name_snapshot"],
played_at=round_row["played_at"],
start_hole=round_row["start_hole"],
holes_planned=round_row["holes_planned"],
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
)
async def _create_participant(
conn,
round_id: str,
resolved: _ResolvedCourse,
tee_name: str,
*,
user_id: str | None,
guest_name: str | None,
is_owner: bool,
gender: str,
handicap_index: float | None,
) -> str:
rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None
course_handicap_snapshot = None
if rating is not None:
course_rating, slope_rating, tee_par = rating
if handicap_index is not None:
course_handicap_snapshot = course_handicap(handicap_index, slope_rating, course_rating, tee_par)
participant_row = await conn.fetchrow(
"""
INSERT INTO round_participant
(round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id::text AS id
""",
round_id,
user_id,
guest_name,
is_owner,
gender,
handicap_index,
course_rating,
slope_rating,
tee_par,
course_handicap_snapshot,
)
participant_id = participant_row["id"]
for hole_number, par, stroke_index in resolved.holes:
await conn.execute(
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
participant_id,
hole_number,
par,
stroke_index,
)
return participant_id
@router.post("/rounds", response_model=RoundOut, status_code=201)
async def create_round(
body: RoundCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
if body.course_source == "teeoff":
if not body.teeoff_facility_slug or not body.teeoff_course_id:
raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
else:
if not body.personal_course_id:
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
resolved = await _resolve_personal_course(body.personal_course_id)
async with plain_connection() as conn:
owner_row = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
user.user_id,
)
if owner_row is None or owner_row["gender"] is None:
raise app_error(400, "VALIDATION_FAILED", "Fullfør profilen din (kjønn/HCP) før du registrerer en runde.")
if resolved.rating_for(body.tee_name, owner_row["gender"]) is None:
raise app_error(
400, "VALIDATION_FAILED",
"Valgt utslag har ingen rating for ditt registrerte kjønn på denne banen.",
)
async with conn.transaction(), translate_db_errors():
round_row = await conn.fetchrow(
"""
INSERT INTO round
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id::text AS id
""",
user.user_id,
body.course_source,
body.teeoff_facility_slug,
str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
body.personal_course_id,
resolved.course_name,
body.tee_name,
body.played_at,
body.start_hole,
body.holes_planned,
)
round_id = round_row["id"]
await _create_participant(
conn, round_id, resolved, body.tee_name,
user_id=user.user_id, guest_name=None, is_owner=True,
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
)
return await _load_round_out(conn, round_id)
@router.get("/rounds", response_model=list[RoundOut])
async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]:
async with plain_connection() as conn:
ids = await conn.fetch(
"SELECT id::text AS id FROM round WHERE owner_user_id = $1 ORDER BY played_at DESC, created_at DESC",
user.user_id,
)
return [await _load_round_out(conn, r["id"]) for r in ids]
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id)
if row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if row["owner_user_id"] != user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
@router.get("/rounds/{round_id}", response_model=RoundOut)
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
return await _load_round_out(conn, round_id)
@router.delete("/rounds/{round_id}", status_code=204)
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
await conn.execute("DELETE FROM round WHERE id = $1", round_id)
# ---------------------------------------------------------------------------
# Gjest-deltakere (flighten) -- se moduldoc om hvorfor kun gjester i v1
# ---------------------------------------------------------------------------
class GuestParticipantCreate(BaseModel):
guest_name: str = Field(min_length=1, max_length=100)
gender: Literal["m", "f", "x"]
handicap_index: float | None = Field(default=None, ge=-10, le=54)
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
async def add_guest_participant(
round_id: str,
body: GuestParticipantCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1",
round_id,
)
if round_row["course_source"] == "teeoff":
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
else:
resolved = await _resolve_personal_course(round_row["personal_course_id"])
if resolved.rating_for(round_row["tee_name_snapshot"], body.gender) is None and body.handicap_index is not None:
raise app_error(
400, "VALIDATION_FAILED",
"Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
)
async with conn.transaction(), translate_db_errors():
participant_id = await _create_participant(
conn, round_id, resolved, round_row["tee_name_snapshot"],
user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
gender=body.gender, handicap_index=body.handicap_index,
)
row = await conn.fetchrow(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential
FROM round_participant WHERE id = $1
""",
participant_id,
)
return RoundParticipantOut(**dict(row))
@router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204)
async def remove_guest_participant(
round_id: str,
participant_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow(
"SELECT is_owner FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
if row["is_owner"]:
raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id)
# ---------------------------------------------------------------------------
# Hull-for-hull-registrering
# ---------------------------------------------------------------------------
class HoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)
putts: int | None = Field(default=None, ge=0, le=10)
club_off_tee: str | None = Field(default=None, max_length=50)
tee_shot_result: Literal["fairway", "left", "right"] | None = None
approach_result: Literal["hit", "long", "short", "left", "right"] | None = None
chip_count: int | None = Field(default=None, ge=0)
bunker_shot_count: int | None = Field(default=None, ge=0)
penalty_strokes: int | None = Field(default=None, ge=0)
first_putt_distance_m: float | None = Field(default=None, ge=0)
@router.patch("/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}")
async def update_hole(
round_id: str,
participant_id: str,
hole_number: int,
body: HoleUpdate,
user: CurrentUser = Depends(get_current_user),
) -> dict:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
owner_check = await conn.fetchval(
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if owner_check is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
async with translate_db_errors():
row = await conn.fetchrow(
"""
UPDATE round_hole SET
played = $3, score = $4, putts = $5, club_off_tee = $6,
tee_shot_result = $7, approach_result = $8, chip_count = $9,
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12
WHERE round_participant_id = $1 AND hole_number = $2
RETURNING id
""",
participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_m,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
return {"ok": True}
# ---------------------------------------------------------------------------
# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G)
# ---------------------------------------------------------------------------
@router.post("/rounds/{round_id}/complete", response_model=RoundOut)
async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id)
participants = await conn.fetch(
"""
SELECT id::text AS id, handicap_index_snapshot::float AS handicap_index_snapshot,
course_rating_snapshot::float AS course_rating_snapshot,
slope_rating_snapshot, tee_par_snapshot
FROM round_participant WHERE round_id = $1
""",
round_id,
)
for p in participants:
holes = await conn.fetch(
"""
SELECT hole_number, par, stroke_index, played, score
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""",
p["id"],
)
played_count = sum(1 for h in holes if h["played"])
counts = round_counts_for_handicap(played_count, round_row["holes_planned"])
differential = None
if counts and p["handicap_index_snapshot"] is not None and p["course_rating_snapshot"] is not None:
pars = [h["par"] for h in holes]
strokes_received = allocate_strokes_by_index(
_course_handicap_from_row(p),
[h["stroke_index"] for h in holes],
)
scores = [h["score"] if h["played"] else None for h in holes]
ags = adjusted_gross_score(scores, pars, strokes_received)
differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"])
await conn.execute(
"UPDATE round_participant SET counts_for_handicap = $2, score_differential = $3 WHERE id = $1",
p["id"], counts, differential,
)
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
return await _load_round_out(conn, round_id)
def _course_handicap_from_row(p) -> int:
return course_handicap(
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
)