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)