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 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 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 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 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)