"""Garmin routes: bind a Garmin account, trigger a sync, read sync status. Deliberately independent of the `users` identity: this blueprint reads and writes only `garmin_tokens` and the sync-status tables, keyed by `g.user_id`. Web login (routes/auth.py, via auth-hub) establishes who `g.user_id` is; whether that account has a Garmin binding at all is this blueprint's business alone, and the two are meant to be operable independently — see services/garmin.py's `get_remembered_email` for the one deliberate, backward-compatible read of the legacy `users.garmin_email` column. """ from flask import Blueprint, request, g, jsonify from auth import require_auth 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 remembered Garmin email when only a password is supplied. if not creds["garminEmail"]: creds["garminEmail"] = garmin_svc.get_remembered_email(g.user_id) # 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("/disconnect", methods=["POST"]) @require_auth def disconnect(): """Drop the stored Garmin token so the next sync must re-authenticate. Deletes the OAuth token (the UI calls this a "退出 Garmin 账号"). garmin_email stays on the user record, so re-login only needs the password again. Already synced health data is untouched. """ garmin_svc.delete_token(g.user_id) return jsonify({ "ok": True, "message": "已退出 Garmin 账号,已保存的授权令牌已删除,下次同步需重新登录获取新令牌。", }) @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: garmin_email = garmin_svc.get_remembered_email(g.user_id) 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 stored for one activity: stats, laps, zones, series. A local read. Detail is fetched during the sync rather than when the user taps an activity — seven Garmin calls on the critical path of a tap was slow on a good connection and a timeout on a bad one. """ detail = garmin_svc.read_activity_detail(g.user_id, activity_id) if detail is None: return jsonify({ "error": "这条运动的详细数据还没同步到本机,去「同步」页拉一次即可。", "needsSync": True, }), 404 return jsonify(detail) @bp.route("/sync-details", methods=["POST"]) @require_auth def sync_details(): """Backfill activity details and daily curves for existing history.""" if not garmin_svc.has_token(g.user_id): return jsonify({"error": "尚未绑定 Garmin 账号"}), 400 body = request.get_json(silent=True) or {} try: limit = int(body["limit"]) if body.get("limit") else None except (TypeError, ValueError): limit = None return jsonify(garmin_svc.start_backfill(g.user_id, limit)), 202 @bp.route("/sync-details", methods=["GET"]) @require_auth def sync_details_status(): return jsonify(garmin_svc.backfill_status(g.user_id))