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:
+13
-10
@@ -32,17 +32,20 @@ async def login_submit(
|
||||
):
|
||||
email = email.strip().lower()
|
||||
|
||||
# Check email is admin or invited
|
||||
# Check email is admin, already registered, or has a pending invitation
|
||||
if email != ADMIN_EMAIL:
|
||||
async with db.execute(
|
||||
"SELECT id FROM invitations WHERE email = ?", (email,)
|
||||
) as cursor:
|
||||
invitation = await cursor.fetchone()
|
||||
if invitation is None:
|
||||
return templates.TemplateResponse(
|
||||
"auth/login.html",
|
||||
{"request": request, "error": "This email address has not been invited."},
|
||||
)
|
||||
async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as cursor:
|
||||
existing_user = await cursor.fetchone()
|
||||
if existing_user is None:
|
||||
async with db.execute(
|
||||
"SELECT id FROM invitations WHERE email = ?", (email,)
|
||||
) as cursor:
|
||||
invitation = await cursor.fetchone()
|
||||
if invitation is None:
|
||||
return templates.TemplateResponse(
|
||||
"auth/login.html",
|
||||
{"request": request, "error": "This email address has not been invited."},
|
||||
)
|
||||
|
||||
await create_magic_token(email, db)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+56
-8
@@ -65,6 +65,29 @@ async def _broadcast(game_id: str, message: dict):
|
||||
_connections.get(game_id, set()).difference_update(dead)
|
||||
|
||||
|
||||
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True):
|
||||
"""Returns (skins_per_player, hole_results, unclaimed_pot)"""
|
||||
skins = {p["id"]: 0 for p in players}
|
||||
hole_results = {}
|
||||
pot = 0
|
||||
for hole in holes:
|
||||
scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
|
||||
if not scored:
|
||||
continue
|
||||
pot += 1
|
||||
min_score = min(scored.values())
|
||||
winners = [uid for uid, s in scored.items() if s == min_score]
|
||||
if len(winners) == 1:
|
||||
skins[winners[0]] += pot
|
||||
hole_results[hole] = {"winner": winners[0], "pot": pot, "min_score": min_score}
|
||||
pot = 0
|
||||
else:
|
||||
hole_results[hole] = {"winner": None, "pot": pot, "min_score": min_score}
|
||||
if not carryover:
|
||||
pot = 0
|
||||
return skins, hole_results, pot
|
||||
|
||||
|
||||
def _totals(players: list, scores: dict) -> dict:
|
||||
"""Returns {user_id: {bingo, bango, bongo, total}}"""
|
||||
totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players}
|
||||
@@ -84,7 +107,7 @@ def _totals(players: list, scores: dict) -> dict:
|
||||
async def games_list(request: Request, user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
async with db.execute(
|
||||
"""SELECT g.id, g.holes_count, g.status, g.created_at,
|
||||
"""SELECT g.id, g.holes_count, g.status, g.created_at, g.format, g.carryover,
|
||||
u.name as creator_name, c.name as course_name
|
||||
FROM games g JOIN users u ON g.created_by = u.id
|
||||
LEFT JOIN courses c ON c.id = g.course_id
|
||||
@@ -94,12 +117,12 @@ async def games_list(request: Request, user: dict = Depends(get_current_user),
|
||||
) as c:
|
||||
games = [dict(r) for r in await c.fetchall()]
|
||||
|
||||
# For completed games, find the winner (highest total points)
|
||||
# For completed BBB games, find the winner (highest total points)
|
||||
async with db.execute(
|
||||
"""SELECT hs.game_id, u.name, u.avatar,
|
||||
SUM(hs.bingo + hs.bango + hs.bongo) AS total
|
||||
FROM hole_scores hs JOIN users u ON hs.user_id = u.id
|
||||
WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed')
|
||||
WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed' AND format = 'bingo_bango_bongo')
|
||||
GROUP BY hs.game_id, hs.user_id
|
||||
ORDER BY hs.game_id, total DESC""",
|
||||
) as c:
|
||||
@@ -111,6 +134,21 @@ async def games_list(request: Request, user: dict = Depends(get_current_user),
|
||||
if r["game_id"] not in winners:
|
||||
winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]}
|
||||
|
||||
# For completed skins games, calculate winner via skins algorithm
|
||||
for g in games:
|
||||
if g["status"] == "completed" and g["format"] == "skins":
|
||||
gid = g["id"]
|
||||
players_g = await _get_players(gid, db)
|
||||
scores_g = await _get_scores(gid, db)
|
||||
holes_g = list(range(1, g["holes_count"] + 1))
|
||||
skins_g, _, _ = _calc_skins(players_g, scores_g, holes_g, bool(g["carryover"]))
|
||||
if skins_g:
|
||||
winner_id = max(skins_g, key=skins_g.get)
|
||||
if skins_g[winner_id] > 0:
|
||||
wp = next((p for p in players_g if p["id"] == winner_id), None)
|
||||
if wp:
|
||||
winners[gid] = {"name": wp["name"], "avatar": wp["avatar"], "total": skins_g[winner_id]}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"games/games.html", {"request": request, "user": user, "games": games, "winners": winners}
|
||||
)
|
||||
@@ -144,7 +182,7 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
|
||||
)
|
||||
|
||||
|
||||
VALID_FORMATS = {"bingo_bango_bongo"}
|
||||
VALID_FORMATS = {"bingo_bango_bongo", "skins"}
|
||||
|
||||
@router.post("/games/new")
|
||||
async def create_game(request: Request, holes_count: int = Form(...),
|
||||
@@ -198,10 +236,11 @@ async def create_game(request: Request, holes_count: int = Form(...),
|
||||
|
||||
game_id = secrets.token_urlsafe(10)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
carryover = 1 if form.get("carryover") == "1" else 0
|
||||
|
||||
await db.execute(
|
||||
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id) VALUES (?,?,?,?,?,?,?)",
|
||||
(game_id, "bingo_bango_bongo", holes_count, "active", user["id"], now, course_id),
|
||||
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover),
|
||||
)
|
||||
for uid in player_ids:
|
||||
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
|
||||
@@ -241,13 +280,21 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
||||
if row["stroke_index"] is not None:
|
||||
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
||||
|
||||
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
|
||||
skins_won, skins_hole_results, unclaimed = None, None, 0
|
||||
if game["format"] == "skins":
|
||||
carryover = bool(game["carryover"])
|
||||
skins_won, skins_hole_results, unclaimed = _calc_skins(players, scores, holes, carryover)
|
||||
results = sorted(players, key=lambda p: skins_won.get(p["id"], 0), reverse=True)
|
||||
else:
|
||||
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
|
||||
|
||||
return templates.TemplateResponse("games/summary.html", {
|
||||
"request": request, "user": user, "game": dict(game),
|
||||
"players": players, "scores": scores, "totals": totals,
|
||||
"holes": holes, "hole_pars": hole_pars,
|
||||
"results": results,
|
||||
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed,
|
||||
"carryover": bool(game["carryover"]),
|
||||
})
|
||||
|
||||
|
||||
@@ -293,6 +340,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
|
||||
"players": players, "scores": scores, "totals": totals,
|
||||
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
|
||||
"playing_handicaps": playing_handicaps,
|
||||
"carryover": bool(game["carryover"]),
|
||||
})
|
||||
|
||||
|
||||
@@ -358,7 +406,7 @@ async def finish_game(game_id: str, user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,))
|
||||
await db.commit()
|
||||
return RedirectResponse(f"/games/{game_id}", status_code=302)
|
||||
return RedirectResponse(f"/games/{game_id}/summary", status_code=302)
|
||||
|
||||
|
||||
# ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user