Files
SkinsPins/app/routers/courses.py
T
Rolf 0be6e908e6 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>
2026-03-23 22:52:45 +01:00

383 lines
17 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.
import json
import os
import secrets
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import aiosqlite
from app.database import get_db
from app.dependencies import require_admin
from app.templates_config import templates
GOLF_COURSE_API_KEY = os.getenv("GOLF_COURSE_API_KEY", "")
_GOLF_API_BASE = "https://api.golfcourseapi.com/v1"
async def _golf_api(path: str, params: dict = None):
async with httpx.AsyncClient() as client:
r = await client.get(
f"{_GOLF_API_BASE}{path}",
headers={"Authorization": f"Key {GOLF_COURSE_API_KEY}"},
params=params,
timeout=10,
)
r.raise_for_status()
return r.json()
router = APIRouter()
async def _get_course_detail(cid: str, db: aiosqlite.Connection):
"""Returns (course_dict, holes_list, tees_list) or (None, [], [])."""
async with db.execute(
"SELECT id, name, holes, location FROM courses WHERE id = ?", (cid,)
) as c:
course = await c.fetchone()
if not course:
return None, [], []
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(cid,),
) as c:
holes = [dict(r) for r in await c.fetchall()]
async with db.execute(
"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()]
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)
async def courses_page(request: Request, user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
async with db.execute(
"SELECT id, name, holes, location FROM courses ORDER BY name"
) as c:
courses = [dict(r) for r in await c.fetchall()]
return templates.TemplateResponse(
"admin/courses.html",
{"request": request, "user": user, "courses": courses},
)
# ── Create ────────────────────────────────────────────────────────────────────
@router.get("/admin/courses/new", response_class=HTMLResponse)
async def new_course_page(request: Request, user: dict = Depends(require_admin)):
return templates.TemplateResponse(
"admin/new_course.html", {"request": request, "user": user}
)
@router.post("/admin/courses/new")
async def create_course(request: Request,
name: str = Form(...), holes: int = Form(...),
location: str = Form(""),
user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
form = await request.form()
name = name.strip()
location = location.strip()
errors = {}
if not name:
errors["name"] = "Name is required."
if holes not in (9, 18):
errors["holes"] = "Holes must be 9 or 18."
hole_count = holes if holes in (9, 18) else 18
pars, stroke_indices, hole_errors = _parse_hole_form(form, hole_count)
errors.update(hole_errors)
if errors:
return templates.TemplateResponse(
"admin/new_course.html",
{"request": request, "user": user, "errors": errors,
"form": {"name": name, "holes": holes, "location": location,
"pars": pars, "stroke_indices": stroke_indices}},
status_code=422,
)
cid = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)",
(cid, name, holes, location or None, now),
)
for hole_num, par in pars.items():
await db.execute(
"INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)",
(secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)),
)
await db.commit()
return RedirectResponse(
f"/admin/courses/{cid}/edit?success=Course+created.+Add+tees+below.",
status_code=302,
)
# ── API Search & Import ───────────────────────────────────────────────────────
@router.get("/admin/courses/search", response_class=HTMLResponse)
async def search_courses_page(request: Request, user: dict = Depends(require_admin),
q: str = ""):
results, error = [], None
if q and GOLF_COURSE_API_KEY:
try:
data = await _golf_api("/search", {"search_query": q})
results = data.get("courses", [])
except Exception:
error = "Search failed. Check your API key or try again."
return templates.TemplateResponse("admin/course_search.html", {
"request": request, "user": user, "q": q, "results": results,
"error": error, "api_configured": bool(GOLF_COURSE_API_KEY),
})
@router.post("/admin/courses/import/{api_id}")
async def import_course(api_id: int, request: Request,
user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
try:
data = await _golf_api(f"/courses/{api_id}")
except Exception:
return RedirectResponse("/admin/courses/search?error=Import+failed.", status_code=302)
# Unwrap {"course": {...}} envelope if present
data = data.get("course", data)
# Course name
club = data.get("club_name", "").strip()
cname = data.get("course_name", "").strip()
name = club if (not cname or club == cname) else f"{club} {cname}"
# Location
loc = data.get("location", {})
if loc.get("state") and loc.get("country") == "United States":
location = f"{loc.get('city', '')}, {loc['state']}"
elif loc.get("city") and loc.get("country"):
location = f"{loc['city']}, {loc['country']}"
else:
location = (loc.get("address") or "").strip()
# 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
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] = {}
hole_sis: dict[int, int] = {}
for t in tees_male:
hole_data = t.get("holes") or []
if hole_data:
for i, h in enumerate(hole_data[:holes_count], 1):
hole_pars[i] = h.get("par", 4)
if h.get("handicap"):
hole_sis[i] = h["handicap"]
break
now = datetime.now(timezone.utc).isoformat()
cid = secrets.token_urlsafe(10)
await db.execute(
"INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)",
(cid, name, holes_count, location or None, now),
)
for i in range(1, holes_count + 1):
await db.execute(
"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)),
)
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
)
# ── Edit ──────────────────────────────────────────────────────────────────────
@router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse)
async def edit_course_page(cid: str, request: Request,
user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
course, holes, tees = await _get_course_detail(cid, db)
if not course:
return RedirectResponse("/admin/courses", status_code=302)
return templates.TemplateResponse(
"admin/edit_course.html",
{"request": request, "user": user, "course": course, "holes": holes, "tees": tees},
)
@router.post("/admin/courses/{cid}/edit")
async def edit_course_submit(cid: str, request: Request,
name: str = Form(...), holes: int = Form(...),
location: str = Form(""),
user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
form = await request.form()
course, existing_holes, tees = await _get_course_detail(cid, db)
if not course:
return RedirectResponse("/admin/courses", status_code=302)
name = name.strip()
location = location.strip()
errors = {}
if not name:
errors["name"] = "Name is required."
if holes not in (9, 18):
errors["holes"] = "Holes must be 9 or 18."
hole_count = holes if holes in (9, 18) else 18
pars, stroke_indices, hole_errors = _parse_hole_form(form, hole_count)
errors.update(hole_errors)
if errors:
return templates.TemplateResponse(
"admin/edit_course.html",
{"request": request, "user": user, "course": course,
"holes": existing_holes, "tees": tees, "errors": errors},
status_code=422,
)
await db.execute(
"UPDATE courses SET name = ?, holes = ?, location = ? WHERE id = ?",
(name, holes, location or None, cid),
)
for hole_num, par in pars.items():
await db.execute(
"""INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)
ON CONFLICT(course_id, hole_number) DO UPDATE SET
par=excluded.par, stroke_index=excluded.stroke_index""",
(secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)),
)
# Remove holes beyond new count if holes was reduced
await db.execute(
"DELETE FROM course_holes WHERE course_id = ? AND hole_number > ?", (cid, holes)
)
await db.commit()
return RedirectResponse(f"/admin/courses/{cid}/edit?success=Course+updated.", status_code=302)
# ── Delete course ─────────────────────────────────────────────────────────────
@router.post("/admin/courses/{cid}/delete")
async def delete_course(cid: str, user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
await db.execute("DELETE FROM course_holes WHERE course_id = ?", (cid,))
await db.execute("DELETE FROM course_tees WHERE course_id = ?", (cid,))
await db.execute("DELETE FROM courses WHERE id = ?", (cid,))
await db.commit()
return RedirectResponse("/admin/courses?success=Course+deleted.", status_code=302)
# ── Tees ──────────────────────────────────────────────────────────────────────
@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, 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)
@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 = ?, 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)
@router.post("/admin/courses/{cid}/tees/{tid}/delete")
async def delete_tee(cid: str, tid: str, user: dict = Depends(require_admin),
db: aiosqlite.Connection = Depends(get_db)):
# Nullify references in game_players before deleting
await db.execute(
"UPDATE game_players SET tee_id = NULL WHERE tee_id = ?", (tid,)
)
await db.execute(
"DELETE FROM course_tees WHERE id = ? AND course_id = ?", (tid, cid)
)
await db.commit()
return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+deleted.", status_code=302)