"""Garmin routes: trigger a sync and read sync status.""" from flask import Blueprint, request, g, jsonify from auth import require_auth from db import query_one from services import garmin as garmin_svc from services import garmin_auth from services import scheduler bp = Blueprint("garmin", __name__) @bp.route("/sync", methods=["POST"]) @require_auth def sync(): data = request.get_json(silent=True) or {} creds = { "garminEmail": (data.get("garminEmail") or "").strip(), "garminPassword": data.get("garminPassword") or "", } # Fall back to the stored Garmin email when only a password is supplied. if not creds["garminEmail"]: user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id]) if user and user.get("garmin_email"): creds["garminEmail"] = user["garmin_email"] # With stored OAuth tokens no password is needed at all. Without them the # plaintext password must come in the body, because only a hash is kept. if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id): return ( jsonify({ "status": "error", "recordsSynced": 0, "message": "需要 Garmin 密码以执行同步,请在请求体中提供 garminPassword" "(密码仅作哈希存储,无法还原)。", }), 400, ) days = request.get_json(silent=True).get("days") if request.is_json else None try: days = max(1, min(int(days), 730)) if days else None except (TypeError, ValueError): days = None # Always run in the background: even a week takes ~20s, and a full # backfill runs for many minutes. Progress is polled via /status. result = garmin_svc.start_sync(g.user_id, creds, days) return jsonify(result), 202 @bp.route("/auth-status", methods=["GET"]) @require_auth def auth_status(): """Whether a stored token exists, so the UI knows to ask for a password.""" return jsonify({"hasToken": garmin_svc.has_token(g.user_id)}) @bp.route("/login", methods=["POST"]) @require_auth def login(): """Begin an interactive Garmin login. Returns immediately with a session id; the login continues in the background and parks if Garmin asks for a two-factor code. Poll /login-status and post the code to /mfa. """ data = request.get_json(silent=True) or {} password = data.get("garminPassword") or "" if not password: return jsonify({"error": "请提供 Garmin 密码"}), 400 garmin_email = (data.get("garminEmail") or "").strip() if not garmin_email: user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id]) garmin_email = (user or {}).get("garmin_email") or "" if not garmin_email: return jsonify({"error": "缺少 Garmin 邮箱"}), 400 session_id = garmin_auth.start_login(g.user_id, garmin_email, password) return jsonify({"session": session_id, "status": "starting"}), 202 @bp.route("/login-status", methods=["GET"]) @require_auth def login_status(): session_id = request.args.get("session") or "" row = garmin_auth.get_session(session_id, g.user_id) if not row: return jsonify({"error": "登录会话不存在或已过期"}), 404 return jsonify({ "session": row["id"], "status": row["status"], "error": row["error"], }) @bp.route("/mfa", methods=["POST"]) @require_auth def submit_mfa(): data = request.get_json(silent=True) or {} session_id = (data.get("session") or "").strip() code = (data.get("code") or "").strip() if not session_id or not code: return jsonify({"error": "session 与 code 均为必填"}), 400 ok, message = garmin_auth.submit_code(session_id, g.user_id, code) return jsonify({"ok": ok, "message": message}), (200 if ok else 400) @bp.route("/login", methods=["DELETE"]) @require_auth def cancel_login(): session_id = request.args.get("session") or "" garmin_auth.cancel(session_id, g.user_id) return jsonify({"ok": True}) @bp.route("/status", methods=["GET"]) @require_auth def status(): return jsonify(garmin_svc.get_sync_status(g.user_id)) @bp.route("/sync-latest", methods=["POST"]) @require_auth def sync_latest(): """Pull just the last couple of days. Separate from /sync because it is fast enough to wait for (a few seconds rather than minutes), so the UI can report the result directly instead of handing back a job to poll. """ if not garmin_svc.has_token(g.user_id): return jsonify({"error": "尚未绑定 Garmin 账号"}), 400 days = request.get_json(silent=True) or {} try: window = max(1, min(int(days.get("days", scheduler.SYNC_DAYS)), 7)) except (TypeError, ValueError): window = scheduler.SYNC_DAYS return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window)) @bp.route("/auto-sync", methods=["GET"]) @require_auth def auto_sync_status(): """When the scheduler last ran, and when this account is next due.""" return jsonify(scheduler.status(g.user_id)) @bp.route("/activities//detail", methods=["GET"]) @require_auth def activity_detail(activity_id): """Everything Garmin holds for one activity: stats, laps, zones, series. The first open goes out to Garmin and takes a few seconds; after that it is served from the stored copy. `?refresh=1` forces a refetch. """ if not garmin_svc.has_token(g.user_id): return jsonify({"error": "尚未绑定 Garmin 账号"}), 400 refresh = request.args.get("refresh") in ("1", "true", "yes") try: return jsonify( garmin_svc.get_activity_detail(g.user_id, activity_id, refresh=refresh) ) except garmin_svc.MFARequired as e: return jsonify({"error": str(e)}), 401 except Exception as e: # noqa: BLE001 - surfaced to the user verbatim return jsonify({"error": garmin_svc.describe(e)}), 502