added activity view
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
@@ -15,6 +15,7 @@ import garth
|
||||
import time
|
||||
from garth.auth_tokens import OAuth1Token, OAuth2Token
|
||||
from ..services.fitbit_client import FitbitClient
|
||||
from fitbit import exceptions
|
||||
from ..models.weight_record import WeightRecord
|
||||
from ..models.config import Configuration
|
||||
from enum import Enum
|
||||
@@ -28,11 +29,21 @@ class SyncActivityRequest(BaseModel):
|
||||
class SyncMetricsRequest(BaseModel):
|
||||
days_back: int = 30
|
||||
|
||||
class UploadWeightRequest(BaseModel):
|
||||
limit: int = 50
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
job_id: Optional[str] = None
|
||||
|
||||
class WeightComparisonResponse(BaseModel):
|
||||
fitbit_total: int
|
||||
garmin_total: int
|
||||
missing_in_garmin: int
|
||||
missing_dates: List[str]
|
||||
message: str
|
||||
|
||||
class FitbitSyncScope(str, Enum):
|
||||
LAST_30_DAYS = "30d"
|
||||
ALL_HISTORY = "all"
|
||||
@@ -53,66 +64,27 @@ def get_db():
|
||||
with db_manager.get_db_session() as session:
|
||||
yield session
|
||||
|
||||
def _load_and_verify_garth_session(db: Session):
|
||||
"""Helper to load token from DB and verify session with Garmin."""
|
||||
logger.info("Loading and verifying Garmin session...")
|
||||
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
||||
if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token):
|
||||
raise HTTPException(status_code=401, detail="Garmin token not found.")
|
||||
|
||||
try:
|
||||
oauth1_dict = json.loads(token_record.garth_oauth1_token)
|
||||
oauth2_dict = json.loads(token_record.garth_oauth2_token)
|
||||
|
||||
domain = oauth1_dict.get('domain')
|
||||
if domain:
|
||||
garth.configure(domain=domain)
|
||||
|
||||
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
|
||||
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
|
||||
|
||||
garth.UserProfile.get()
|
||||
logger.info("Garth session verified.")
|
||||
except Exception as e:
|
||||
logger.error(f"Garth session verification failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=401, detail=f"Failed to authenticate with Garmin: {e}")
|
||||
|
||||
def run_activity_sync_task(job_id: str, days_back: int):
|
||||
logger.info(f"Starting background activity sync task {job_id}")
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
try:
|
||||
_load_and_verify_garth_session(session)
|
||||
garmin_client = GarminClient()
|
||||
sync_app = SyncApp(db_session=session, garmin_client=garmin_client)
|
||||
sync_app.sync_activities(days_back=days_back, job_id=job_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Background task failed: {e}")
|
||||
job_manager.update_job(job_id, status="failed", message=str(e))
|
||||
|
||||
def run_metrics_sync_task(job_id: str, days_back: int):
|
||||
logger.info(f"Starting background metrics sync task {job_id}")
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
with db_manager.get_db_session() as session:
|
||||
try:
|
||||
_load_and_verify_garth_session(session)
|
||||
garmin_client = GarminClient()
|
||||
sync_app = SyncApp(db_session=session, garmin_client=garmin_client)
|
||||
sync_app.sync_health_metrics(days_back=days_back, job_id=job_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Background task failed: {e}")
|
||||
job_manager.update_job(job_id, status="failed", message=str(e))
|
||||
from ..services.garth_helper import load_and_verify_garth_session
|
||||
from ..tasks.definitions import (
|
||||
run_activity_sync_task,
|
||||
run_metrics_sync_task,
|
||||
run_health_scan_job,
|
||||
run_fitbit_sync_job,
|
||||
run_garmin_upload_job,
|
||||
run_health_sync_job
|
||||
)
|
||||
|
||||
@router.post("/sync/activities", response_model=SyncResponse)
|
||||
def sync_activities(request: SyncActivityRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
# Verify auth first before starting task
|
||||
try:
|
||||
_load_and_verify_garth_session(db)
|
||||
load_and_verify_garth_session(db)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"Garmin auth failed: {str(e)}")
|
||||
|
||||
job_id = job_manager.create_job("Activity Sync")
|
||||
background_tasks.add_task(run_activity_sync_task, job_id, request.days_back)
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
background_tasks.add_task(run_activity_sync_task, job_id, request.days_back, db_manager.get_db_session)
|
||||
|
||||
return SyncResponse(
|
||||
status="started",
|
||||
@@ -123,12 +95,13 @@ def sync_activities(request: SyncActivityRequest, background_tasks: BackgroundTa
|
||||
@router.post("/sync/metrics", response_model=SyncResponse)
|
||||
def sync_metrics(request: SyncMetricsRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
try:
|
||||
_load_and_verify_garth_session(db)
|
||||
load_and_verify_garth_session(db)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"Garmin auth failed: {str(e)}")
|
||||
|
||||
job_id = job_manager.create_job("Health Metrics Sync")
|
||||
background_tasks.add_task(run_metrics_sync_task, job_id, request.days_back)
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
background_tasks.add_task(run_metrics_sync_task, job_id, request.days_back, db_manager.get_db_session)
|
||||
|
||||
return SyncResponse(
|
||||
status="started",
|
||||
@@ -136,6 +109,22 @@ def sync_metrics(request: SyncMetricsRequest, background_tasks: BackgroundTasks,
|
||||
job_id=job_id
|
||||
)
|
||||
|
||||
@router.post("/metrics/sync/scan", response_model=SyncResponse)
|
||||
async def scan_health_trigger(
|
||||
background_tasks: BackgroundTasks,
|
||||
days_back: int = Query(30, description="Number of days to scan back")
|
||||
):
|
||||
"""Trigger background scan of health gaps"""
|
||||
job_id = job_manager.create_job("scan_health_metrics")
|
||||
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
background_tasks.add_task(run_health_scan_job, job_id, days_back, db_manager.get_db_session)
|
||||
return SyncResponse(
|
||||
status="started",
|
||||
message="Health metrics scan started in background",
|
||||
job_id=job_id
|
||||
)
|
||||
|
||||
@router.post("/sync/fitbit/weight", response_model=SyncResponse)
|
||||
def sync_fitbit_weight(request: WeightSyncRequest, db: Session = Depends(get_db)):
|
||||
# Keep functionality for now, ideally also background
|
||||
@@ -161,13 +150,37 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session):
|
||||
raise HTTPException(status_code=400, detail="Fitbit credentials missing.")
|
||||
|
||||
# 2. Init Client
|
||||
# Define callback to save new token
|
||||
def refresh_cb(token_dict):
|
||||
logger.info("Fitbit token refreshed via callback")
|
||||
try:
|
||||
# Re-query to avoid stale object errors if session closed?
|
||||
# We have 'db' session from argument.
|
||||
# We can use it.
|
||||
# Convert token_dict to model fields
|
||||
# The token_dict from fitbit library usually has access_token, refresh_token, expires_in/at
|
||||
|
||||
# token is the APIToken object from line 197. Use it if attached, or query.
|
||||
# It's better to query by ID or token_type again to be safe?
|
||||
# Or just use the 'token' variable if it's still attached to session.
|
||||
token.access_token = token_dict.get('access_token')
|
||||
token.refresh_token = token_dict.get('refresh_token')
|
||||
token.expires_at = datetime.fromtimestamp(token_dict.get('expires_at')) if token_dict.get('expires_at') else None
|
||||
# scopes?
|
||||
|
||||
db.commit()
|
||||
logger.info("New Fitbit token saved to DB")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save refreshed token: {e}")
|
||||
|
||||
try:
|
||||
fitbit_client = FitbitClient(
|
||||
config_entry.fitbit_client_id,
|
||||
config_entry.fitbit_client_secret,
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
redirect_uri=config_entry.fitbit_redirect_uri
|
||||
redirect_uri=config_entry.fitbit_redirect_uri,
|
||||
refresh_cb=refresh_cb
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Fitbit client: {e}")
|
||||
@@ -245,6 +258,7 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session):
|
||||
# Structure: {'bmi': 23.5, 'date': '2023-01-01', 'logId': 12345, 'time': '23:59:59', 'weight': 70.5, 'source': 'API'}
|
||||
fitbit_id = str(log.get('logId'))
|
||||
weight_val = log.get('weight')
|
||||
bmi_val = log.get('bmi')
|
||||
date_str = log.get('date')
|
||||
time_str = log.get('time')
|
||||
|
||||
@@ -252,11 +266,15 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session):
|
||||
dt_str = f"{date_str} {time_str}"
|
||||
timestamp = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Check exist
|
||||
# Check exist
|
||||
existing = db.query(WeightRecord).filter_by(fitbit_id=fitbit_id).first()
|
||||
if existing:
|
||||
if abs(existing.weight - weight_val) > 0.01: # Check for update
|
||||
# Check for update (weight changed or BMI missing)
|
||||
if abs(existing.weight - weight_val) > 0.01 or existing.bmi is None:
|
||||
existing.weight = weight_val
|
||||
existing.bmi = bmi_val
|
||||
existing.unit = 'kg' # Force unit update too
|
||||
existing.date = timestamp
|
||||
existing.timestamp = timestamp
|
||||
existing.sync_status = 'unsynced' # Mark for Garmin sync if we implement that direction
|
||||
@@ -265,6 +283,7 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session):
|
||||
new_record = WeightRecord(
|
||||
fitbit_id=fitbit_id,
|
||||
weight=weight_val,
|
||||
bmi=bmi_val,
|
||||
unit='kg',
|
||||
date=timestamp,
|
||||
timestamp=timestamp,
|
||||
@@ -291,11 +310,7 @@ def sync_fitbit_weight_impl(request: WeightSyncRequest, db: Session):
|
||||
job_id=f"fitbit-weight-sync-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
)
|
||||
|
||||
class WeightComparisonResponse(BaseModel):
|
||||
fitbit_total: int
|
||||
garmin_total: int
|
||||
missing_in_garmin: int
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/sync/compare-weight", response_model=WeightComparisonResponse)
|
||||
def compare_weight_records(db: Session = Depends(get_db)):
|
||||
@@ -318,15 +333,24 @@ def compare_weight_records(db: Session = Depends(get_db)):
|
||||
garmin_date_set = {d[0].date() for d in garmin_dates if d[0]}
|
||||
|
||||
# 3. Compare
|
||||
missing_dates = fitbit_date_set - garmin_date_set
|
||||
missing_dates_set = fitbit_date_set - garmin_date_set
|
||||
missing_dates_list = sorted([d.isoformat() for d in missing_dates_set], reverse=True)
|
||||
|
||||
return WeightComparisonResponse(
|
||||
fitbit_total=len(fitbit_date_set),
|
||||
garmin_total=len(garmin_date_set),
|
||||
missing_in_garmin=len(missing_dates),
|
||||
message=f"Comparison Complete. Fitbit has {len(fitbit_date_set)} unique days, Garmin has {len(garmin_date_set)}. {len(missing_dates)} days from Fitbit are missing in Garmin."
|
||||
missing_in_garmin=len(missing_dates_set),
|
||||
missing_dates=missing_dates_list,
|
||||
message=f"Comparison Complete. Fitbit has {len(fitbit_date_set)} unique days, Garmin has {len(garmin_date_set)}. {len(missing_dates_set)} days from Fitbit are missing in Garmin."
|
||||
)
|
||||
|
||||
limit = request.limit
|
||||
job_id = job_manager.create_job("garmin_weight_upload")
|
||||
|
||||
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
||||
background_tasks.add_task(run_garmin_upload_job, job_id, limit, db_manager.get_db_session)
|
||||
return {"job_id": job_id, "status": "started"}
|
||||
|
||||
@router.get("/jobs/active", response_model=List[JobStatusResponse])
|
||||
def get_active_jobs():
|
||||
return job_manager.get_active_jobs()
|
||||
@@ -336,3 +360,11 @@ def stop_job(job_id: str):
|
||||
if job_manager.request_cancel(job_id):
|
||||
return {"status": "cancelled", "message": f"Cancellation requested for job {job_id}"}
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=JobStatusResponse)
|
||||
def get_job_status(job_id: str):
|
||||
"""Get status of a specific job."""
|
||||
job = job_manager.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
Reference in New Issue
Block a user