Spaces:
Sleeping
Sleeping
Commit ·
5d4959a
1
Parent(s): 7e1ff1d
Add optional Google login with Sheets-backed persistence for Points Tracker
Browse filesAnonymous 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 +7 -0
- README.md +49 -0
- app.py +18 -0
- pages/points_tracker.py +42 -0
- requirements.txt +5 -1
- src/bootstrap_secrets.py +40 -0
- src/sheets_backend.py +95 -0
.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 |
+
)
|