added activity view

This commit is contained in:
2026-01-09 09:59:36 -08:00
parent c45e41b6a9
commit 55e37fbca8
168 changed files with 8799 additions and 2426 deletions

View File

@@ -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