diff --git a/app/database.py b/app/database.py index 8e19abf..1ee9edf 100644 --- a/app/database.py +++ b/app/database.py @@ -139,5 +139,6 @@ async def init_db(): try: await db.execute(migration) await db.commit() - except Exception: - pass # column already exists + except Exception as e: + if "duplicate column name" not in str(e).lower(): + raise diff --git a/app/routers/courses.py b/app/routers/courses.py index 1ee4f40..3d8c945 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -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 3–5." + 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 3–5." - 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 3–5." - 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( diff --git a/app/routers/games.py b/app/routers/games.py index 8d5d9e7..c811379 100644 --- a/app/routers/games.py +++ b/app/routers/games.py @@ -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 diff --git a/app/templates/games/game.html b/app/templates/games/game.html index a46de61..5e4c2a0 100644 --- a/app/templates/games/game.html +++ b/app/templates/games/game.html @@ -307,6 +307,7 @@ function gameApp() { touchStartX: 0, dragSrc: null, dragOver: null, + _skinsCache: null, init() { this.players = PLAYERS_DATA; @@ -339,6 +340,7 @@ function gameApp() { this.scores[h][uid] = s; } this.totals = msg.totals; + this._skinsCache = null; } }; this.ws.onclose = () => setTimeout(() => this.connectWs(), 2000); @@ -358,6 +360,7 @@ function gameApp() { const cur = this.scores[hole][uid].strokes; this.scores[hole][uid].strokes = cur ? Math.max(1, cur + delta) : (HOLE_PARS[hole] || 4); this.dirty.add(hole); + this._skinsCache = null; }, setBingo(hole, uid) { @@ -441,6 +444,7 @@ function gameApp() { } // Alpine reactivity workaround for Set this.skipped = new Set(this.skipped); + this._skinsCache = null; }, isSkipped(hole) { @@ -503,6 +507,7 @@ function gameApp() { touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); }, calcSkins() { + if (this._skinsCache) return this._skinsCache; const skins = {}; const money = {}; PLAYERS_DATA.forEach(p => { skins[p.id] = 0; money[p.id] = 0; }); @@ -535,7 +540,8 @@ function gameApp() { if (!SKINS_CARRYOVER) { pot = 0; moneyPot = 0; } } } - return { skins, money, holes, unclaimed: pot, unclaimedMoney: moneyPot }; + this._skinsCache = { skins, money, holes, unclaimed: pot, unclaimedMoney: moneyPot }; + return this._skinsCache; }, isSkinWinner(hole, uid) { diff --git a/app/templates/games/new_game.html b/app/templates/games/new_game.html index 59fb2ce..53d23ea 100644 --- a/app/templates/games/new_game.html +++ b/app/templates/games/new_game.html @@ -163,7 +163,7 @@