mirror of
https://github.com/sstent/foodplanner.git
synced 2026-04-05 12:35:12 +00:00
adding fitbit shecdule + fixing macro balance chart
This commit is contained in:
@@ -10,61 +10,14 @@ from typing import Optional
|
|||||||
|
|
||||||
from app.database import get_db, FitbitConfig, WeightLog
|
from app.database import get_db, FitbitConfig, WeightLog
|
||||||
from main import templates
|
from main import templates
|
||||||
|
from app.services.fitbit_service import get_config, refresh_tokens, sync_fitbit_weight
|
||||||
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
# Moved to app.services.fitbit_service
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# --- Routes ---
|
# --- Routes ---
|
||||||
|
|
||||||
@@ -185,99 +138,7 @@ async def sync_data(
|
|||||||
scope: str = Form("30d"),
|
scope: str = Form("30d"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
config = get_config(db)
|
result = sync_fitbit_weight(db, scope)
|
||||||
if not config.access_token:
|
status_code = 200 if result['status'] == 'success' else 400
|
||||||
return JSONResponse({"status": "error", "message": "Not connected"}, status_code=400)
|
return JSONResponse(result, status_code=status_code)
|
||||||
|
|
||||||
# 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 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") + ")"})
|
|
||||||
|
|||||||
151
app/services/fitbit_service.py
Normal file
151
app/services/fitbit_service.py
Normal file
@@ -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") + ")"}
|
||||||
@@ -5,7 +5,7 @@ services:
|
|||||||
- "8999:8999"
|
- "8999:8999"
|
||||||
environment:
|
environment:
|
||||||
#- DATABASE_URL=sqlite:////app/data/meal_planner.db
|
#- 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
|
- PYTHONUNBUFFERED=1
|
||||||
volumes:
|
volumes:
|
||||||
- ./alembic:/app/alembic
|
- ./alembic:/app/alembic
|
||||||
|
|||||||
14
main.py
14
main.py
@@ -46,6 +46,7 @@ async def lifespan(app: FastAPI):
|
|||||||
# Schedule the backup job - temporarily disabled for debugging
|
# Schedule the backup job - temporarily disabled for debugging
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
scheduler.add_job(scheduled_backup, 'cron', hour=0)
|
scheduler.add_job(scheduled_backup, 'cron', hour=0)
|
||||||
|
scheduler.add_job(scheduled_fitbit_sync, 'interval', hours=1)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logging.info("Scheduled backup job started.")
|
logging.info("Scheduled backup job started.")
|
||||||
yield
|
yield
|
||||||
@@ -152,6 +153,19 @@ def scheduled_backup():
|
|||||||
backup_path = os.path.join(backup_dir, f"meal_planner_{timestamp}.db")
|
backup_path = os.path.join(backup_dir, f"meal_planner_{timestamp}.db")
|
||||||
backup_database(db_path, backup_path)
|
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):
|
def test_sqlite_connection(db_path):
|
||||||
"""Test if we can create and write to SQLite database file"""
|
"""Test if we can create and write to SQLite database file"""
|
||||||
|
|||||||
BIN
meal_planner_2026-01-13_00-29-12.db:Zone.Identifier
Normal file
BIN
meal_planner_2026-01-13_00-29-12.db:Zone.Identifier
Normal file
Binary file not shown.
@@ -312,6 +312,7 @@
|
|||||||
<h5 class="mb-0">Macro Balance</h5>
|
<h5 class="mb-0">Macro Balance</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
{% if day_totals.calories > 0 %}
|
||||||
<!-- Labels Row -->
|
<!-- Labels Row -->
|
||||||
<div class="d-flex w-100 mb-1 small fw-bold">
|
<div class="d-flex w-100 mb-1 small fw-bold">
|
||||||
<div class="text-center" style="width: {{ day_totals.carbs_pct }}%; color: #0d6efd;">Carbs</div>
|
<div class="text-center" style="width: {{ day_totals.carbs_pct }}%; color: #0d6efd;">Carbs</div>
|
||||||
@@ -340,6 +341,11 @@
|
|||||||
<div class="text-center" style="width: {{ day_totals.protein_pct }}%;">{{ day_totals.protein_pct
|
<div class="text-center" style="width: {{ day_totals.protein_pct }}%;">{{ day_totals.protein_pct
|
||||||
}}%</div>
|
}}%</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted my-3">
|
||||||
|
<small>No meals tracked yet</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user