Files
SkinsPins/app/routers/admin.py
T
Rolf 3a1da1482c 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>
2026-03-20 10:16:46 +01:00

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)