web: port Sync page login + sync to garminconnect backend
garminconnect_backend gains web-friendly auth: resume() (token restore with API validation), begin_login()/complete_mfa() (two-step MFA handshake via return_on_mfa), user_label(), and patch_garth() split out of activate(). 5_Sync.py drops the garth login flow (Cloudflare-blocked) for the new backend; the authenticated client is cached in st.session_state. Verified headlessly with streamlit AppTest: token restore, sync-button click, full incremental sync, no exceptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,8 +33,16 @@ architecture and conventions. Delete this file when the list is done.
|
||||
fallback in gc.login (main() parks it as .bak first).
|
||||
- DB after sync: 385 activities (→ 2026-06-02), all ISO timestamps on new
|
||||
rows, 356 FITs linked, 364 TIZ rows, wellness through 2026-06-12.
|
||||
The 18 legacy epoch-ms rows remain (P2). Web Sync page still uses the
|
||||
garth login flow — port it to the gc backend (or hide login) later.
|
||||
The 18 legacy epoch-ms rows remain (P2).
|
||||
- **Web Sync page ported to the gc backend** (same session): login + MFA
|
||||
round-trip + sync button all run on garminconnect; verified headlessly via
|
||||
streamlit AppTest (login restore, button click, full sync, no exceptions).
|
||||
`openrun.ingest.auth` (garth) remains only for `--backend=garth`.
|
||||
- **New goal (user, 2026-06-12): shareable web UI** — let others run the
|
||||
openrun web app against their own Garmin account. Known gaps: per-user
|
||||
token store + DB (everything is cwd-relative single-user today), per-user
|
||||
openrun.toml (zones/races — openrun-init helps), and deploy story
|
||||
(`streamlit run` is single-process; secrets must stay server-side).
|
||||
- `race_plan` table is EMPTY and `manual_activities` empty, but openrun.toml
|
||||
has races: 30K 2026-06-13, 50K 2026-07-25, 50 MILE 2026-09-12.
|
||||
- Known bugs: 18 activities have epoch-ms floats in `start_time_local`
|
||||
|
||||
@@ -19,19 +19,86 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def token_dir() -> Path:
|
||||
"""Token store: `.secrets/` relative to the current working directory."""
|
||||
return Path.cwd() / ".secrets"
|
||||
|
||||
|
||||
def has_tokens(token_dir: Path) -> bool:
|
||||
return (token_dir / "garmin_tokens.json").exists()
|
||||
|
||||
|
||||
def resume(td: Path | None = None) -> Any | None:
|
||||
"""Restore an authenticated Garmin client from saved tokens, else None.
|
||||
|
||||
Validates against the API (refreshing the DI token if needed), so a dead
|
||||
tokenstore returns None rather than failing later mid-sync.
|
||||
"""
|
||||
from garminconnect import Garmin
|
||||
|
||||
td = td or token_dir()
|
||||
if not has_tokens(td):
|
||||
return None
|
||||
try:
|
||||
gc = Garmin()
|
||||
gc.login(tokenstore=str(td))
|
||||
return gc
|
||||
except Exception: # noqa: BLE001 — expired/rejected tokens → logged out
|
||||
return None
|
||||
|
||||
|
||||
def begin_login(email: str, password: str, td: Path | None = None) -> tuple[str, Any]:
|
||||
"""Start a fresh credential login (web flow, no input()). Returns:
|
||||
|
||||
* ``("ok", gc)`` — logged in; tokens saved.
|
||||
* ``("needs_mfa", gc)`` — hold ``gc`` (e.g. in st.session_state) and pass
|
||||
it with the emailed/app code to ``complete_mfa``.
|
||||
|
||||
Raises garminconnect's typed errors on bad credentials / rate limits.
|
||||
"""
|
||||
from garminconnect import Garmin
|
||||
|
||||
gc = Garmin(email=email, password=password, return_on_mfa=True)
|
||||
status, _ = gc.login() # cascading strategies; early-returns pre-dump
|
||||
if status == "needs_mfa":
|
||||
return "needs_mfa", gc
|
||||
return "ok", _finish_login(gc, td)
|
||||
|
||||
|
||||
def complete_mfa(gc: Any, code: str, td: Path | None = None) -> Any:
|
||||
"""Finish an MFA login started by ``begin_login``. Returns the client."""
|
||||
gc.client.resume_login(None, code.strip())
|
||||
return _finish_login(gc, td)
|
||||
|
||||
|
||||
def _finish_login(gc: Any, td: Path | None = None) -> Any:
|
||||
"""Persist tokens and load the profile (skipped in return_on_mfa mode)."""
|
||||
td = td or token_dir()
|
||||
td.mkdir(parents=True, exist_ok=True)
|
||||
gc.client.dump(str(td))
|
||||
gc._load_profile_and_settings() # noqa: SLF001 — wrapper has no public hook
|
||||
return gc
|
||||
|
||||
|
||||
def user_label(gc: Any) -> str:
|
||||
"""Human-readable account label for UI display."""
|
||||
return gc.full_name or gc.username or gc.display_name or "Garmin user"
|
||||
|
||||
|
||||
def activate(token_dir: Path) -> str:
|
||||
"""Authenticate from `token_dir` and patch garth. Returns the username."""
|
||||
import garth
|
||||
from garth.http import Client as GarthClient
|
||||
from garth.http import client as garth_singleton
|
||||
from garminconnect import Garmin
|
||||
|
||||
gc = Garmin()
|
||||
gc.login(tokenstore=str(token_dir)) # loads DI tokens; auto-refreshes + re-saves
|
||||
return patch_garth(gc)
|
||||
|
||||
|
||||
def patch_garth(gc: Any) -> str:
|
||||
"""Route garth's API surface through an authenticated garminconnect client."""
|
||||
import garth
|
||||
from garth.http import Client as GarthClient
|
||||
from garth.http import client as garth_singleton
|
||||
|
||||
def connectapi(path: str, **kwargs: Any) -> Any:
|
||||
return gc.connectapi(path, **kwargs)
|
||||
|
||||
@@ -19,7 +19,7 @@ import streamlit as st
|
||||
|
||||
from openrun.config import default_config
|
||||
from openrun.db import get_state
|
||||
from openrun.ingest import auth as gauth
|
||||
from openrun.ingest import garminconnect_backend as gauth
|
||||
from openrun.ingest.garmin_api import run_sync
|
||||
from openrun.ingest.garmin_export import ingest_path
|
||||
from openrun.web._helpers import get_conn, show_sidebar
|
||||
@@ -45,10 +45,14 @@ st.write(
|
||||
st.divider()
|
||||
st.subheader("Live sync (Garmin Connect)")
|
||||
|
||||
_user = gauth.current_user()
|
||||
# Resume validates tokens against the API (one round-trip), so cache the
|
||||
# result for the browser session instead of re-checking on every rerun.
|
||||
if "garmin_client" not in st.session_state:
|
||||
st.session_state["garmin_client"] = gauth.resume()
|
||||
_gc = st.session_state["garmin_client"]
|
||||
|
||||
if _user:
|
||||
st.success(f"Logged in as **{_user}**.")
|
||||
if _gc is not None:
|
||||
st.success(f"Logged in as **{gauth.user_label(_gc)}**.")
|
||||
|
||||
c1, c2 = st.columns(2)
|
||||
full = c1.checkbox(
|
||||
@@ -77,34 +81,33 @@ if _user:
|
||||
fit_limit = int(_lim) or None
|
||||
|
||||
if st.button("🔄 Sync now from Garmin", type="primary"):
|
||||
if not gauth.resume():
|
||||
st.error("Session expired — log in again below.")
|
||||
else:
|
||||
summary = None
|
||||
with st.status("Syncing from Garmin…", expanded=True) as status:
|
||||
try:
|
||||
summary = run_sync(
|
||||
get_conn(),
|
||||
full=full,
|
||||
fetch_fit=want_fit,
|
||||
fit_backfill=fit_backfill,
|
||||
fit_type=fit_type,
|
||||
fit_limit=fit_limit,
|
||||
progress=status.write,
|
||||
)
|
||||
status.update(label="Sync complete", state="complete")
|
||||
except Exception as exc: # noqa: BLE001 — surface any garth/HTTP error
|
||||
status.update(label="Sync failed", state="error")
|
||||
st.error(str(exc))
|
||||
if summary is not None:
|
||||
st.cache_data.clear()
|
||||
st.success("Synced from Garmin Connect.")
|
||||
st.json(summary)
|
||||
summary = None
|
||||
with st.status("Syncing from Garmin…", expanded=True) as status:
|
||||
try:
|
||||
gauth.patch_garth(_gc)
|
||||
summary = run_sync(
|
||||
get_conn(),
|
||||
full=full,
|
||||
fetch_fit=want_fit,
|
||||
fit_backfill=fit_backfill,
|
||||
fit_type=fit_type,
|
||||
fit_limit=fit_limit,
|
||||
progress=status.write,
|
||||
)
|
||||
status.update(label="Sync complete", state="complete")
|
||||
except Exception as exc: # noqa: BLE001 — surface any API/HTTP error
|
||||
status.update(label="Sync failed", state="error")
|
||||
st.error(str(exc))
|
||||
if summary is not None:
|
||||
st.cache_data.clear()
|
||||
st.success("Synced from Garmin Connect.")
|
||||
st.json(summary)
|
||||
|
||||
with st.expander("Log out"):
|
||||
st.caption("Removes the saved OAuth tokens in `.secrets/`. You'll need to log in again to sync.")
|
||||
if st.button("Forget saved tokens"):
|
||||
shutil.rmtree(gauth.token_dir(), ignore_errors=True)
|
||||
st.session_state.pop("garmin_client", None)
|
||||
st.rerun()
|
||||
|
||||
else:
|
||||
@@ -123,7 +126,8 @@ else:
|
||||
st.warning("Enter both email and password.")
|
||||
else:
|
||||
try:
|
||||
kind, payload = gauth.begin_login(email, password)
|
||||
with st.spinner("Logging in to Garmin…"):
|
||||
kind, payload = gauth.begin_login(email, password)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
st.error(f"Login failed: {exc}")
|
||||
else:
|
||||
@@ -131,7 +135,7 @@ else:
|
||||
st.session_state["garmin_mfa_state"] = payload
|
||||
st.rerun()
|
||||
else:
|
||||
st.success(f"Logged in as {payload}.")
|
||||
st.session_state["garmin_client"] = payload
|
||||
st.rerun()
|
||||
|
||||
if "garmin_mfa_state" in st.session_state:
|
||||
@@ -141,12 +145,12 @@ else:
|
||||
mfa_submitted = st.form_submit_button("Verify", type="primary")
|
||||
if mfa_submitted:
|
||||
try:
|
||||
user = gauth.complete_mfa(st.session_state["garmin_mfa_state"], code)
|
||||
gc = gauth.complete_mfa(st.session_state["garmin_mfa_state"], code)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
st.error(f"Verification failed: {exc}")
|
||||
else:
|
||||
del st.session_state["garmin_mfa_state"]
|
||||
st.success(f"Logged in as {user}.")
|
||||
st.session_state["garmin_client"] = gc
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
Reference in New Issue
Block a user