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 FROM course_tees WHERE course_id = ? ORDER BY 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 + stroke index from first tee that has hole data hole_pars: dict[int, int] = {} hole_sis: dict[int, int] = {} for t in all_tees: 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)), ) for t in tees_male: 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) VALUES (?,?,?,?,?)", (secrets.token_urlsafe(10), cid, tname, slope, 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(...), user: dict = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)): await db.execute( "INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", (secrets.token_urlsafe(10), cid, name.strip(), slope, 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(...), user: dict = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)): await db.execute( "UPDATE course_tees SET name = ?, slope = ?, rating = ? WHERE id = ? AND course_id = ?", (name.strip(), slope, 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)