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
-
We sent a login link to {{ email }}
+
We sent a code and a login link to {{ email }}
-
- The link expires in 15 minutes.
You can close this tab.
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
+
+
+ The code and link expire in 15 minutes.
-
+
← Use a different email
+
+
+
+