Refactor and optimize: deduplicate, batch queries, fix bugs

- Remove unused DATABASE_PATH import from games.py
- Extract _new_game_context(db) helper to deduplicate GET/POST error path
- Extract _parse_hole_form() helper to deduplicate hole validation in create/edit course
- Batch N+1 player handicap and tee queries in create_game into two IN() queries
- Fix hole count import bug: use max() across all tees instead of first match
- Fix migration exception handling: re-raise unless "duplicate column name"
- Cache calcSkins() result in _skinsCache; invalidate on score/skip changes
- Collapse two identical Next buttons into one in new_game.html
- Fix stale skinDoubleBackNine checkbox: reset on mode change, use x-model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-23 22:52:45 +01:00
parent d81d6a2d3c
commit 0be6e908e6
5 changed files with 101 additions and 118 deletions
+37 -55
View File
@@ -51,6 +51,36 @@ async def _get_course_detail(cid: str, db: aiosqlite.Connection):
return dict(course), holes, tees
def _parse_hole_form(form, hole_count: int) -> tuple[dict, dict, dict]:
"""Parse par/SI fields from a form. Returns (pars, stroke_indices, errors)."""
pars: dict[int, int] = {}
stroke_indices: dict[int, int | None] = {}
errors: dict[str, str] = {}
for i in range(1, hole_count + 1):
raw = form.get(f"par_{i}", "4")
try:
p = int(raw)
if p < 3 or p > 5:
errors[f"par_{i}"] = f"Hole {i}: par must be 35."
else:
pars[i] = p
except (ValueError, TypeError):
errors[f"par_{i}"] = f"Hole {i}: invalid par."
raw_si = form.get(f"si_{i}", "")
if raw_si:
try:
si = int(raw_si)
if si < 1 or si > hole_count:
errors[f"si_{i}"] = f"Hole {i}: SI must be 1{hole_count}."
else:
stroke_indices[i] = si
except (ValueError, TypeError):
errors[f"si_{i}"] = f"Hole {i}: invalid SI."
else:
stroke_indices[i] = None
return pars, stroke_indices, errors
# ── List ──────────────────────────────────────────────────────────────────────
@router.get("/admin/courses", response_class=HTMLResponse)
@@ -91,30 +121,8 @@ async def create_course(request: Request,
errors["holes"] = "Holes must be 9 or 18."
hole_count = holes if holes in (9, 18) else 18
pars = {}
stroke_indices = {}
for i in range(1, hole_count + 1):
raw = form.get(f"par_{i}", "4")
try:
p = int(raw)
if p < 3 or p > 5:
errors[f"par_{i}"] = f"Hole {i}: par must be 35."
else:
pars[i] = p
except (ValueError, TypeError):
errors[f"par_{i}"] = f"Hole {i}: invalid par."
raw_si = form.get(f"si_{i}", "")
if raw_si:
try:
si = int(raw_si)
if si < 1 or si > hole_count:
errors[f"si_{i}"] = f"Hole {i}: SI must be 1{hole_count}."
else:
stroke_indices[i] = si
except (ValueError, TypeError):
errors[f"si_{i}"] = f"Hole {i}: invalid SI."
else:
stroke_indices[i] = None
pars, stroke_indices, hole_errors = _parse_hole_form(form, hole_count)
errors.update(hole_errors)
if errors:
return templates.TemplateResponse(
@@ -187,16 +195,12 @@ async def import_course(api_id: int, request: Request,
else:
location = (loc.get("address") or "").strip()
# Determine hole count from tees
# Determine hole count from tees — use max so a course with any 18-hole tee stays 18
tees_male = data.get("tees", {}).get("male") or []
tees_female = data.get("tees", {}).get("female") or []
all_tees = tees_male + tees_female
holes_count = 18
for t in all_tees:
n = t.get("number_of_holes")
if n in (9, 18):
holes_count = n
break
valid_counts = [t.get("number_of_holes") for t in all_tees if t.get("number_of_holes") in (9, 18)]
holes_count = max(valid_counts, default=18)
# Hole par + course-level stroke index from first male tee with hole data (fallback for display)
hole_pars: dict[int, int] = {}
@@ -286,30 +290,8 @@ async def edit_course_submit(cid: str, request: Request,
errors["holes"] = "Holes must be 9 or 18."
hole_count = holes if holes in (9, 18) else 18
pars = {}
stroke_indices = {}
for i in range(1, hole_count + 1):
raw = form.get(f"par_{i}", "4")
try:
p = int(raw)
if p < 3 or p > 5:
errors[f"par_{i}"] = f"Hole {i}: par must be 35."
else:
pars[i] = p
except (ValueError, TypeError):
errors[f"par_{i}"] = f"Hole {i}: invalid par."
raw_si = form.get(f"si_{i}", "")
if raw_si:
try:
si = int(raw_si)
if si < 1 or si > hole_count:
errors[f"si_{i}"] = f"Hole {i}: SI must be 1{hole_count}."
else:
stroke_indices[i] = si
except (ValueError, TypeError):
errors[f"si_{i}"] = f"Hole {i}: invalid SI."
else:
stroke_indices[i] = None
pars, stroke_indices, hole_errors = _parse_hole_form(form, hole_count)
errors.update(hole_errors)
if errors:
return templates.TemplateResponse(
+50 -55
View File
@@ -6,7 +6,7 @@ import aiosqlite
from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from app.database import get_db, DATABASE_PATH
from app.database import get_db
from app.dependencies import get_current_user
from app.templates_config import templates
@@ -140,6 +140,29 @@ def _totals(players: list, scores: dict) -> dict:
return totals
async def _new_game_context(db: aiosqlite.Connection) -> dict:
async with db.execute(
"SELECT id, name, avatar, handicap FROM users WHERE is_onboarded = 1 ORDER BY name"
) as c:
all_users = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name"
) as c:
courses = [dict(r) for r in await c.fetchall()]
async with db.execute(
"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"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
)
return {"all_users": all_users, "courses": courses, "tees_by_course": tees_by_course}
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("/games", response_class=HTMLResponse)
@@ -196,32 +219,9 @@ async def games_list(request: Request, user: dict = Depends(get_current_user),
@router.get("/games/new", response_class=HTMLResponse)
async def new_game_page(request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
async with db.execute(
"SELECT id, name, avatar, handicap FROM users WHERE is_onboarded = 1 ORDER BY name"
) as c:
all_users = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name"
) as c:
courses = [dict(r) for r in await c.fetchall()]
async with db.execute(
"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()]
# Group tees by course_id for JSON embed in template
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"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
)
ctx = await _new_game_context(db)
return templates.TemplateResponse(
"games/new_game.html",
{"request": request, "user": user, "all_users": all_users,
"courses": courses, "tees_by_course": tees_by_course},
"games/new_game.html", {"request": request, "user": user, **ctx},
)
@@ -260,25 +260,10 @@ async def create_game(request: Request, holes_count: int = Form(...),
error = "All players must select a tee."
if error:
async with db.execute(
"SELECT id, name, avatar, handicap FROM users WHERE is_onboarded = 1 ORDER BY name"
) as c:
all_users = [dict(r) for r in await c.fetchall()]
async with db.execute(
"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"],
"gender": t["gender"], "front_slope": t["front_slope"], "front_rating": t["front_rating"],
"back_slope": t["back_slope"], "back_rating": t["back_rating"]}
)
ctx = await _new_game_context(db)
return templates.TemplateResponse(
"games/new_game.html",
{"request": request, "user": user, "all_users": all_users,
"courses": courses, "tees_by_course": tees_by_course, "error": error},
{"request": request, "user": user, **ctx, "error": error},
)
game_id = secrets.token_urlsafe(10)
@@ -326,19 +311,29 @@ async def create_game(request: Request, holes_count: int = Form(...),
# 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:
row = await c.fetchone()
hc = row["handicap"] if row else None
tee_id = form.get(f"tee_{uid}") or None
# Batch-fetch all player handicaps
ph_placeholders = ",".join("?" * len(player_ids))
async with db.execute(
f"SELECT id, handicap FROM users WHERE id IN ({ph_placeholders})", player_ids
) as c:
handicaps_map = {r["id"]: r["handicap"] for r in await c.fetchall()}
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()
# Batch-fetch all selected tees
tee_id_map = {uid: (form.get(f"tee_{uid}") or None) for uid in player_ids}
tee_ids_needed = [t for t in tee_id_map.values() if t]
tees_map: dict[str, aiosqlite.Row] = {}
if tee_ids_needed:
t_placeholders = ",".join("?" * len(tee_ids_needed))
async with db.execute(
f"SELECT id, slope, rating, stroke_indices, front_slope, front_rating, back_slope, back_rating FROM course_tees WHERE id IN ({t_placeholders})",
tee_ids_needed,
) as c:
tees_map = {r["id"]: r for r in await c.fetchall()}
for uid in player_ids:
hc = handicaps_map.get(uid)
tee_id = tee_id_map[uid]
tee_row = tees_map.get(tee_id) if tee_id else None
playing_handicap = None
strokes_allocation_json = None