| import uuid |
| from datetime import datetime, timezone |
|
|
| import gspread |
| import streamlit as st |
| from google.oauth2.credentials import Credentials |
|
|
| _PLAYERS_HEADER = ["player_id", "name", "created_at"] |
| _GAMES_HEADER = ["game_id", "name", "created_at"] |
| |
| |
| |
| |
| _GAMES_PLAYED_HEADER = [ |
| "timestamp", |
| "game_id", |
| "game_name", |
| "session_id", |
| "round_number", |
| "player_id", |
| "player_name", |
| "score", |
| ] |
| _WORKBOOK_TITLE_PREFIX = "BoardGameTracker" |
|
|
| |
| |
| |
| |
| |
| |
| |
| _RECORDS_CACHE_TTL = 20 |
|
|
|
|
| def _get_user_client(access_token: str) -> gspread.Client: |
| creds = Credentials(token=access_token) |
| return gspread.authorize(creds) |
|
|
|
|
| def _ensure_columns(ws: gspread.Worksheet, required_columns: list[str]) -> list[str]: |
| """Appends any missing columns to the end of the header row. |
| |
| Existing data is never touched or reordered -- only new columns get |
| added at the end -- so sheets created before a schema change keep |
| working unmodified; old rows simply have blank cells for the new |
| columns. |
| """ |
| header = ws.row_values(1) |
| missing = [c for c in required_columns if c not in header] |
| if missing: |
| header = header + missing |
| ws.update([header], "A1") |
| return header |
|
|
|
|
| def _get_or_create_worksheet( |
| wb: gspread.Spreadsheet, title: str, header: list[str] |
| ) -> gspread.Worksheet: |
| cache_key = f"_ws_handle_{wb.id}_{title}" |
| if cache_key in st.session_state: |
| return st.session_state[cache_key] |
|
|
| try: |
| ws = wb.worksheet(title) |
| _ensure_columns(ws, header) |
| except gspread.exceptions.WorksheetNotFound: |
| ws = wb.add_worksheet(title=title, rows=1, cols=len(header)) |
| ws.append_row(header) |
|
|
| st.session_state[cache_key] = ws |
| return ws |
|
|
|
|
| @st.cache_data(ttl=_RECORDS_CACHE_TTL, show_spinner=False) |
| def _cached_records(_ws: gspread.Worksheet, cache_key: str) -> list[dict]: |
| return _ws.get_all_records() |
|
|
|
|
| def _get_records(wb: gspread.Spreadsheet, ws: gspread.Worksheet, title: str) -> list[dict]: |
| return _cached_records(ws, f"{wb.id}:{title}") |
|
|
|
|
| def _invalidate_records_cache() -> None: |
| _cached_records.clear() |
|
|
|
|
| def _append_row(ws: gspread.Worksheet, row: dict) -> None: |
| column_order = ws.row_values(1) |
| ws.append_row([row.get(c, "") for c in column_order]) |
| _invalidate_records_cache() |
|
|
|
|
| def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spreadsheet: |
| """Returns the user's own Sheets workbook, creating it in their Drive on first use. |
| |
| Uses the user's own OAuth access token (drive.file scope), so the file is |
| owned by them directly -- no service account or shared storage involved. |
| """ |
| client = _get_user_client(access_token) |
| title = f"{_WORKBOOK_TITLE_PREFIX} - {email}" |
|
|
| existing = client.list_spreadsheet_files(title=title) |
| if existing: |
| sh = client.open_by_key(existing[0]["id"]) |
| else: |
| sh = client.create(title) |
| players_ws = sh.sheet1 |
| players_ws.update_title("players") |
| players_ws.append_row(_PLAYERS_HEADER) |
|
|
| |
| |
| _get_or_create_worksheet(sh, "players", _PLAYERS_HEADER) |
| _get_or_create_worksheet(sh, "games", _GAMES_HEADER) |
| _get_or_create_worksheet(sh, "games_played", _GAMES_PLAYED_HEADER) |
| return sh |
|
|
|
|
| def list_players(wb: gspread.Spreadsheet) -> list[dict]: |
| ws = _get_or_create_worksheet(wb, "players", _PLAYERS_HEADER) |
| return _get_records(wb, ws, "players") |
|
|
|
|
| def add_player(wb: gspread.Spreadsheet, name: str) -> str: |
| ws = _get_or_create_worksheet(wb, "players", _PLAYERS_HEADER) |
| existing = _get_records(wb, ws, "players") |
| for row in existing: |
| if str(row.get("name", "")).strip().lower() == name.strip().lower(): |
| return row["player_id"] |
|
|
| player_id = uuid.uuid4().hex |
| _append_row( |
| ws, {"player_id": player_id, "name": name, "created_at": datetime.now(timezone.utc).isoformat()} |
| ) |
| return player_id |
|
|
|
|
| def list_games(wb: gspread.Spreadsheet) -> list[dict]: |
| ws = _get_or_create_worksheet(wb, "games", _GAMES_HEADER) |
| return _get_records(wb, ws, "games") |
|
|
|
|
| def get_or_create_game(wb: gspread.Spreadsheet, name: str) -> str: |
| ws = _get_or_create_worksheet(wb, "games", _GAMES_HEADER) |
| existing = _get_records(wb, ws, "games") |
| for row in existing: |
| if str(row.get("name", "")).strip().lower() == name.strip().lower(): |
| return row["game_id"] |
|
|
| game_id = uuid.uuid4().hex |
| _append_row( |
| ws, {"game_id": game_id, "name": name, "created_at": datetime.now(timezone.utc).isoformat()} |
| ) |
| return game_id |
|
|
|
|
| def list_game_names(wb: gspread.Spreadsheet) -> list[str]: |
| """Distinct game names played, most-recently-used first.""" |
| ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) |
| names = [row["game_name"] for row in _get_records(wb, ws, "games_played") if row.get("game_name")] |
| return list(dict.fromkeys(reversed(names))) |
|
|
|
|
| def record_round( |
| wb: gspread.Spreadsheet, |
| *, |
| game_id: str, |
| game_name: str, |
| session_id: str, |
| round_number: int, |
| player_id: str, |
| player_name: str, |
| score: int, |
| ) -> None: |
| ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) |
| _append_row( |
| ws, |
| { |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| "game_id": game_id, |
| "game_name": game_name, |
| "session_id": session_id, |
| "round_number": round_number, |
| "player_id": player_id, |
| "player_name": player_name, |
| "score": score, |
| }, |
| ) |
|
|
|
|
| def list_game_sessions(wb: gspread.Spreadsheet) -> list[dict]: |
| """One summary entry per distinct session_id, most recently played first.""" |
| ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) |
| rows = _get_records(wb, ws, "games_played") |
|
|
| sessions: dict[str, dict] = {} |
| order: list[str] = [] |
| for row in rows: |
| sid = row.get("session_id") |
| if not sid: |
| continue |
| if sid not in sessions: |
| sessions[sid] = { |
| "game_name": row.get("game_name") or "Untitled", |
| "timestamp": row.get("timestamp", ""), |
| "players": set(), |
| "rounds": set(), |
| } |
| order.append(sid) |
| if row.get("player_name"): |
| sessions[sid]["players"].add(row["player_name"]) |
| sessions[sid]["rounds"].add(row.get("round_number")) |
|
|
| return [ |
| { |
| "session_id": sid, |
| "game_name": sessions[sid]["game_name"], |
| "timestamp": sessions[sid]["timestamp"], |
| "num_players": len(sessions[sid]["players"]), |
| "num_rounds": len(sessions[sid]["rounds"]), |
| } |
| for sid in reversed(order) |
| ] |
|
|
|
|
| def get_session_rounds(wb: gspread.Spreadsheet, session_id: str) -> list[dict]: |
| """Raw games_played rows for one session, sorted by round number.""" |
| ws = _get_or_create_worksheet(wb, "games_played", _GAMES_PLAYED_HEADER) |
| rows = _get_records(wb, ws, "games_played") |
| matching = [r for r in rows if str(r.get("session_id")) == str(session_id)] |
| matching.sort(key=lambda r: int(r.get("round_number") or 0)) |
| return matching |
|
|