Add full SkinsPins application

- FastAPI + SQLite + Alpine.js + HTMX PWA for Bingo Bango Bongo golf scoring
- Passwordless magic link auth with email (aiosmtplib) and admin invite system
- Real-time score entry via WebSocket with drag-and-drop player ordering
- Course management with per-hole par and stroke index
- Scorecard with golf score markers (birdie, eagle, bogey, etc.)
- Two-step new game form with tee selection per player
- Dashboard with stats, live games social stream, and recent game history
- Game summary view (read-only scorecard with leaderboard)
- User profile pages with win stats
- PWA install support with generated PNG icons
- Admin panel for users, courses, games, and invitations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-20 10:16:46 +01:00
parent 4b31b98f89
commit 3a1da1482c
51 changed files with 4964 additions and 1 deletions
+374
View File
@@ -0,0 +1,374 @@
import json
import secrets
from datetime import datetime, timezone
import aiosqlite
from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from app.database import get_db, DATABASE_PATH
from app.dependencies import get_current_user
from app.templates_config import templates
router = APIRouter()
# game_id -> set of connected websockets
_connections: dict[str, set[WebSocket]] = {}
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _get_game(game_id: str, db: aiosqlite.Connection):
async with db.execute(
"""SELECT g.*, c.name as course_name
FROM games g LEFT JOIN courses c ON c.id = g.course_id
WHERE g.id = ?""",
(game_id,),
) as c:
return await c.fetchone()
async def _get_players(game_id: str, db: aiosqlite.Connection):
async with db.execute(
"""SELECT u.id, u.name, u.avatar, gp.handicap_snapshot,
ct.slope, ct.rating as tee_rating, ct.name as tee_name
FROM game_players gp JOIN users u ON gp.user_id = u.id
LEFT JOIN course_tees ct ON ct.id = gp.tee_id
WHERE gp.game_id = ? ORDER BY gp.joined_at""",
(game_id,),
) as c:
return [dict(r) for r in await c.fetchall()]
async def _get_scores(game_id: str, db: aiosqlite.Connection):
"""Returns {hole_number: {user_id: score_row}}"""
async with db.execute(
"SELECT * FROM hole_scores WHERE game_id = ?", (game_id,)
) as c:
rows = await c.fetchall()
scores: dict[int, dict] = {}
for r in rows:
h = r["hole_number"]
if h not in scores:
scores[h] = {}
scores[h][r["user_id"]] = dict(r)
return scores
async def _broadcast(game_id: str, message: dict):
dead = set()
for ws in _connections.get(game_id, set()):
try:
await ws.send_text(json.dumps(message))
except Exception:
dead.add(ws)
_connections.get(game_id, set()).difference_update(dead)
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}
for hole_scores in scores.values():
for uid, s in hole_scores.items():
if uid in totals:
totals[uid]["bingo"] += s["bingo"]
totals[uid]["bango"] += s["bango"]
totals[uid]["bongo"] += s["bongo"]
totals[uid]["total"] += s["bingo"] + s["bango"] + s["bongo"]
return totals
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("/games", response_class=HTMLResponse)
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,
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
WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?)
ORDER BY g.created_at DESC LIMIT 20""",
(user["id"],),
) as c:
games = [dict(r) for r in await c.fetchall()]
# For completed 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')
GROUP BY hs.game_id, hs.user_id
ORDER BY hs.game_id, total DESC""",
) as c:
rows = await c.fetchall()
# Keep only the top scorer per game
winners: dict[str, dict] = {}
for r in rows:
if r["game_id"] not in winners:
winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]}
return templates.TemplateResponse(
"games/games.html", {"request": request, "user": user, "games": games, "winners": winners}
)
@router.get("/games/new", response_class=HTMLResponse)
async def new_game_page(request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
async with db.execute(
"SELECT id, name, avatar FROM users WHERE is_onboarded = 1 ORDER BY name"
) as c:
all_users = [dict(r) for r in await c.fetchall()]
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()]
async with db.execute(
"SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
) as c:
tee_rows = [dict(r) for r in await c.fetchall()]
# Group tees by course_id for JSON embed in template
tees_by_course: dict[str, list] = {}
for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]}
)
return templates.TemplateResponse(
"games/new_game.html",
{"request": request, "user": user, "all_users": all_users,
"courses": courses, "tees_by_course": tees_by_course},
)
VALID_FORMATS = {"bingo_bango_bongo"}
@router.post("/games/new")
async def create_game(request: Request, holes_count: int = Form(...),
course_id: str = Form(...),
format: str = Form("bingo_bango_bongo"),
user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
form = await request.form()
player_ids = form.getlist("players")
if format not in VALID_FORMATS:
format = "bingo_bango_bongo"
if user["id"] not in player_ids:
player_ids.insert(0, user["id"])
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()]
error = None
if len(player_ids) < 2:
error = "Select at least 2 players."
if not course_id or not any(c["id"] == course_id for c in courses):
error = error or "Select a course."
if not error:
async with db.execute(
"SELECT id FROM course_tees WHERE course_id = ? LIMIT 1", (course_id,)
) as c:
has_tees = await c.fetchone()
if has_tees and any(not form.get(f"tee_{uid}") for uid in player_ids):
error = "All players must select a tee."
if error:
async with db.execute(
"SELECT id, name, avatar FROM users WHERE is_onboarded = 1 ORDER BY name"
) as c:
all_users = [dict(r) for r in await c.fetchall()]
async with db.execute(
"SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name"
) as c:
tee_rows = [dict(r) for r in await c.fetchall()]
tees_by_course: dict[str, list] = {}
for t in tee_rows:
tees_by_course.setdefault(t["course_id"], []).append(
{"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]}
)
return templates.TemplateResponse(
"games/new_game.html",
{"request": request, "user": user, "all_users": all_users,
"courses": courses, "tees_by_course": tees_by_course, "error": error},
)
game_id = secrets.token_urlsafe(10)
now = datetime.now(timezone.utc).isoformat()
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),
)
for uid in player_ids:
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
row = await c.fetchone()
hc = row["handicap"] if row else None
tee_id = form.get(f"tee_{uid}") or None
await db.execute(
"INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at) VALUES (?,?,?,?,?,?)",
(secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now),
)
await db.commit()
return RedirectResponse(f"/games/{game_id}", status_code=302)
@router.get("/games/{game_id}/summary", response_class=HTMLResponse)
async def game_summary(game_id: str, request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
game = await _get_game(game_id, db)
if not game:
return RedirectResponse("/games", status_code=302)
players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
holes = list(range(1, game["holes_count"] + 1))
hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {}
if game["course_id"]:
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(game["course_id"],),
) as c:
for row in await c.fetchall():
hole_pars[row["hole_number"]] = row["par"]
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)
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,
})
@router.get("/games/{game_id}", response_class=HTMLResponse)
async def game_page(game_id: str, request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
game = await _get_game(game_id, db)
if not game:
return RedirectResponse("/games", status_code=302)
players = await _get_players(game_id, db)
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
holes = list(range(1, game["holes_count"] + 1))
# Load hole pars and stroke indices if the game has a course
hole_pars: dict[int, int] = {}
hole_stroke_indices: dict[int, int] = {}
if game["course_id"]:
async with db.execute(
"SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number",
(game["course_id"],),
) as c:
for row in await c.fetchall():
hole_pars[row["hole_number"]] = row["par"]
if row["stroke_index"] is not None:
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
# Playing handicap per player: HI × (slope / 113) + (course_rating par)
course_par = sum(hole_pars.values())
playing_handicaps: dict[str, int | None] = {}
for p in players:
hc = p.get("handicap_snapshot")
slope = p.get("slope")
rating = p.get("tee_rating")
if hc is not None and slope and rating and course_par:
playing_handicaps[p["id"]] = round(hc * (slope / 113) + (rating - course_par))
else:
playing_handicaps[p["id"]] = None
return templates.TemplateResponse("games/game.html", {
"request": request, "user": user, "game": dict(game),
"players": players, "scores": scores, "totals": totals,
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
"playing_handicaps": playing_handicaps,
})
@router.post("/games/{game_id}/score")
async def save_score(game_id: str, request: Request, user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(get_db)):
form = await request.form()
hole = int(form["hole"])
now = datetime.now(timezone.utc).isoformat()
game = await _get_game(game_id, db)
if not game:
return HTMLResponse("Not found", status_code=404)
players = await _get_players(game_id, db)
player_ids = [p["id"] for p in players]
# Determine Bingo (first on green) and Bongo (first to hole out) — single winner
bingo_winner = form.get("bingo", "")
bongo_winner = form.get("bongo", "")
# Bango: may be multiple winners (split point)
bango_winners = form.getlist("bango")
bango_value = round(1.0 / len(bango_winners), 4) if bango_winners else 0.0
for uid in player_ids:
bingo = 1 if uid == bingo_winner else 0
bango = bango_value if uid in bango_winners else 0.0
bongo = 1 if uid == bongo_winner else 0
strokes_val = form.get(f"strokes_{uid}")
strokes = int(strokes_val) if strokes_val and strokes_val.isdigit() else None
await db.execute(
"""INSERT INTO hole_scores (id, game_id, hole_number, user_id, strokes, bingo, bango, bongo, updated_by, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(game_id, hole_number, user_id) DO UPDATE SET
strokes=excluded.strokes, bingo=excluded.bingo, bango=excluded.bango, bongo=excluded.bongo,
updated_by=excluded.updated_by, updated_at=excluded.updated_at""",
(secrets.token_urlsafe(10), game_id, hole, uid, strokes, bingo, bango, bongo, user["id"], now),
)
await db.commit()
# Broadcast updated scores to all connected players
scores = await _get_scores(game_id, db)
totals = _totals(players, scores)
hole_data = {uid: scores.get(hole, {}).get(uid, {}) for uid in player_ids}
await _broadcast(game_id, {
"type": "score_update",
"hole": hole,
"scores": {uid: {"strokes": d.get("strokes"), "bingo": d.get("bingo", 0),
"bango": d.get("bango", 0.0), "bongo": d.get("bongo", 0)}
for uid, d in hole_data.items()},
"totals": totals,
})
return HTMLResponse("", status_code=204)
@router.post("/games/{game_id}/finish")
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)
# ── WebSocket ─────────────────────────────────────────────────────────────────
@router.websocket("/games/{game_id}/ws")
async def game_ws(game_id: str, websocket: WebSocket):
await websocket.accept()
_connections.setdefault(game_id, set()).add(websocket)
try:
while True:
await websocket.receive_text() # keep alive
except WebSocketDisconnect:
_connections.get(game_id, set()).discard(websocket)