0be6e908e6
- 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>
383 lines
17 KiB
Python
383 lines
17 KiB
Python
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 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)
|
||
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)
|