3a1da1482c
- 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>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
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)
|