RandomCatLover Claude Sonnet 5 commited on
Commit
5d4959a
·
1 Parent(s): 7e1ff1d

Add optional Google login with Sheets-backed persistence for Points Tracker

Browse files

Anonymous play is unchanged. Logging in via the sidebar provisions a
per-user Google Sheet (owned by a service account) with players and
games_played worksheets, and Points Tracker writes player/round data
to it as you play. Secrets are gitignored locally and can be assembled
from individual env vars on deploy (src/bootstrap_secrets.py) so
nothing sensitive needs to live in the image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .streamlit/secrets.toml
5
+ client_secret_*.json
6
+ *.iam.gserviceaccount.com.json
7
+ poetic-chariot-*.json
README.md CHANGED
@@ -18,3 +18,52 @@ Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :hear
18
 
19
  If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
  forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
  forums](https://discuss.streamlit.io).
21
+
22
+ ## Persistence setup (Google login + Sheets)
23
+
24
+ The app works fully anonymously with no setup. Logging in with Google (button
25
+ in the sidebar) additionally persists your players and round history to a
26
+ Google Sheet owned by a service account.
27
+
28
+ ### One-time Google Cloud setup
29
+ 1. Enable the **Google Sheets API** and **Google Drive API** on a GCP project.
30
+ 2. Create an OAuth 2.0 **Web application** Client ID for login; add your
31
+ app's URL(s) as authorized redirect URIs (e.g. `http://localhost:8501` for
32
+ local dev).
33
+ 3. Create a **Service Account**, download its JSON key. Service accounts have
34
+ no Drive storage quota of their own, so:
35
+ - Create a folder in a real Google Drive account, share it with the
36
+ service account's `client_email` as **Editor**, and copy the folder ID
37
+ from its URL. This becomes `shared_folder_id` below.
38
+ 4. Run the one-time index-spreadsheet creation (see `src/sheets_backend.py`,
39
+ `get_or_create_user_workbook` needs an index spreadsheet — create one via
40
+ the service account inside the shared folder and note its ID).
41
+
42
+ ### Local dev — `.streamlit/secrets.toml` (gitignored, never commit this)
43
+ ```toml
44
+ [auth]
45
+ redirect_uri = "http://localhost:8501"
46
+ cookie_secret = "<random string>"
47
+ client_id = "<oauth client id>"
48
+ client_secret = "<oauth client secret>"
49
+ server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
50
+
51
+ [gcp_service_account]
52
+ # ...full service account JSON fields...
53
+
54
+ [sheets]
55
+ index_spreadsheet_id = "<id of the index spreadsheet>"
56
+ shared_folder_id = "<id of the Drive folder shared with the service account>"
57
+ ```
58
+
59
+ ### Hugging Face Space deploy
60
+ `st.login()` only reads `st.secrets`, so `src/bootstrap_secrets.py` assembles
61
+ `.streamlit/secrets.toml` at startup from individual Space secrets (Settings →
62
+ Variables and secrets), so nothing sensitive needs to live in the repo or the
63
+ image:
64
+
65
+ `AUTH_CLIENT_ID`, `AUTH_CLIENT_SECRET`, `AUTH_COOKIE_SECRET`,
66
+ `AUTH_REDIRECT_URI` (your Space's public URL), `GCP_PROJECT_ID`,
67
+ `GCP_PRIVATE_KEY_ID`, `GCP_PRIVATE_KEY` (paste with literal `\n` for
68
+ newlines), `GCP_CLIENT_EMAIL`, `GCP_CLIENT_ID`, `GCP_CLIENT_X509_CERT_URL`,
69
+ `SHEETS_INDEX_ID`, `SHEETS_SHARED_FOLDER_ID`.
app.py CHANGED
@@ -1,7 +1,25 @@
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
 
3
  st.set_page_config(page_title="Board Game Tracker", layout="wide")
4
 
 
 
 
 
 
 
 
 
 
5
  home_page = st.Page("pages/home.py", title="Home", url_path="home", default=True)
6
  skull_king_page = st.Page("pages/skull_king.py", title="Skull King", url_path="skull_king")
7
  dungeon_draft_page = st.Page("pages/dungeon_draft.py", title="Dungeon Draft", url_path="dungeon_draft")
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).parent / "src"))
5
+
6
+ from bootstrap_secrets import ensure_secrets_file
7
+
8
+ ensure_secrets_file()
9
+
10
  import streamlit as st
