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:
Rolf
2026-03-22 09:18:04 +01:00
parent ef22436c76
commit c51c872c33
12 changed files with 554 additions and 46 deletions
+110
View File
@@ -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)