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).
|
fallback in gc.login (main() parks it as .bak first).
|
||||||
- DB after sync: 385 activities (→ 2026-06-02), all ISO timestamps on new
|
- 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.
|
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
|
The 18 legacy epoch-ms rows remain (P2).
|
||||||
garth login flow — port it to the gc backend (or hide login) later.
|
- **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
|
- `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.
|
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`
|
- Known bugs: 18 activities have epoch-ms floats in `start_time_local`
|
||||||
|
|||||||
@@ -19,19 +19,86 @@ from pathlib import Path
|
|||||||
from typing import Any
|
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:
|
def has_tokens(token_dir: Path) -> bool:
|
||||||
return (token_dir / "garmin_tokens.json").exists()
|
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:
|
def activate(token_dir: Path) -> str:
|
||||||
"""Authenticate from `token_dir` and patch garth. Returns the username."""
|
"""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
|
from garminconnect import Garmin
|
||||||
|
|
||||||
gc = Garmin()
|
gc = Garmin()
|
||||||
gc.login(tokenstore=str(token_dir)) # loads DI tokens; auto-refreshes + re-saves
|
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:
|
def connectapi(path: str, **kwargs: Any) -> Any:
|
||||||
return gc.connectapi(path, **kwargs)
|
return gc.connectapi(path, **kwargs)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import streamlit as st
|
|||||||
|
|
||||||
from openrun.config import default_config
|
from openrun.config import default_config
|
||||||
from openrun.db import get_state
|
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_api import run_sync
|
||||||
from openrun.ingest.garmin_export import ingest_path
|
from openrun.ingest.garmin_export import ingest_path
|
||||||
from openrun.web._helpers import get_conn, show_sidebar
|
from openrun.web._helpers import get_conn, show_sidebar
|
||||||
@@ -45,10 +45,14 @@ st.write(
|
|||||||
st.divider()
|
st.divider()
|
||||||
st.subheader("Live sync (Garmin Connect)")
|
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:
|
if _gc is not None:
|
||||||
st.success(f"Logged in as **{_user}**.")
|
st.success(f"Logged in as **{gauth.user_label(_gc)}**.")
|
||||||
|
|
||||||
c1, c2 = st.columns(2)
|
c1, c2 = st.columns(2)
|
||||||
full = c1.checkbox(
|
full = c1.checkbox(
|
||||||
@@ -77,34 +81,33 @@ if _user:
|
|||||||
fit_limit = int(_lim) or None
|
fit_limit = int(_lim) or None
|
||||||
|
|
||||||
if st.button("🔄 Sync now from Garmin", type="primary"):
|
if st.button("🔄 Sync now from Garmin", type="primary"):
|
||||||
if not gauth.resume():
|
summary = None
|
||||||
st.error("Session expired — log in again below.")
|
with st.status("Syncing from Garmin…", expanded=True) as status:
|
||||||
else:
|
try:
|
||||||
summary = None
|
gauth.patch_garth(_gc)
|
||||||
with st.status("Syncing from Garmin…", expanded=True) as status:
|
summary = run_sync(
|
||||||
try:
|
get_conn(),
|
||||||
summary = run_sync(
|
full=full,
|
||||||
get_conn(),
|
fetch_fit=want_fit,
|
||||||
full=full,
|
fit_backfill=fit_backfill,
|
||||||
fetch_fit=want_fit,
|
fit_type=fit_type,
|
||||||
fit_backfill=fit_backfill,
|
fit_limit=fit_limit,
|
||||||
fit_type=fit_type,
|
progress=status.write,
|
||||||
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 complete", state="complete")
|
status.update(label="Sync failed", state="error")
|
||||||
except Exception as exc: # noqa: BLE001 — surface any garth/HTTP error
|
st.error(str(exc))
|
||||||
status.update(label="Sync failed", state="error")
|
if summary is not None:
|
||||||
st.error(str(exc))
|
st.cache_data.clear()
|
||||||
if summary is not None:
|
st.success("Synced from Garmin Connect.")
|
||||||
st.cache_data.clear()
|
st.json(summary)
|
||||||
st.success("Synced from Garmin Connect.")
|
|
||||||
st.json(summary)
|
|
||||||
|
|
||||||
with st.expander("Log out"):
|
with st.expander("Log out"):
|
||||||
st.caption("Removes the saved OAuth tokens in `.secrets/`. You'll need to log in again to sync.")
|
st.caption("Removes the saved OAuth tokens in `.secrets/`. You'll need to log in again to sync.")
|
||||||
if st.button("Forget saved tokens"):
|
if st.button("Forget saved tokens"):
|
||||||
shutil.rmtree(gauth.token_dir(), ignore_errors=True)
|
shutil.rmtree(gauth.token_dir(), ignore_errors=True)
|
||||||
|
st.session_state.pop("garmin_client", None)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -123,7 +126,8 @@ else:
|
|||||||
st.warning("Enter both email and password.")
|
st.warning("Enter both email and password.")
|
||||||
else:
|
else:
|
||||||
try:
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
st.error(f"Login failed: {exc}")
|
st.error(f"Login failed: {exc}")
|
||||||
else:
|
else:
|
||||||
@@ -131,7 +135,7 @@ else:
|
|||||||
st.session_state["garmin_mfa_state"] = payload
|
st.session_state["garmin_mfa_state"] = payload
|
||||||
st.rerun()
|
st.rerun()
|
||||||
else:
|
else:
|
||||||
st.success(f"Logged in as {payload}.")
|
st.session_state["garmin_client"] = payload
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if "garmin_mfa_state" in st.session_state:
|
if "garmin_mfa_state" in st.session_state:
|
||||||
@@ -141,12 +145,12 @@ else:
|
|||||||
mfa_submitted = st.form_submit_button("Verify", type="primary")
|
mfa_submitted = st.form_submit_button("Verify", type="primary")
|
||||||
if mfa_submitted:
|
if mfa_submitted:
|
||||||
try:
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
st.error(f"Verification failed: {exc}")
|
st.error(f"Verification failed: {exc}")
|
||||||
else:
|
else:
|
||||||
del st.session_state["garmin_mfa_state"]
|
del st.session_state["garmin_mfa_state"]
|
||||||
st.success(f"Logged in as {user}.")
|
st.session_state["garmin_client"] = gc
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|||||||
Reference in New Issue
Block a user