11
 
12
  st.set_page_config(page_title="Board Game Tracker", layout="wide")
13
 
14
+ with st.sidebar:
15
+ if "auth" not in st.secrets:
16
+ pass # no auth configured in this environment; app stays anonymous-only
17
+ elif not st.user.is_logged_in:
18
+ st.button("Log in with Google", on_click=st.login)
19
+ else:
20
+ st.caption(f"Logged in as {st.user.email}")
21
+ st.button("Log out", on_click=st.logout)
22
+
23
  home_page = st.Page("pages/home.py", title="Home", url_path="home", default=True)
24
  skull_king_page = st.Page("pages/skull_king.py", title="Skull King", url_path="skull_king")
25
  dungeon_draft_page = st.Page("pages/dungeon_draft.py", title="Dungeon Draft", url_path="dungeon_draft")
pages/points_tracker.py CHANGED
@@ -1,7 +1,11 @@
 
 
1
  import streamlit as st
2
  import pandas as pd
3
  import altair as alt
4
 
 
 
5
 
6
  def _init_state():
7
  if "pt_players" not in st.session_state:
@@ -14,6 +18,17 @@ def _init_state():
14
  st.session_state.pt_winning_score = 100
15
  if "pt_game_name" not in st.session_state:
16
  st.session_state.pt_game_name = ""
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def _setup_phase():
@@ -38,6 +53,9 @@ def _setup_phase():
38
  name = st.session_state.pt_new_player.strip()
39
  if name and name not in st.session_state.pt_players:
40
  st.session_state.pt_players.append(name)
 
 
 
41
  st.session_state.pt_new_player = ""
42
 
43
  col1, col2 = st.columns([3, 1])
@@ -48,6 +66,18 @@ def _setup_phase():
48
  st.write("")
49
  st.button("Add Player", key="pt_add_btn", on_click=_add_player)
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  if st.session_state.pt_players:
52
  st.markdown("**Players:**")
53
  for i, p in enumerate(st.session_state.pt_players):
@@ -61,6 +91,7 @@ def _setup_phase():
61
  if st.button("Start Game", type="primary"):
62
  st.session_state.pt_game_started = True
63
  st.session_state.pt_scores = []
 
64
  st.rerun()
65
  else:
66
  st.info("Add at least 2 players to start.")
@@ -206,6 +237,17 @@ def _game_phase():
206
 
207
  if st.button("Save Round", type="primary", key=f"pt_save_r{next_round}"):
208
  st.session_state.pt_scores.append(round_scores)
 
 
 
 
 
 
 
 
 
 
 
209
  st.rerun()
210
 
211
 
 
1
+ import uuid
2
+
3
  import streamlit as st
4
  import pandas as pd
5
  import altair as alt
6
 
7
+ import sheets_backend as sheets
8
+
9
 
10
  def _init_state():
11
  if "pt_players" not in st.session_state:
 
18
  st.session_state.pt_winning_score = 100
19
  if "pt_game_name" not in st.session_state:
20
  st.session_state.pt_game_name = ""
