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:
@@ -0,0 +1,271 @@
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
import aiosqlite
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.email import send_invitation
|
||||
from app.templates_config import templates
|
||||
from app.routers.onboarding import AVATARS
|
||||
from app.avatar import save_avatar
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _list_context(db: aiosqlite.Connection):
|
||||
async with db.execute(
|
||||
"SELECT id, email, created_at FROM invitations ORDER BY created_at DESC"
|
||||
) as c:
|
||||
invitations = await c.fetchall()
|
||||
async with db.execute(
|
||||
"SELECT id, email, name, avatar, handicap, created_at FROM users ORDER BY created_at DESC"
|
||||
) as c:
|
||||
users = await c.fetchall()
|
||||
return invitations, users
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_page(request: Request, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
invitations, users = await _list_context(db)
|
||||
return templates.TemplateResponse(
|
||||
"admin/admin.html",
|
||||
{"request": request, "user": user, "invitations": invitations, "users": users},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/invite", response_class=HTMLResponse)
|
||||
async def admin_invite(request: Request, email: str = Form(...),
|
||||
user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
email = email.strip().lower()
|
||||
error = None
|
||||
|
||||
async with db.execute("SELECT id FROM invitations WHERE email = ?", (email,)) as c:
|
||||
if await c.fetchone():
|
||||
error = f"{email} has already been invited."
|
||||
|
||||
if not error:
|
||||
async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as c:
|
||||
if await c.fetchone():
|
||||
error = f"{email} is already a registered user."
|
||||
|
||||
if not error:
|
||||
await db.execute(
|
||||
"INSERT INTO invitations (id, email, invited_by, created_at) VALUES (?, ?, ?, ?)",
|
||||
(secrets.token_urlsafe(16), email, user["id"], datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
await db.commit()
|
||||
login_url = f"{os.getenv('SITE_URL', 'http://localhost:8000')}/login"
|
||||
await send_invitation(email, user.get("name") or "Someone", login_url)
|
||||
|
||||
invitations, users = await _list_context(db)
|
||||
return templates.TemplateResponse(
|
||||
"admin/admin.html",
|
||||
{"request": request, "user": user, "invitations": invitations, "users": users,
|
||||
"error": error, "success": None if error else f"Invitation sent to {email}."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/invitations/{inv_id}/delete")
|
||||
async def delete_invitation(inv_id: str, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
await db.execute("DELETE FROM invitations WHERE id = ?", (inv_id,))
|
||||
await db.commit()
|
||||
return RedirectResponse("/admin?success=Invitation+deleted.", status_code=302)
|
||||
|
||||
|
||||
@router.post("/admin/invitations/{inv_id}/resend")
|
||||
async def resend_invitation(inv_id: str, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
async with db.execute("SELECT email FROM invitations WHERE id = ?", (inv_id,)) as c:
|
||||
row = await c.fetchone()
|
||||
if row:
|
||||
login_url = f"{os.getenv('SITE_URL', 'http://localhost:8000')}/login"
|
||||
await send_invitation(row["email"], user.get("name") or "Admin", login_url)
|
||||
return RedirectResponse("/admin?success=Invitation+resent.", status_code=302)
|
||||
|
||||
|
||||
@router.get("/admin/users/new", response_class=HTMLResponse)
|
||||
async def new_user_page(request: Request, user: dict = Depends(require_admin)):
|
||||
return templates.TemplateResponse(
|
||||
"admin/new_user.html",
|
||||
{"request": request, "user": user, "avatars": AVATARS},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/users/new", response_class=HTMLResponse)
|
||||
async def new_user_submit(request: Request,
|
||||
name: str = Form(...), email: str = Form(...),
|
||||
handicap: float = Form(...),
|
||||
avatar_emoji: str = Form(""),
|
||||
avatar_file: UploadFile = File(None),
|
||||
user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
name = name.strip()
|
||||
email = email.strip().lower()
|
||||
errors = {}
|
||||
if not name:
|
||||
errors["name"] = "Name is required."
|
||||
if not email:
|
||||
errors["email"] = "Email is required."
|
||||
if not (0.0 <= handicap <= 54.0):
|
||||
errors["handicap"] = "Handicap must be between 0 and 54."
|
||||
|
||||
if not errors:
|
||||
async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as c:
|
||||
if await c.fetchone():
|
||||
errors["email"] = f"{email} is already registered."
|
||||
|
||||
uid = secrets.token_urlsafe(16)
|
||||
avatar = None
|
||||
if avatar_file and avatar_file.filename:
|
||||
avatar, err = await save_avatar(avatar_file, uid)
|
||||
if err:
|
||||
errors["avatar"] = err
|
||||
elif avatar_emoji in AVATARS:
|
||||
avatar = avatar_emoji
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
"admin/new_user.html",
|
||||
{"request": request, "user": user, "avatars": AVATARS, "errors": errors,
|
||||
"form": {"name": name, "email": email, "handicap": handicap}},
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await db.execute(
|
||||
"""INSERT INTO users (id, email, name, handicap, avatar, is_onboarded, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?)""",
|
||||
(uid, email, name, handicap, avatar, now),
|
||||
)
|
||||
await db.commit()
|
||||
return RedirectResponse("/admin?success=User+added.", status_code=302)
|
||||
|
||||
|
||||
@router.get("/admin/users/{uid}/edit", response_class=HTMLResponse)
|
||||
async def edit_user_page(uid: str, request: Request, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
async with db.execute(
|
||||
"SELECT id, email, name, handicap, avatar FROM users WHERE id = ?", (uid,)
|
||||
) as c:
|
||||
target = await c.fetchone()
|
||||
if not target:
|
||||
return RedirectResponse("/admin", status_code=302)
|
||||
return templates.TemplateResponse(
|
||||
"admin/edit_user.html",
|
||||
{"request": request, "user": user, "target": dict(target), "avatars": AVATARS},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/users/{uid}/edit", response_class=HTMLResponse)
|
||||
async def edit_user_submit(uid: str, request: Request,
|
||||
name: str = Form(...), email: str = Form(...),
|
||||
handicap: float = Form(...),
|
||||
avatar_emoji: str = Form(""),
|
||||
avatar_file: UploadFile = File(None),
|
||||
user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
async with db.execute(
|
||||
"SELECT id, email, name, handicap, avatar FROM users WHERE id = ?", (uid,)
|
||||
) as c:
|
||||
target = await c.fetchone()
|
||||
if not target:
|
||||
return RedirectResponse("/admin", status_code=302)
|
||||
|
||||
errors = {}
|
||||
name = name.strip()
|
||||
email = email.strip().lower()
|
||||
if not name:
|
||||
errors["name"] = "Name is required."
|
||||
if not email:
|
||||
errors["email"] = "Email is required."
|
||||
if not (0.0 <= handicap <= 54.0):
|
||||
errors["handicap"] = "Handicap must be between 0 and 54."
|
||||
|
||||
avatar = target["avatar"]
|
||||
if avatar_file and avatar_file.filename:
|
||||
avatar, err = await save_avatar(avatar_file, uid)
|
||||
if err:
|
||||
errors["avatar"] = err
|
||||
elif avatar_emoji in AVATARS:
|
||||
avatar = avatar_emoji
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
"admin/edit_user.html",
|
||||
{"request": request, "user": user, "target": dict(target),
|
||||
"avatars": AVATARS, "errors": errors},
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"UPDATE users SET name = ?, email = ?, handicap = ?, avatar = ? WHERE id = ?",
|
||||
(name, email, handicap, avatar, uid),
|
||||
)
|
||||
await db.commit()
|
||||
return RedirectResponse("/admin?success=User+updated.", status_code=302)
|
||||
|
||||
|
||||
@router.post("/admin/users/{uid}/delete")
|
||||
async def delete_user(uid: str, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
# Don't allow deleting yourself
|
||||
if uid == user["id"]:
|
||||
return RedirectResponse("/admin?error=Cannot+delete+your+own+account.", status_code=302)
|
||||
await db.execute("DELETE FROM sessions WHERE user_id = ?", (uid,))
|
||||
await db.execute("DELETE FROM game_players WHERE user_id = ?", (uid,))
|
||||
await db.execute("DELETE FROM hole_scores WHERE user_id = ?", (uid,))
|
||||
await db.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||
await db.commit()
|
||||
return RedirectResponse("/admin?success=User+deleted.", status_code=302)
|
||||
|
||||
|
||||
# ── Admin: Games ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/games", response_class=HTMLResponse)
|
||||
async def admin_games_page(request: Request, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
async with db.execute(
|
||||
"""SELECT g.id, g.holes_count, g.status, g.created_at,
|
||||
c.name as course_name, u.name as creator_name
|
||||
FROM games g
|
||||
JOIN users u ON g.created_by = u.id
|
||||
LEFT JOIN courses c ON c.id = g.course_id
|
||||
ORDER BY g.created_at DESC"""
|
||||
) as cur:
|
||||
games = [dict(r) for r in await cur.fetchall()]
|
||||
|
||||
# Fetch all players grouped by game_id
|
||||
async with db.execute(
|
||||
"""SELECT gp.game_id, u.id AS user_id, u.name, u.avatar
|
||||
FROM game_players gp JOIN users u ON gp.user_id = u.id
|
||||
ORDER BY gp.joined_at"""
|
||||
) as cur:
|
||||
player_rows = await cur.fetchall()
|
||||
|
||||
players_by_game: dict[str, list] = {}
|
||||
for r in player_rows:
|
||||
players_by_game.setdefault(r["game_id"], []).append(
|
||||
{"id": r["user_id"], "name": r["name"], "avatar": r["avatar"]}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"admin/games.html",
|
||||
{"request": request, "user": user, "games": games,
|
||||
"players_by_game": players_by_game},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/games/{gid}/delete")
|
||||
async def admin_delete_game(gid: str, user: dict = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
await db.execute("DELETE FROM hole_scores WHERE game_id = ?", (gid,))
|
||||
await db.execute("DELETE FROM game_players WHERE game_id = ?", (gid,))
|
||||
await db.execute("DELETE FROM games WHERE id = ?", (gid,))
|
||||
await db.commit()
|
||||
return RedirectResponse("/admin/games?success=Game+deleted.", status_code=302)
|
||||
@@ -0,0 +1,105 @@
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
import aiosqlite
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth import create_magic_token, verify_magic_token, create_session, delete_session
|
||||
from app.templates_config import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
|
||||
SESSION_COOKIE = "session"
|
||||
SESSION_TTL_DAYS = 30
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
if request.cookies.get(SESSION_COOKIE):
|
||||
return RedirectResponse("/", status_code=302)
|
||||
return templates.TemplateResponse("auth/login.html", {"request": request})
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
email = email.strip().lower()
|
||||
|
||||
# Check email is admin or invited
|
||||
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."},
|
||||
)
|
||||
|
||||
await create_magic_token(email, db)
|
||||
|
||||
return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email})
|
||||
|
||||
|
||||
@router.get("/auth/verify", response_class=HTMLResponse)
|
||||
async def auth_verify(
|
||||
request: Request,
|
||||
token: str,
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
email = await verify_magic_token(token, db)
|
||||
|
||||
if email is None:
|
||||
return templates.TemplateResponse(
|
||||
"auth/login.html",
|
||||
{"request": request, "error": "This link is invalid or has expired. Please request a new one."},
|
||||
)
|
||||
|
||||
# Get or create user
|
||||
async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (email,)) as cursor:
|
||||
user = await cursor.fetchone()
|
||||
|
||||
if user is None:
|
||||
user_id = secrets.token_urlsafe(16)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await db.execute(
|
||||
"INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)",
|
||||
(user_id, email, now),
|
||||
)
|
||||
await db.execute("DELETE FROM invitations WHERE email = ?", (email,))
|
||||
await db.commit()
|
||||
is_onboarded = False
|
||||
else:
|
||||
user_id = user["id"]
|
||||
is_onboarded = bool(user["is_onboarded"])
|
||||
|
||||
session_token = await create_session(user_id, db)
|
||||
|
||||
redirect_to = "/" if is_onboarded else "/onboarding"
|
||||
response = RedirectResponse(redirect_to, status_code=302)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
session_token,
|
||||
max_age=SESSION_TTL_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request, db: aiosqlite.Connection = Depends(get_db)):
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token:
|
||||
await delete_session(token, db)
|
||||
response = RedirectResponse("/login", status_code=302)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return response
|
||||
@@ -0,0 +1,266 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ── 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)
|
||||
@@ -0,0 +1,112 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
import aiosqlite
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.templates_config import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
uid = user["id"]
|
||||
|
||||
# Games played (completed)
|
||||
async with db.execute(
|
||||
"""SELECT COUNT(*) FROM games
|
||||
WHERE status = 'completed'
|
||||
AND id IN (SELECT game_id FROM game_players WHERE user_id = ?)""",
|
||||
(uid,),
|
||||
) as c:
|
||||
games_played = (await c.fetchone())[0]
|
||||
|
||||
# Victories: completed games where this user has the highest total points
|
||||
# For each completed game the user played, rank players by total points
|
||||
async with db.execute(
|
||||
"""SELECT gp.game_id,
|
||||
SUM(CASE WHEN hs.user_id = ? THEN hs.bingo + hs.bango + hs.bongo ELSE 0 END) AS my_total,
|
||||
MAX(hs.bingo + hs.bango + hs.bongo) AS top_hole_score
|
||||
FROM game_players gp
|
||||
JOIN games g ON g.id = gp.game_id
|
||||
JOIN hole_scores hs ON hs.game_id = gp.game_id
|
||||
WHERE gp.user_id = ? AND g.status = 'completed'
|
||||
GROUP BY gp.game_id""",
|
||||
(uid, uid),
|
||||
) as c:
|
||||
game_rows = await c.fetchall()
|
||||
|
||||
victories = 0
|
||||
for row in game_rows:
|
||||
game_id = row["game_id"]
|
||||
my_total = row["my_total"]
|
||||
# Get the max total among all players in this game
|
||||
async with db.execute(
|
||||
"""SELECT MAX(player_total) FROM (
|
||||
SELECT user_id, SUM(bingo + bango + bongo) AS player_total
|
||||
FROM hole_scores WHERE game_id = ? GROUP BY user_id
|
||||
)""",
|
||||
(game_id,),
|
||||
) as c2:
|
||||
max_total = (await c2.fetchone())[0] or 0
|
||||
if my_total >= max_total and my_total > 0:
|
||||
victories += 1
|
||||
|
||||
# Recent completed games with results
|
||||
async with db.execute(
|
||||
"""SELECT g.id, g.holes_count, g.created_at, g.format, c.name AS course_name
|
||||
FROM games g
|
||||
LEFT JOIN courses c ON c.id = g.course_id
|
||||
JOIN game_players gp ON gp.game_id = g.id
|
||||
WHERE gp.user_id = ? AND g.status = 'completed'
|
||||
ORDER BY g.created_at DESC LIMIT 5""",
|
||||
(uid,),
|
||||
) as c:
|
||||
recent_games = [dict(r) for r in await c.fetchall()]
|
||||
|
||||
# For each recent game, get player totals
|
||||
for game in recent_games:
|
||||
async with db.execute(
|
||||
"""SELECT u.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 = ?
|
||||
GROUP BY hs.user_id
|
||||
ORDER BY total DESC""",
|
||||
(game["id"],),
|
||||
) as c:
|
||||
game["results"] = [dict(r) for r in await c.fetchall()]
|
||||
|
||||
# All active games (social stream)
|
||||
async with db.execute(
|
||||
"""SELECT g.id, g.holes_count, g.created_at, g.format, c.name AS course_name
|
||||
FROM games g
|
||||
LEFT JOIN courses c ON c.id = g.course_id
|
||||
WHERE g.status = 'active'
|
||||
ORDER BY g.created_at DESC""",
|
||||
) as c:
|
||||
active_games = [dict(r) for r in await c.fetchall()]
|
||||
|
||||
for game in active_games:
|
||||
async with db.execute(
|
||||
"""SELECT u.id, u.name, u.avatar,
|
||||
COALESCE(SUM(hs.bingo + hs.bango + hs.bongo), 0) AS total
|
||||
FROM game_players gp
|
||||
JOIN users u ON u.id = gp.user_id
|
||||
LEFT JOIN hole_scores hs ON hs.game_id = gp.game_id AND hs.user_id = u.id
|
||||
WHERE gp.game_id = ?
|
||||
GROUP BY u.id
|
||||
ORDER BY total DESC""",
|
||||
(game["id"],),
|
||||
) as c:
|
||||
game["players"] = [dict(r) for r in await c.fetchall()]
|
||||
game["is_participant"] = any(p["id"] == uid for p in game["players"])
|
||||
|
||||
return templates.TemplateResponse("dashboard/dashboard.html", {
|
||||
"request": request, "user": user,
|
||||
"games_played": games_played, "victories": victories,
|
||||
"recent_games": recent_games,
|
||||
"active_games": active_games,
|
||||
})
|
||||
@@ -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)
|
||||
@@ -0,0 +1,63 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
import aiosqlite
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.templates_config import templates
|
||||
from app.avatar import save_avatar
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATARS = ["🏌️", "⛳", "🦅", "🐦", "🏆", "🎯", "🌟", "🔥", "💪", "🦁", "🐯", "🦊"]
|
||||
|
||||
|
||||
@router.get("/onboarding", response_class=HTMLResponse)
|
||||
async def onboarding_page(request: Request, user: dict = Depends(get_current_user)):
|
||||
if user["is_onboarded"]:
|
||||
return RedirectResponse("/", status_code=302)
|
||||
return templates.TemplateResponse(
|
||||
"onboarding/setup.html", {"request": request, "user": user, "avatars": AVATARS}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/onboarding", response_class=HTMLResponse)
|
||||
async def onboarding_submit(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
handicap: float = Form(...),
|
||||
avatar_emoji: str = Form(""),
|
||||
avatar_file: UploadFile = File(None),
|
||||
user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
errors = {}
|
||||
name = name.strip()
|
||||
if not name:
|
||||
errors["name"] = "Name is required."
|
||||
if not (0.0 <= handicap <= 54.0):
|
||||
errors["handicap"] = "Handicap must be between 0 and 54."
|
||||
|
||||
avatar = None
|
||||
if avatar_file and avatar_file.filename:
|
||||
avatar, err = await save_avatar(avatar_file, user["id"])
|
||||
if err:
|
||||
errors["avatar"] = err
|
||||
elif avatar_emoji in AVATARS:
|
||||
avatar = avatar_emoji
|
||||
else:
|
||||
errors["avatar"] = "Please upload a photo or select an emoji."
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
"onboarding/setup.html",
|
||||
{"request": request, "user": user, "avatars": AVATARS, "errors": errors,
|
||||
"values": {"name": name, "handicap": handicap, "avatar_emoji": avatar_emoji}},
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"UPDATE users SET name = ?, handicap = ?, avatar = ?, is_onboarded = 1 WHERE id = ?",
|
||||
(name, handicap, avatar, user["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
return RedirectResponse("/", status_code=302)
|
||||
@@ -0,0 +1,62 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse
|
||||
import aiosqlite
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.routers.onboarding import AVATARS
|
||||
from app.templates_config import templates
|
||||
from app.avatar import save_avatar
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/profile", response_class=HTMLResponse)
|
||||
async def profile_page(request: Request, user: dict = Depends(get_current_user)):
|
||||
return templates.TemplateResponse(
|
||||
"profile/profile.html", {"request": request, "user": user, "avatars": AVATARS}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/profile", response_class=HTMLResponse)
|
||||
async def profile_submit(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
handicap: float = Form(...),
|
||||
avatar_emoji: str = Form(""),
|
||||
avatar_file: UploadFile = File(None),
|
||||
user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
errors = {}
|
||||
name = name.strip()
|
||||
if not name:
|
||||
errors["name"] = "Name is required."
|
||||
if not (0.0 <= handicap <= 54.0):
|
||||
errors["handicap"] = "Handicap must be between 0 and 54."
|
||||
|
||||
avatar = user["avatar"] # keep existing if nothing new provided
|
||||
if avatar_file and avatar_file.filename:
|
||||
avatar, err = await save_avatar(avatar_file, user["id"])
|
||||
if err:
|
||||
errors["avatar"] = err
|
||||
elif avatar_emoji in AVATARS:
|
||||
avatar = avatar_emoji
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
"profile/profile.html",
|
||||
{"request": request, "user": user, "avatars": AVATARS, "errors": errors},
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"UPDATE users SET name = ?, handicap = ?, avatar = ? WHERE id = ?",
|
||||
(name, handicap, avatar, user["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"profile/profile.html",
|
||||
{"request": request, "user": {**user, "name": name, "handicap": handicap, "avatar": avatar},
|
||||
"avatars": AVATARS, "success": "Profile updated."},
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
import aiosqlite
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.templates_config import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_class=HTMLResponse)
|
||||
async def user_profile(user_id: str, request: Request,
|
||||
user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db)):
|
||||
async with db.execute(
|
||||
"SELECT id, name, handicap, avatar FROM users WHERE id = ? AND is_onboarded = 1",
|
||||
(user_id,),
|
||||
) as c:
|
||||
profile = await c.fetchone()
|
||||
if not profile:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/", status_code=302)
|
||||
|
||||
uid = user_id
|
||||
|
||||
async with db.execute(
|
||||
"""SELECT COUNT(*) FROM games
|
||||
WHERE status = 'completed'
|
||||
AND id IN (SELECT game_id FROM game_players WHERE user_id = ?)""",
|
||||
(uid,),
|
||||
) as c:
|
||||
games_played = (await c.fetchone())[0]
|
||||
|
||||
async with db.execute(
|
||||
"""SELECT gp.game_id,
|
||||
SUM(CASE WHEN hs.user_id = ? THEN hs.bingo + hs.bango + hs.bongo ELSE 0 END) AS my_total
|
||||
FROM game_players gp
|
||||
JOIN games g ON g.id = gp.game_id
|
||||
JOIN hole_scores hs ON hs.game_id = gp.game_id
|
||||
WHERE gp.user_id = ? AND g.status = 'completed'
|
||||
GROUP BY gp.game_id""",
|
||||
(uid, uid),
|
||||
) as c:
|
||||
game_rows = await c.fetchall()
|
||||
|
||||
victories = 0
|
||||
for row in game_rows:
|
||||
async with db.execute(
|
||||
"""SELECT MAX(player_total) FROM (
|
||||
SELECT user_id, SUM(bingo + bango + bongo) AS player_total
|
||||
FROM hole_scores WHERE game_id = ? GROUP BY user_id
|
||||
)""",
|
||||
(row["game_id"],),
|
||||
) as c2:
|
||||
max_total = (await c2.fetchone())[0] or 0
|
||||
if row["my_total"] >= max_total and row["my_total"] > 0:
|
||||
victories += 1
|
||||
|
||||
win_pct = round((victories / games_played) * 100) if games_played > 0 else None
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"users/profile.html",
|
||||
{"request": request, "user": user, "profile": dict(profile),
|
||||
"games_played": games_played, "victories": victories, "win_pct": win_pct},
|
||||
)
|
||||
Reference in New Issue
Block a user