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
+271
View File
@@ -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)