Import gender tees + front/back split ratings; front/back/9 hole selection

Course import now fetches both male and female tees, each with their own
per-tee stroke indices extracted from the holes array. Front and back 9
slope/rating are stored per tee for more accurate WHS handicap calculation.

New game creation replaces the 9/18 radio with a course-aware selector:
18-hole courses offer 18 holes, Front 9, or Back 9; 9-hole courses auto-
select. start_hole is stored on the game and used throughout scoring,
handicap calculation, and scorecard rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 22:37:20 +01:00
parent 5432734240
commit 3e98f5ae54
6 changed files with 211 additions and 78 deletions
+40 -16
View File
@@ -1,3 +1,4 @@
import json
import os
import secrets
from datetime import datetime, timezone
@@ -43,7 +44,7 @@ async def _get_course_detail(cid: str, db: aiosqlite.Connection):
) as c:
holes = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT id, name, slope, rating FROM course_tees WHERE course_id = ? ORDER BY name",
"SELECT id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE course_id = ? ORDER BY gender, name",
(cid,),
) as c:
tees = [dict(r) for r in await c.fetchall()]
@@ -197,10 +198,10 @@ async def import_course(api_id: int, request: Request,
holes_count = n
break
# Hole par + stroke index from first tee that has hole data
# Hole par + course-level stroke index from first male tee with hole data (fallback for display)
hole_pars: dict[int, int] = {}
hole_sis: dict[int, int] = {}
for t in all_tees:
for t in tees_male:
hole_data = t.get("holes") or []
if hole_data:
for i, h in enumerate(hole_data[:holes_count], 1):
@@ -220,15 +221,30 @@ async def import_course(api_id: int, request: Request,
"INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, i, hole_pars.get(i, 4), hole_sis.get(i)),
)
for t in tees_male:
tname = (t.get("tee_name") or "").strip()
slope = t.get("slope_rating")
rating = t.get("course_rating")
if tname and slope and rating:
await db.execute(
"INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, tname, slope, rating),
)
def _tee_stroke_indices(t: dict) -> str | None:
"""Extract per-hole stroke indices from a tee's holes array as JSON."""
si_map = {}
for i, h in enumerate((t.get("holes") or [])[:holes_count], 1):
if h.get("handicap"):
si_map[i] = h["handicap"]
return json.dumps(si_map) if si_map else None
for gender, tee_list in (("male", tees_male), ("female", tees_female)):
for t in tee_list:
tname = (t.get("tee_name") or "").strip()
slope = t.get("slope_rating")
rating = t.get("course_rating")
if tname and slope and rating:
await db.execute(
"""INSERT INTO course_tees
(id, course_id, name, slope, rating, gender, stroke_indices,
front_slope, front_rating, back_slope, back_rating)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(secrets.token_urlsafe(10), cid, tname, slope, rating, gender, _tee_stroke_indices(t),
t.get("front_slope_rating"), t.get("front_course_rating"),
t.get("back_slope_rating"), t.get("back_course_rating")),
)
await db.commit()
return RedirectResponse(
f"/admin/courses/{cid}/edit?success=Course+imported+successfully.", status_code=302
@@ -339,11 +355,15 @@ async def delete_course(cid: str, user: dict = Depends(require_admin),
@router.post("/admin/courses/{cid}/tees/new")
async def add_tee(cid: str, name: str = Form(...),
slope: int = Form(...), rating: float = Form(...),
gender: str = Form("male"),
front_slope: int | None = Form(None), front_rating: float | None = Form(None),
back_slope: int | None = Form(None), back_rating: float | None = Form(None),
user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
gender = gender if gender in ("male", "female") else "male"
await db.execute(
"INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, name.strip(), slope, rating),
"INSERT INTO course_tees (id, course_id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating) VALUES (?,?,?,?,?,?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, name.strip(), slope, rating, gender, front_slope, front_rating, back_slope, back_rating),
)
await db.commit()
return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+added.", status_code=302)
@@ -352,11 +372,15 @@ async def add_tee(cid: str, name: str = Form(...),
@router.post("/admin/courses/{cid}/tees/{tid}/edit")
async def edit_tee(cid: str, tid: str, name: str = Form(...),
slope: int = Form(...), rating: float = Form(...),
gender: str = Form("male"),
front_slope: int | None = Form(None), front_rating: float | None = Form(None),
back_slope: int | None = Form(None), back_rating: float | None = Form(None),
user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
gender = gender if gender in ("male", "female") else "male"
await db.execute(
"UPDATE course_tees SET name = ?, slope = ?, rating = ? WHERE id = ? AND course_id = ?",
(name.strip(), slope, rating, tid, cid),
"UPDATE course_tees SET name = ?, slope = ?, rating = ?, gender = ?, front_slope = ?, front_rating = ?, back_slope = ?, back_rating = ? WHERE id = ? AND course_id = ?",
(name.strip(), slope, rating, gender, front_slope, front_rating, back_slope, back_rating, tid, cid),
)
await db.commit()
return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+updated.", status_code=302)
+61 -34
View File
@@ -205,7 +205,7 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
) as c:
courses = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
"SELECT id, course_id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees ORDER BY course_id, gender, name"
) as c:
tee_rows = [dict(r) for r in await c.fetchall()]
@@ -213,7 +213,9 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
tees_by_course: dict[str, list] = {}
for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]}
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
)
return templates.TemplateResponse(
@@ -263,13 +265,15 @@ async def create_game(request: Request, holes_count: int = Form(...),
) as c:
all_users = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
"SELECT id, course_id, name, slope, rating, gender, front_slope, front_rating, back_slope, back_rating FROM course_tees ORDER BY course_id, gender, name"
) as c:
tee_rows = [dict(r) for r in await c.fetchall()]
tees_by_course: dict[str, list] = {}
for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]}
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
)
return templates.TemplateResponse(
"games/new_game.html",
@@ -279,6 +283,8 @@ async def create_game(request: Request, holes_count: int = Form(...),
game_id = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat()
start_hole_raw = form.get("start_hole", "1")
start_hole = int(start_hole_raw) if start_hole_raw in ("1", "10") else 1
carryover = 1 if form.get("carryover") == "1" else 0
skin_value_str = (form.get("skin_value") or "").strip()
skin_value = float(skin_value_str) if skin_value_str else None
@@ -287,12 +293,12 @@ async def create_game(request: Request, holes_count: int = Form(...),
side_skins_enabled = 1 if form.get("side_skins_enabled") == "1" else 0
await db.execute(
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled),
"INSERT INTO games (id, format, holes_count, start_hole, status, created_by, created_at, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(game_id, format, holes_count, start_hole, "active", user["id"], now, course_id, carryover, skin_value, skin_double_back_nine, skins_net, side_skins_enabled),
)
# Pre-fetch course holes for handicap calculation
course_par_played = 0 # par for the holes being played
course_par_played = 0 # par for the actual holes being played
course_total_holes = holes_count # assume matches unless we find otherwise
hole_stroke_indices: dict[int, int] = {}
if course_id:
@@ -305,23 +311,20 @@ async def create_game(request: Request, holes_count: int = Form(...),
(course_id,),
) as c:
for row in await c.fetchall():
if row["hole_number"] <= holes_count:
h = row["hole_number"]
if start_hole <= h < start_hole + holes_count:
course_par_played += row["par"] or 0
if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
hole_stroke_indices[h] = row["stroke_index"]
# For a 9-hole game on an 18-hole course the tee ratings are 18-hole values.
# Per WHS: compute 18-hole course handicap using played-par×2 as the 18-hole par, then halve.
# Using played-par×2 avoids relying on back-nine holes being present in course_holes.
nine_of_eighteen = (holes_count == 9 and course_total_holes == 18)
nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen)
is_back_nine = nine_of_eighteen and start_hole == 10
played_holes = list(range(1, holes_count + 1))
played_with_si = {h: hole_stroke_indices[h] for h in played_holes if h in hole_stroke_indices}
hole_rank: dict[int, int] = {}
if played_with_si:
sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h])
hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)}
n_si = len(played_with_si)
played_holes = list(range(start_hole, start_hole + holes_count))
# Fallback formula for 9-of-18 without split ratings: compute 18-hole CH then halve.
# Use played-par×2 as estimated 18-hole par.
par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played
for uid in player_ids:
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
@@ -329,24 +332,45 @@ async def create_game(request: Request, holes_count: int = Form(...),
hc = row["handicap"] if row else None
tee_id = form.get(f"tee_{uid}") or None
tee_row = None
if tee_id:
async with db.execute(
"SELECT slope, rating, stroke_indices, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE id = ?",
(tee_id,),
) as c:
tee_row = await c.fetchone()
playing_handicap = None
strokes_allocation_json = None
par_for_formula = course_par_played * 2 if nine_of_eighteen else course_par_played
# Dedicated 9-hole courses have 9-hole ratings: WHS uses HI÷2 in the formula.
# For 9-of-18 the full HI is used and the result halved instead.
nine_hole_dedicated = (holes_count == 9 and not nine_of_eighteen)
if hc is not None and tee_id and par_for_formula:
async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c:
tee_row = await c.fetchone()
if tee_row:
if hc is not None and tee_row and par_for_formula:
if is_back_nine and tee_row["back_slope"] and tee_row["back_rating"]:
# Back 9 specific ratings available — use directly
ph = round(hc * (tee_row["back_slope"] / 113) + (tee_row["back_rating"] - course_par_played))
playing_handicap = ph
elif nine_of_eighteen and not is_back_nine and tee_row["front_slope"] and tee_row["front_rating"]:
# Front 9 specific ratings available — use directly
ph = round(hc * (tee_row["front_slope"] / 113) + (tee_row["front_rating"] - course_par_played))
playing_handicap = ph
else:
hc_for_formula = hc / 2 if nine_hole_dedicated else hc
ph = round(hc_for_formula * (tee_row["slope"] / 113) + (tee_row["rating"] - par_for_formula))
playing_handicap = round(ph / 2) if nine_of_eighteen else ph
if hole_rank and playing_handicap > 0:
base, extra = divmod(playing_handicap, n_si)
allocation = {str(h): base + (1 if hole_rank[h] <= extra else 0)
for h in played_holes if h in played_with_si}
strokes_allocation_json = json.dumps(allocation)
# Per-tee stroke indices take priority over course-level fallback
if tee_row["stroke_indices"]:
raw = json.loads(tee_row["stroke_indices"])
player_si = {int(k): v for k, v in raw.items()}
else:
player_si = hole_stroke_indices
played_with_si_p = {h: player_si[h] for h in played_holes if h in player_si}
if played_with_si_p and playing_handicap > 0:
n_si_p = len(played_with_si_p)
sorted_holes_p = sorted(played_with_si_p, key=lambda h: played_with_si_p[h])
hole_rank_p = {h: rank + 1 for rank, h in enumerate(sorted_holes_p)}
base, extra = divmod(playing_handicap, n_si_p)
allocation = {str(h): base + (1 if hole_rank_p[h] <= extra else 0)
for h in played_holes if h in played_with_si_p}
strokes_allocation_json = json.dumps(allocation)
await db.execute(
"INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at, playing_handicap, strokes_allocation) VALUES (?,?,?,?,?,?,?,?)",
@@ -367,7 +391,8 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
holes = list(range(1, game["holes_count"] + 1))
start_hole = game["start_hole"] if game["start_hole"] else 1
holes = list(range(start_hole, start_hole + game["holes_count"]))
hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {}
@@ -427,7 +452,8 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
holes = list(range(1, game["holes_count"] + 1))
start_hole = game["start_hole"] if game["start_hole"] else 1
holes = list(range(start_hole, start_hole + game["holes_count"]))
hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {}
@@ -455,6 +481,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
"skin_double_back9": bool(game["skin_double_back_nine"]),
"skins_net": bool(game["skins_net"]),
"side_skins_enabled": bool(game.get("side_skins_enabled", 0)),
"start_hole": start_hole,
})