Add Skins format, GolfCourseAPI import, and UX improvements
- Add Skins game format with optional carryover, mirroring BBB scoring pattern
- Add GolfCourseAPI integration for course search and import (httpx)
- Unwrap {"course": {}} API response envelope on import
- Add searchable course combobox and player filter to new game form
- Redirect to summary page after finishing a game
- Fix auth to allow registered users without a pending invitation
- Fix hardcoded BBB format in create_game; store carryover flag in DB
- Add game summary/scorecard view for completed games
- Add live games social stream to dashboard
- Add course name + subtitle to recent game cards
- Reorder nav: Home → Games → Admin → Profile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -9,6 +11,21 @@ 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()
|
||||
|
||||
|
||||
@@ -125,6 +142,99 @@ async def create_course(request: Request,
|
||||
)
|
||||
|
||||
|
||||
# ── 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)
|
||||
|
||||
Reference in New Issue
Block a user