Add Strava sync status report and manual trigger to admin panel
Each sync run now writes _strava_sync_status.json per user (status, imported count, error message). New admin endpoints expose this data and allow triggering an on-demand sync. The admin page gains a Strava Sync section showing per-user token/credentials state, total imported, last sync time, and last-run status with inline error messages.
This commit is contained in:
@@ -164,6 +164,8 @@ public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redire
|
||||
dem_url: str = "https://api.open-elevation.com" # Open-Elevation-compatible API base URL
|
||||
sync_secret: str = "" # shared secret for /api/internal/rebuild (set via --sync-secret)
|
||||
_db = None # sqlite3.Connection, opened lazily
|
||||
_strava_sync_running = False
|
||||
_strava_sync_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_db():
|
||||
@@ -1476,6 +1478,91 @@ async def admin_delete_user_directory(
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/admin/strava-sync")
|
||||
async def admin_strava_sync_status(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return per-user Strava sync status for the admin panel."""
|
||||
_require_admin(bincio_session)
|
||||
root = _get_data_dir()
|
||||
users = []
|
||||
for tf in sorted(root.glob("*/strava_token.json")):
|
||||
user_dir = tf.parent
|
||||
handle = user_dir.name
|
||||
has_creds = (user_dir / "strava_credentials.json").exists()
|
||||
|
||||
last_sync: str | None = None
|
||||
total_imported = 0
|
||||
sync_path = user_dir / "_strava_sync.json"
|
||||
if sync_path.exists():
|
||||
try:
|
||||
sc = json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
last_sync = sc.get("last_sync")
|
||||
total_imported = len(sc.get("imported_ids", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
run_status: str | None = None
|
||||
run_imported = 0
|
||||
run_errors = 0
|
||||
run_error_message: str | None = None
|
||||
last_run: str | None = None
|
||||
status_path = user_dir / "_strava_sync_status.json"
|
||||
if status_path.exists():
|
||||
try:
|
||||
ss = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
run_status = ss.get("status")
|
||||
run_imported = ss.get("imported", 0)
|
||||
run_errors = ss.get("errors", 0)
|
||||
run_error_message = ss.get("error_message")
|
||||
last_run = ss.get("last_run")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
users.append({
|
||||
"handle": handle,
|
||||
"has_credentials": has_creds,
|
||||
"last_sync": last_sync,
|
||||
"total_imported": total_imported,
|
||||
"run_status": run_status,
|
||||
"run_imported": run_imported,
|
||||
"run_errors": run_errors,
|
||||
"run_error_message": run_error_message,
|
||||
"last_run": last_run,
|
||||
})
|
||||
|
||||
return JSONResponse({"running": _strava_sync_running, "users": users})
|
||||
|
||||
|
||||
@app.post("/api/admin/strava-sync/run")
|
||||
async def admin_strava_sync_run(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger an immediate Strava sync for all users (admin only)."""
|
||||
global _strava_sync_running
|
||||
_require_admin(bincio_session)
|
||||
with _strava_sync_lock:
|
||||
if _strava_sync_running:
|
||||
raise HTTPException(409, "Sync already running")
|
||||
_strava_sync_running = True
|
||||
|
||||
def _run() -> None:
|
||||
global _strava_sync_running
|
||||
try:
|
||||
from bincio.sync_strava import sync_all
|
||||
results = sync_all(_get_data_dir())
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
if total_new > 0:
|
||||
_site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("admin_strava_sync_run: unexpected error")
|
||||
finally:
|
||||
_strava_sync_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start()
|
||||
return JSONResponse({"ok": True}, status_code=202)
|
||||
|
||||
|
||||
|
||||
# ── Self-service user settings ────────────────────────────────────────────────
|
||||
|
||||
|
||||
+30
-3
@@ -20,13 +20,35 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
_TOKEN_FILE = "strava_token.json"
|
||||
_CREDS_FILE = "strava_credentials.json"
|
||||
_SYNC_FILE = "_strava_sync.json"
|
||||
_TOKEN_FILE = "strava_token.json"
|
||||
_CREDS_FILE = "strava_credentials.json"
|
||||
_SYNC_FILE = "_strava_sync.json"
|
||||
_STATUS_FILE = "_strava_sync_status.json"
|
||||
|
||||
log = logging.getLogger("bincio.sync_strava")
|
||||
|
||||
|
||||
def _write_status(
|
||||
user_dir: Path,
|
||||
status: str,
|
||||
imported: int,
|
||||
errors: int,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
payload: dict = {
|
||||
"status": status,
|
||||
"imported": imported,
|
||||
"errors": errors,
|
||||
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if error_message is not None:
|
||||
payload["error_message"] = error_message
|
||||
try:
|
||||
(user_dir / _STATUS_FILE).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_creds(user_dir: Path) -> tuple[str, str] | None:
|
||||
"""Return (client_id, client_secret) from strava_credentials.json, or None."""
|
||||
p = user_dir / _CREDS_FILE
|
||||
@@ -59,6 +81,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
creds = _load_creds(user_dir)
|
||||
if creds is None:
|
||||
log.debug("sync[%s]: no strava_credentials.json — skipped", handle)
|
||||
_write_status(user_dir, "no_credentials", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
client_id, client_secret = creds
|
||||
@@ -67,6 +90,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
token = ensure_fresh(user_dir, client_id, client_secret)
|
||||
except StravaError as exc:
|
||||
log.error("sync[%s]: token refresh failed: %s", handle, exc)
|
||||
_write_status(user_dir, "token_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
access_token = token["access_token"]
|
||||
@@ -89,6 +113,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
all_acts = fetch_activities(access_token, after=after_ts)
|
||||
except StravaError as exc:
|
||||
log.error("sync[%s]: fetch_activities failed: %s", handle, exc)
|
||||
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids]
|
||||
@@ -97,6 +122,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
handle, len(new_acts), len(all_acts) - len(new_acts),
|
||||
)
|
||||
if not new_acts:
|
||||
_write_status(user_dir, "ok", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
# Load existing index so we can update it in place
|
||||
@@ -158,6 +184,7 @@ def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
merge_all(user_dir)
|
||||
|
||||
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors)
|
||||
_write_status(user_dir, "ok", imported, errors)
|
||||
return imported, errors
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user