21
+ if "pt_session_id" not in st.session_state:
22
+ st.session_state.pt_session_id = None
23
+
24
+
25
+ def _get_workbook():
26
+ """Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
27
+ if "auth" not in st.secrets or not st.user.is_logged_in:
28
+ return None
29
+ if "pt_workbook" not in st.session_state:
30
+ st.session_state.pt_workbook = sheets.get_or_create_user_workbook(st.user.email)
31
+ return st.session_state.pt_workbook
32
 
33
 
34
  def _setup_phase():
 
53
  name = st.session_state.pt_new_player.strip()
54
  if name and name not in st.session_state.pt_players:
55
  st.session_state.pt_players.append(name)
56
+ wb = _get_workbook()
57
+ if wb is not None:
58
+ sheets.add_player(wb, name)
59
  st.session_state.pt_new_player = ""
60
 
61
  col1, col2 = st.columns([3, 1])
 
66
  st.write("")
67
  st.button("Add Player", key="pt_add_btn", on_click=_add_player)
68
 
69
+ wb = _get_workbook()
70
+ if wb is not None:
71
+ saved_names = [p["name"] for p in sheets.list_players(wb) if p.get("name")]
72
+ remaining = [n for n in saved_names if n not in st.session_state.pt_players]
73
+ if remaining:
74
+ st.caption("Quick-add from your saved players:")
75
+ qcols = st.columns(min(len(remaining), 6))
76
+ for i, name in enumerate(remaining):
77
+ if qcols[i % len(qcols)].button(name, key=f"pt_quickadd_{name}"):
78
+ st.session_state.pt_players.append(name)
79
+ st.rerun()
80
+
81
  if st.session_state.pt_players:
82
  st.markdown("**Players:**")
83
  for i, p in enumerate(st.session_state.pt_players):
 
91
  if st.button("Start Game", type="primary"):
92
  st.session_state.pt_game_started = True
93
  st.session_state.pt_scores = []
94
+ st.session_state.pt_session_id = uuid.uuid4().hex
95
  st.rerun()
96
  else:
97
  st.info("Add at least 2 players to start.")
 
237
 
238
  if st.button("Save Round", type="primary", key=f"pt_save_r{next_round}"):
239
  st.session_state.pt_scores.append(round_scores)
240
+ wb = _get_workbook()
241
+ if wb is not None:
242
+ for player, score in round_scores.items():
243
+ sheets.record_round(
244
+ wb,
245
+ game_name=game_name or "Points Tracker",
246
+ session_id=st.session_state.pt_session_id,
247
+ round_number=next_round,
248
+ player_name=player,
249
+ score=score,
250
+ )
251
  st.rerun()
252
 
253
 
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
  altair
2
  pandas
3
- streamlit
 
 
 
 
 
1
  altair
2
  pandas
3
+ streamlit>=1.42
4
+ gspread
5
+ google-auth
6
+ Authlib
7
+ tomli_w
src/bootstrap_secrets.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import tomli_w
5
+
6
+ _SECRETS_PATH = Path(".streamlit/secrets.toml")
7
+
8
+
9
+ def ensure_secrets_file() -> None:
10
+ if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
11
+ return # local dev already has the real file; skip if env unset
12
+
13
+ _SECRETS_PATH.parent.mkdir(exist_ok=True)
14
+ data = {
15
+ "auth": {
16
+ "redirect_uri": os.environ["AUTH_REDIRECT_URI"],
17
+ "cookie_secret": os.environ["AUTH_COOKIE_SECRET"],
18
+ "client_id": os.environ["AUTH_CLIENT_ID"],
19
+ "client_secret": os.environ["AUTH_CLIENT_SECRET"],
20
+ "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
21
+ },
22
+ "gcp_service_account": {
23
+ "type": "service_account",
24
+ "project_id": os.environ["GCP_PROJECT_ID"],
25
+ "private_key_id": os.environ["GCP_PRIVATE_KEY_ID"],
26
+ "private_key": os.environ["GCP_PRIVATE_KEY"].replace("\\n", "\n"),
27
+ "client_email": os.environ["GCP_CLIENT_EMAIL"],
28
+ "client_id": os.environ["GCP_CLIENT_ID"],
29
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
30
+ "token_uri": "https://oauth2.googleapis.com/token",
31
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
32
+ "client_x509_cert_url": os.environ["GCP_CLIENT_X509_CERT_URL"],
33
+ "universe_domain": "googleapis.com",
34
+ },
35
+ "sheets": {
36
+ "index_spreadsheet_id": os.environ["SHEETS_INDEX_ID"],
37
+ "shared_folder_id": os.environ.get("SHEETS_SHARED_FOLDER_ID", ""),
38
+ },
39
+ }
40
+ _SECRETS_PATH.write_text(tomli_w.dumps(data))
src/sheets_backend.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ import gspread
5
+ import streamlit as st
6
+ from google.oauth2.service_account import Credentials
7
+
8
+ _SCOPES = [
9
+ "https://www.googleapis.com/auth/spreadsheets",
10
+ "https://www.googleapis.com/auth/drive",
11
+ ]
12
+
13
+ _PLAYERS_HEADER = ["player_id", "name", "created_at"]
14
+ _GAMES_HEADER = ["timestamp", "game_name", "session_id", "round_number", "player_name", "score"]
15
+
16
+
17
+ @st.cache_resource
18
+ def _get_client():
19
+ creds = Credentials.from_service_account_info(
20
+ st.secrets["gcp_service_account"], scopes=_SCOPES
21
+ )
22
+ return gspread.authorize(creds)
23
+
24
+
25
+ def _folder_id():
26
+ return st.secrets["sheets"].get("shared_folder_id") or None
27
+
28
+
29
+ def _get_index_ws():
30
+ client = _get_client()
31
+ sh = client.open_by_key(st.secrets["sheets"]["index_spreadsheet_id"])
32
+ return sh.worksheet("users")
33
+
34
+
35
+ def get_or_create_user_workbook(email: str) -> gspread.Spreadsheet:
36
+ client = _get_client()
37
+ index_ws = _get_index_ws()
38
+ rows = index_ws.get_all_records()
39
+ for row in rows:
40
+ if row.get("email") == email:
41
+ return client.open_by_key(row["spreadsheet_id"])
42
+
43
+ sh = client.create(f"BoardGameTracker - {email}", folder_id=_folder_id())
44
+ players_ws = sh.sheet1
45
+ players_ws.update_title("players")
46
+ players_ws.append_row(_PLAYERS_HEADER)
47
+ games_ws = sh.add_worksheet(title="games_played", rows=1, cols=len(_GAMES_HEADER))
48
+ games_ws.append_row(_GAMES_HEADER)
49
+
50
+ try:
51
+ sh.share(email, perm_type="user", role="writer")
52
+ except gspread.exceptions.APIError:
53
+ pass # sharing is a nice-to-have; don't block provisioning on it
54
+
55
+ index_ws.append_row([email, sh.id, sh.url, datetime.now(timezone.utc).isoformat()])
56
+ return sh
57
+
58
+
59
+ def list_players(wb: gspread.Spreadsheet) -> list[dict]:
60
+ ws = wb.worksheet("players")
61
+ return ws.get_all_records()
62
+
63
+
64
+ def add_player(wb: gspread.Spreadsheet, name: str) -> str:
65
+ ws = wb.worksheet("players")
66
+ existing = ws.get_all_records()
67
+ for row in existing:
68
+ if row.get("name", "").strip().lower() == name.strip().lower():
69
+ return row["player_id"]
70
+
71
+ player_id = uuid.uuid4().hex
72
+ ws.append_row([player_id, name, datetime.now(timezone.utc).isoformat()])
73
+ return player_id
74
+
75
+
76
+ def record_round(
77
+ wb: gspread.Spreadsheet,
78
+ *,
79
+ game_name: str,
80
+ session_id: str,
81
+ round_number: int,
82
+ player_name: str,
83
+ score: int,
84
+ ) -> None:
85
+ ws = wb.worksheet("games_played")
86
+ ws.append_row(
87
+ [
88
+ datetime.now(timezone.utc).isoformat(),
89
+ game_name,
90
+ session_id,
91
+ round_number,
92
+ player_name,
93
+ score,
94
+ ]
95
+ )