diff --git a/app/auth.py b/app/auth.py index ffc39cc..f77a33a 100644 --- a/app/auth.py +++ b/app/auth.py @@ -37,19 +37,41 @@ async def create_magic_token(email: str, db: aiosqlite.Connection) -> str: token_id = secrets.token_urlsafe(16) token = secrets.token_urlsafe(32) + code = f"{secrets.randbelow(1000000):06d}" expires_at = _in(minutes=TOKEN_TTL_MINUTES) await db.execute( - "INSERT INTO magic_link_tokens (id, email, token, expires_at) VALUES (?, ?, ?, ?)", - (token_id, email, token, expires_at), + "INSERT INTO magic_link_tokens (id, email, token, code, expires_at) VALUES (?, ?, ?, ?, ?)", + (token_id, email, token, code, expires_at), ) await db.commit() link = f"{SITE_URL}/auth/verify?token={token}" - await send_magic_link(email, link) + await send_magic_link(email, link, code) return token +async def verify_magic_code(email: str, code: str, db: aiosqlite.Connection): + """Returns the email if code matches a valid unused token, else None.""" + async with db.execute( + "SELECT expires_at, used_at FROM magic_link_tokens WHERE email = ? AND code = ? AND used_at IS NULL", + (email, code), + ) as cursor: + row = await cursor.fetchone() + + if row is None: + return None + if _expired(row["expires_at"]): + return None + + await db.execute( + "UPDATE magic_link_tokens SET used_at = ? WHERE email = ? AND code = ?", + (_now(), email, code), + ) + await db.commit() + return email + + async def verify_magic_token(token: str, db: aiosqlite.Connection): """Returns the email if token is valid and unused, else None.""" async with db.execute( diff --git a/app/database.py b/app/database.py index f1b4351..b291845 100644 --- a/app/database.py +++ b/app/database.py @@ -121,6 +121,9 @@ async def init_db(): "ALTER TABLE game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)", "ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER", "ALTER TABLE games ADD COLUMN carryover INTEGER NOT NULL DEFAULT 1", + "ALTER TABLE game_players ADD COLUMN playing_handicap INTEGER", + "ALTER TABLE game_players ADD COLUMN strokes_allocation TEXT", + "ALTER TABLE magic_link_tokens ADD COLUMN code TEXT", ]: try: await db.execute(migration) diff --git a/app/email.py b/app/email.py index db21332..de2136e 100644 --- a/app/email.py +++ b/app/email.py @@ -43,14 +43,22 @@ async def _send(to: str, subject: str, body_text: str, body_html: str) -> None: ) -async def send_magic_link(to: str, link: str) -> None: - subject = f"Your {SITE_NAME} login link" - body_text = f"Click the link below to log in (expires in 15 minutes):\n\n{link}\n\nIf you didn't request this, you can ignore this email." +async def send_magic_link(to: str, link: str, code: str) -> None: + subject = f"Your {SITE_NAME} login code: {code}" + body_text = ( + f"Your login code is: {code}\n\n" + f"Enter it on the app, or click the link below (both expire in 15 minutes):\n\n{link}\n\n" + f"If you didn't request this, you can ignore this email." + ) body_html = f"""

⛳ {SITE_NAME}

-

Click the button below to log in. This link expires in 15 minutes.

-

+

Enter this code in the app to log in:

+
{code}
+

Or click the button below — whichever is easier. Both expire in 15 minutes.

+

Log in to {SITE_NAME} @@ -67,7 +75,7 @@ async def send_magic_link(to: str, link: str) -> None: except Exception as e: logger.error("Failed to send magic link email to %s: %s", to, e) else: - logger.info("Magic link for %s: %s", to, link) + logger.info("Magic link for %s — code: %s link: %s", to, code, link) async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None: diff --git a/app/routers/auth.py b/app/routers/auth.py index cb9eef3..7cc549d 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse import aiosqlite from app.database import get_db -from app.auth import create_magic_token, verify_magic_token, create_session, delete_session +from app.auth import create_magic_token, verify_magic_token, verify_magic_code, create_session, delete_session from app.templates_config import templates router = APIRouter() @@ -52,6 +52,54 @@ async def login_submit( return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email}) +@router.post("/auth/verify-code", response_class=HTMLResponse) +async def verify_code( + request: Request, + email: str = Form(...), + code: str = Form(...), + db: aiosqlite.Connection = Depends(get_db), +): + code = code.strip() + verified_email = await verify_magic_code(email, code, db) + + if verified_email is None: + return templates.TemplateResponse( + "auth/check_email.html", + {"request": request, "email": email, "error": "Invalid or expired code. Try again."}, + status_code=400, + ) + + async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (verified_email,)) as cursor: + user = await cursor.fetchone() + + if user is None: + user_id = secrets.token_urlsafe(16) + now = datetime.now(timezone.utc).isoformat() + await db.execute( + "INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)", + (user_id, verified_email, now), + ) + await db.execute("DELETE FROM invitations WHERE email = ?", (verified_email,)) + await db.commit() + is_onboarded = False + else: + user_id = user["id"] + is_onboarded = bool(user["is_onboarded"]) + + session_token = await create_session(user_id, db) + + redirect_to = "/" if is_onboarded else "/onboarding" + response = RedirectResponse(redirect_to, status_code=302) + response.set_cookie( + SESSION_COOKIE, + session_token, + max_age=SESSION_TTL_DAYS * 86400, + httponly=True, + samesite="lax", + ) + return response + + @router.get("/auth/verify", response_class=HTMLResponse) async def auth_verify( request: Request, diff --git a/app/routers/games.py b/app/routers/games.py index 650ba53..637e97b 100644 --- a/app/routers/games.py +++ b/app/routers/games.py @@ -31,13 +31,18 @@ async def _get_game(game_id: str, db: aiosqlite.Connection): async def _get_players(game_id: str, db: aiosqlite.Connection): async with db.execute( """SELECT u.id, u.name, u.avatar, gp.handicap_snapshot, + gp.playing_handicap, gp.strokes_allocation, ct.slope, ct.rating as tee_rating, ct.name as tee_name FROM game_players gp JOIN users u ON gp.user_id = u.id LEFT JOIN course_tees ct ON ct.id = gp.tee_id WHERE gp.game_id = ? ORDER BY gp.joined_at""", (game_id,), ) as c: - return [dict(r) for r in await c.fetchall()] + rows = [dict(r) for r in await c.fetchall()] + for p in rows: + raw = p.get("strokes_allocation") + p["strokes_allocation"] = {int(k): v for k, v in json.loads(raw).items()} if raw else {} + return rows async def _get_scores(game_id: str, db: aiosqlite.Connection): @@ -65,42 +70,23 @@ async def _broadcast(game_id: str, message: dict): _connections.get(game_id, set()).difference_update(dead) -def _playing_data( - players: list, - holes: list, - hole_pars: dict, - hole_stroke_indices: dict, -) -> tuple[dict, dict]: - """Return (playing_handicaps, strokes_per_hole). - playing_handicaps – {player_id: course_handicap (int) or None} - strokes_per_hole – {player_id: {hole_number: strokes_received}} - """ - course_par = sum(hole_pars.get(h, 0) for h in holes if h in hole_pars) - playing_handicaps: dict = {} - for p in players: - hc = p.get("handicap_snapshot") - slope = p.get("slope") - rating = p.get("tee_rating") - if hc is not None and slope and rating and course_par: - playing_handicaps[p["id"]] = round(hc * (slope / 113) + (rating - course_par)) - else: - playing_handicaps[p["id"]] = None - - strokes_per_hole: dict = {} +def _strokes_per_hole(players: list, holes: list, hole_stroke_indices: dict) -> dict: + """Fallback: compute strokes-per-hole from playing_handicap + stroke indices.""" played_with_si = {h: hole_stroke_indices[h] for h in holes if h in hole_stroke_indices} - if played_with_si: - n_si = len(played_with_si) - for p in players: - ph = playing_handicaps.get(p["id"]) - if ph is not None and ph > 0: - base, extra = divmod(ph, n_si) - strokes_per_hole[p["id"]] = { - h: base + (1 if played_with_si[h] <= extra else 0) - for h in holes if h in played_with_si - } - - return playing_handicaps, strokes_per_hole + if not played_with_si: + return {} + n_si = len(played_with_si) + sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h]) + hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)} + result = {} + for p in players: + ph = p.get("playing_handicap") + if ph is not None and ph > 0: + base, extra = divmod(ph, n_si) + result[p["id"]] = {h: base + (1 if hole_rank[h] <= extra else 0) + for h in holes if h in played_with_si} + return result def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True): @@ -199,7 +185,9 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user) "SELECT id, name, avatar, handicap FROM users WHERE is_onboarded = 1 ORDER BY name" ) as c: all_users = [dict(r) for r in await c.fetchall()] - async with db.execute("SELECT id, name, holes, location FROM courses ORDER BY name") as c: + async with db.execute( + "SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name" + ) as c: courses = [dict(r) for r in await c.fetchall()] async with db.execute( "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name" @@ -236,7 +224,9 @@ async def create_game(request: Request, holes_count: int = Form(...), if user["id"] not in player_ids: player_ids.insert(0, user["id"]) - async with db.execute("SELECT id, name, holes, location FROM courses ORDER BY name") as c: + async with db.execute( + "SELECT c.id, c.name, c.holes, c.location, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id),0) AS par, COALESCE((SELECT SUM(par) FROM course_holes WHERE course_id=c.id AND hole_number<=9),0) AS par_9 FROM courses c ORDER BY c.name" + ) as c: courses = [dict(r) for r in await c.fetchall()] error = None @@ -280,14 +270,50 @@ async def create_game(request: Request, holes_count: int = Form(...), "INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)", (game_id, format, holes_count, "active", user["id"], now, course_id, carryover), ) + + # Pre-fetch course holes for handicap calculation + course_par = 0 + hole_stroke_indices: dict[int, int] = {} + if course_id: + async with db.execute( + "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? AND hole_number <= ? ORDER BY hole_number", + (course_id, holes_count), + ) as c: + for row in await c.fetchall(): + course_par += row["par"] or 0 + if row["stroke_index"] is not None: + hole_stroke_indices[row["hole_number"]] = row["stroke_index"] + + played_holes = list(range(1, holes_count + 1)) + played_with_si = {h: hole_stroke_indices[h] for h in played_holes if h in hole_stroke_indices} + hole_rank: dict[int, int] = {} + if played_with_si: + sorted_holes = sorted(played_with_si, key=lambda h: played_with_si[h]) + hole_rank = {h: rank + 1 for rank, h in enumerate(sorted_holes)} + n_si = len(played_with_si) + for uid in player_ids: async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c: row = await c.fetchone() hc = row["handicap"] if row else None tee_id = form.get(f"tee_{uid}") or None + + playing_handicap = None + strokes_allocation_json = None + if hc is not None and tee_id and course_par: + async with db.execute("SELECT slope, rating FROM course_tees WHERE id = ?", (tee_id,)) as c: + tee_row = await c.fetchone() + if tee_row: + playing_handicap = round(hc * (tee_row["slope"] / 113) + (tee_row["rating"] - course_par)) + if hole_rank and playing_handicap > 0: + base, extra = divmod(playing_handicap, n_si) + allocation = {str(h): base + (1 if hole_rank[h] <= extra else 0) + for h in played_holes if h in played_with_si} + strokes_allocation_json = json.dumps(allocation) + await db.execute( - "INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at) VALUES (?,?,?,?,?,?)", - (secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now), + "INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at, playing_handicap, strokes_allocation) VALUES (?,?,?,?,?,?,?,?)", + (secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now, playing_handicap, strokes_allocation_json), ) await db.commit() @@ -318,7 +344,10 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_ if row["stroke_index"] is not None: hole_stroke_indices[row["hole_number"]] = row["stroke_index"] - playing_handicaps, strokes_per_hole = _playing_data(players, holes, hole_pars, hole_stroke_indices) + playing_handicaps = {p["id"]: p.get("playing_handicap") for p in players} + computed = _strokes_per_hole(players, holes, hole_stroke_indices) + strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players} + has_stroke_index = bool(hole_stroke_indices) skins_won, skins_hole_results, unclaimed = None, None, 0 if game["format"] == "skins": @@ -331,9 +360,10 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_ return templates.TemplateResponse("games/summary.html", { "request": request, "user": user, "game": dict(game), "players": players, "scores": scores, "totals": totals, - "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices, - "results": results, + "holes": holes, "hole_pars": hole_pars, "playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole, + "has_stroke_index": has_stroke_index, + "results": results, "skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed, "carryover": bool(game["carryover"]), }) @@ -351,7 +381,6 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur totals = _totals(players, scores) holes = list(range(1, game["holes_count"] + 1)) - # Load hole pars and stroke indices if the game has a course hole_pars: dict[int, int] = {} hole_stroke_indices: dict[int, int] = {} if game["course_id"]: @@ -364,13 +393,15 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur if row["stroke_index"] is not None: hole_stroke_indices[row["hole_number"]] = row["stroke_index"] - playing_handicaps, strokes_per_hole = _playing_data(players, holes, hole_pars, hole_stroke_indices) + computed = _strokes_per_hole(players, holes, hole_stroke_indices) + strokes_per_hole = {p["id"]: p["strokes_allocation"] or computed.get(p["id"], {}) for p in players} + has_stroke_index = bool(hole_stroke_indices) return templates.TemplateResponse("games/game.html", { "request": request, "user": user, "game": dict(game), "players": players, "scores": scores, "totals": totals, "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices, - "playing_handicaps": playing_handicaps, "strokes_per_hole": strokes_per_hole, + "strokes_per_hole": strokes_per_hole, "has_stroke_index": has_stroke_index, "carryover": bool(game["carryover"]), }) diff --git a/app/templates/admin/edit_course.html b/app/templates/admin/edit_course.html index dfc83bf..53b2d34 100644 --- a/app/templates/admin/edit_course.html +++ b/app/templates/admin/edit_course.html @@ -108,7 +108,9 @@

- +
@@ -138,7 +140,10 @@
- +
diff --git a/app/templates/auth/check_email.html b/app/templates/auth/check_email.html index db89e69..1ec23ff 100644 --- a/app/templates/auth/check_email.html +++ b/app/templates/auth/check_email.html @@ -12,17 +12,107 @@

Check your email

- + -
+ ← Use a different email
+ + + + diff --git a/app/templates/games/game.html b/app/templates/games/game.html index c957bd7..a81cf48 100644 --- a/app/templates/games/game.html +++ b/app/templates/games/game.html @@ -6,7 +6,7 @@
+ style="position:fixed; top:0; left:0; right:0; bottom:calc(var(--nav-height) + env(safe-area-inset-bottom)); display:flex; flex-direction:column; overflow:hidden;"> {# ── Header ── #}
@@ -17,7 +17,7 @@