Files
SkinsPins/app/routers/courses.py
T
Rolf 3e98f5ae54 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>
2026-03-23 22:37:20 +01:00

401 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
# ── 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 = {}
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
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
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
# 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 = {}
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
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)