diff --git a/app/api/routes/fitbit.py b/app/api/routes/fitbit.py index 22379a4..6020d5f 100644 --- a/app/api/routes/fitbit.py +++ b/app/api/routes/fitbit.py @@ -10,61 +10,14 @@ from typing import Optional from app.database import get_db, FitbitConfig, WeightLog from main import templates +from app.services.fitbit_service import get_config, refresh_tokens, sync_fitbit_weight from urllib.parse import quote router = APIRouter() # --- Helpers --- - -def get_config(db: Session) -> FitbitConfig: - config = db.query(FitbitConfig).first() - if not config: - config = FitbitConfig() - db.add(config) - db.commit() - db.refresh(config) - return config - -def refresh_tokens(db: Session, config: FitbitConfig): - if not config.refresh_token: - return None - - token_url = "https://api.fitbit.com/oauth2/token" - auth_str = f"{config.client_id}:{config.client_secret}" - b64_auth = base64.b64encode(auth_str.encode()).decode() - - headers = { - "Authorization": f"Basic {b64_auth}", - "Content-Type": "application/x-www-form-urlencoded" - } - - data = { - "grant_type": "refresh_token", - "refresh_token": config.refresh_token - } - - try: - response = requests.post(token_url, headers=headers, data=data) - if response.status_code == 200: - tokens = response.json() - config.access_token = tokens['access_token'] - config.refresh_token = tokens['refresh_token'] - # config.expires_at = datetime.datetime.now().timestamp() + tokens['expires_in'] # Optional - db.commit() - return config.access_token - else: - print(f"Failed to refresh token: {response.text}") - return None - except Exception as e: - print(f"Error refreshing token: {e}") - return None - -def get_valid_access_token(db: Session, config: FitbitConfig): - # Simply try to refresh if we suspect it's old (or just always return current and handle 401 caller side) - # For now, return current, caller handles 401 by calling refresh - return config.access_token - +# Moved to app.services.fitbit_service # --- Routes --- @@ -185,99 +138,7 @@ async def sync_data( scope: str = Form("30d"), db: Session = Depends(get_db) ): - config = get_config(db) - if not config.access_token: - return JSONResponse({"status": "error", "message": "Not connected"}, status_code=400) - - # Helper to fetch with token refresh support - def fetch_weights_range(start_date: date, end_date: date, token: str): - url = f"https://api.fitbit.com/1/user/-/body/log/weight/date/{start_date}/{end_date}.json" - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json" - } - return requests.get(url, headers=headers) + result = sync_fitbit_weight(db, scope) + status_code = 200 if result['status'] == 'success' else 400 + return JSONResponse(result, status_code=status_code) - # Determine ranges - ranges = [] - today = datetime.date.today() - - if scope == "all": - # Start from a reasonable past date, e.g., 2015-01-01 - current_start = datetime.date(2015, 1, 1) - while current_start <= today: - current_end = current_start + datetime.timedelta(days=30) - if current_end > today: - current_end = today - ranges.append((current_start, current_end)) - current_start = current_end + datetime.timedelta(days=1) - else: - # Default 30 days - start = today - datetime.timedelta(days=30) - ranges.append((start, today)) - - total_new = 0 - errors = [] - - # Iterate ranges - # We need to manage token state outside the loop to avoid re-refreshing constantly if it fails - current_token = config.access_token - - print(f"DEBUG: Starting sync for scope={scope} with {len(ranges)} ranges.") - - for start, end in ranges: - print(f"DEBUG: Fetching range {start} to {end}...") - resp = fetch_weights_range(start, end, current_token) - - print(f"DEBUG: Response status: {resp.status_code}") - - # Handle 401 (Refresh) - if resp.status_code == 401: - print(f"Token expired during sync of {start}-{end}, refreshing...") - new_token = refresh_tokens(db, config) - if new_token: - current_token = new_token - resp = fetch_weights_range(start, end, current_token) - print(f"DEBUG: Retried request status: {resp.status_code}") - else: - errors.append("Token expired and refresh failed.") - break - - # Handle 429 (Rate Limit) - Basic handling: stop - if resp.status_code == 429: - errors.append("Rate limit exceeded.") - print("DEBUG: Rate limit exceeded.") - break - - if resp.status_code == 200: - data = resp.json() - weights = data.get('weight', []) - print(f"DEBUG: Found {len(weights)} weights in this range.") - for w in weights: - log_id = str(w.get('logId')) - weight_val = float(w.get('weight')) - date_str = w.get('date') - - existing = db.query(WeightLog).filter(WeightLog.fitbit_log_id == log_id).first() - if not existing: - log = WeightLog( - date=datetime.date.fromisoformat(date_str), - weight=weight_val, - fitbit_log_id=log_id, - source='fitbit' - ) - db.add(log) - total_new += 1 - else: - existing.weight = weight_val - db.commit() - else: - print(f"DEBUG: Error response: {resp.text}") - errors.append(f"Error {resp.status_code} for range {start}-{end}: {resp.text}") - - print(f"DEBUG: Sync complete. Total new: {total_new}. Errors: {errors}") - - if errors: - return JSONResponse({"status": "warning", "message": f"Synced {total_new} records, but encountered errors: {', '.join(errors[:3])}..."}) - else: - return JSONResponse({"status": "success", "message": f"Synced {total_new} new records (" + ("All History" if scope == 'all' else "30d") + ")"}) diff --git a/app/services/fitbit_service.py b/app/services/fitbit_service.py new file mode 100644 index 0000000..0bd5961 --- /dev/null +++ b/app/services/fitbit_service.py @@ -0,0 +1,151 @@ +import requests +import base64 +import datetime +from datetime import date +from sqlalchemy.orm import Session +from app.database import FitbitConfig, WeightLog + +def get_config(db: Session) -> FitbitConfig: + config = db.query(FitbitConfig).first() + if not config: + config = FitbitConfig() + db.add(config) + db.commit() + db.refresh(config) + return config + +def refresh_tokens(db: Session, config: FitbitConfig): + if not config.refresh_token: + return None + + token_url = "https://api.fitbit.com/oauth2/token" + auth_str = f"{config.client_id}:{config.client_secret}" + b64_auth = base64.b64encode(auth_str.encode()).decode() + + headers = { + "Authorization": f"Basic {b64_auth}", + "Content-Type": "application/x-www-form-urlencoded" + } + + data = { + "grant_type": "refresh_token", + "refresh_token": config.refresh_token + } + + try: + response = requests.post(token_url, headers=headers, data=data) + if response.status_code == 200: + tokens = response.json() + config.access_token = tokens['access_token'] + config.refresh_token = tokens['refresh_token'] + # config.expires_at = datetime.datetime.now().timestamp() + tokens['expires_in'] # Optional + db.commit() + return config.access_token + else: + print(f"Failed to refresh token: {response.text}") + return None + except Exception as e: + print(f"Error refreshing token: {e}") + return None + +def sync_fitbit_weight(db: Session, scope: str = "30d"): + """ + Synchronizes weight data from Fitbit. + Returns a dictionary with status and message. + """ + config = get_config(db) + if not config.access_token: + return {"status": "error", "message": "Not connected"} + + # Helper to fetch with token refresh support + def fetch_weights_range(start_date: date, end_date: date, token: str): + url = f"https://api.fitbit.com/1/user/-/body/log/weight/date/{start_date}/{end_date}.json" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json" + } + return requests.get(url, headers=headers) + + # Determine ranges + ranges = [] + today = datetime.date.today() + + if scope == "all": + # Start from a reasonable past date, e.g., 2015-01-01 + current_start = datetime.date(2015, 1, 1) + while current_start <= today: + current_end = current_start + datetime.timedelta(days=30) + if current_end > today: + current_end = today + ranges.append((current_start, current_end)) + current_start = current_end + datetime.timedelta(days=1) + else: + # Default 30 days + start = today - datetime.timedelta(days=30) + ranges.append((start, today)) + + total_new = 0 + errors = [] + + # Iterate ranges + # We need to manage token state outside the loop to avoid re-refreshing constantly if it fails + current_token = config.access_token + + print(f"DEBUG: Starting sync for scope={scope} with {len(ranges)} ranges.") + + for start, end in ranges: + print(f"DEBUG: Fetching range {start} to {end}...") + resp = fetch_weights_range(start, end, current_token) + + print(f"DEBUG: Response status: {resp.status_code}") + + # Handle 401 (Refresh) + if resp.status_code == 401: + print(f"Token expired during sync of {start}-{end}, refreshing...") + new_token = refresh_tokens(db, config) + if new_token: + current_token = new_token + resp = fetch_weights_range(start, end, current_token) + print(f"DEBUG: Retried request status: {resp.status_code}") + else: + errors.append("Token expired and refresh failed.") + break + + # Handle 429 (Rate Limit) - Basic handling: stop + if resp.status_code == 429: + errors.append("Rate limit exceeded.") + print("DEBUG: Rate limit exceeded.") + break + + if resp.status_code == 200: + data = resp.json() + weights = data.get('weight', []) + print(f"DEBUG: Found {len(weights)} weights in this range.") + for w in weights: + log_id = str(w.get('logId')) + weight_val = float(w.get('weight')) + date_str = w.get('date') + + existing = db.query(WeightLog).filter(WeightLog.fitbit_log_id == log_id).first() + if not existing: + log = WeightLog( + date=datetime.date.fromisoformat(date_str), + weight=weight_val, + fitbit_log_id=log_id, + source='fitbit' + ) + db.add(log) + total_new += 1 + else: + existing.weight = weight_val + db.commit() + else: + print(f"DEBUG: Error response: {resp.text}") + errors.append(f"Error {resp.status_code} for range {start}-{end}: {resp.text}") + + print(f"DEBUG: Sync complete. Total new: {total_new}. Errors: {errors}") + + if errors: + return {"status": "warning", "message": f"Synced {total_new} records, but encountered errors: {', '.join(errors[:3])}..."} + else: + return {"status": "success", "message": f"Synced {total_new} new records (" + ("All History" if scope == 'all' else "30d") + ")"} diff --git a/docker-compose.yml b/docker-compose.yml index 0bd2216..ad1d79b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: - "8999:8999" environment: #- DATABASE_URL=sqlite:////app/data/meal_planner.db - - DATABASE_URL=postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner + - DATABASE_URL=postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner_dev - PYTHONUNBUFFERED=1 volumes: - ./alembic:/app/alembic diff --git a/main.py b/main.py index 9218e9e..f3b2b1e 100644 --- a/main.py +++ b/main.py @@ -46,6 +46,7 @@ async def lifespan(app: FastAPI): # Schedule the backup job - temporarily disabled for debugging scheduler = BackgroundScheduler() scheduler.add_job(scheduled_backup, 'cron', hour=0) + scheduler.add_job(scheduled_fitbit_sync, 'interval', hours=1) scheduler.start() logging.info("Scheduled backup job started.") yield @@ -152,6 +153,19 @@ def scheduled_backup(): backup_path = os.path.join(backup_dir, f"meal_planner_{timestamp}.db") backup_database(db_path, backup_path) +def scheduled_fitbit_sync(): + """Sync Fitbit weight data.""" + logging.info("DEBUG: Starting scheduled Fitbit sync...") + db = SessionLocal() + from app.services.fitbit_service import sync_fitbit_weight + try: + result = sync_fitbit_weight(db, scope="30d") + logging.info(f"Scheduled Fitbit result: {result}") + except Exception as e: + logging.error(f"Scheduled Fitbit sync failed: {e}") + finally: + db.close() + def test_sqlite_connection(db_path): """Test if we can create and write to SQLite database file""" diff --git a/meal_planner_2026-01-13_00-29-12.db:Zone.Identifier b/meal_planner_2026-01-13_00-29-12.db:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/meal_planner_2026-01-13_00-29-12.db:Zone.Identifier differ diff --git a/templates/tracker.html b/templates/tracker.html index 0a8f785..831788a 100644 --- a/templates/tracker.html +++ b/templates/tracker.html @@ -312,6 +312,7 @@