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
+266
View File
@@ -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 35."
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 35."
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)