feat: Strava support — bulk-export .gz/.tcx import fix + .tcx parser; full Strava API OAuth live sync (activities via streams) with Profile connect UI; colour gym/no-GPS sports red
This commit is contained in:
+237
-107
@@ -28,6 +28,12 @@ celery_app.conf.update(
|
||||
# Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
|
||||
"schedule": float(settings.garmin_sync_interval_minutes * 60),
|
||||
},
|
||||
"sync-strava": {
|
||||
"task": "sync_all_strava",
|
||||
# Shares the Garmin cadence setting; Strava's rate limits make a
|
||||
# tighter interval unwise anyway.
|
||||
"schedule": float(settings.garmin_sync_interval_minutes * 60),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -94,7 +100,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
parse_wellness_fit.delay(file_path, user_id)
|
||||
return {"status": "routed_to_wellness", "file": file_path}
|
||||
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, parse_tcx_file, calculate_hr_zones
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||
from sqlalchemy import select, func
|
||||
@@ -105,6 +111,8 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
try:
|
||||
if source_type == "fit" or file_path.endswith(".fit"):
|
||||
parsed = parse_fit_file(file_path)
|
||||
elif source_type == "tcx" or file_path.endswith(".tcx"):
|
||||
parsed = parse_tcx_file(file_path)
|
||||
else:
|
||||
parsed = parse_gpx_file(file_path)
|
||||
except Exception as e:
|
||||
@@ -122,123 +130,155 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
return persist_activity(
|
||||
db, user_id, parsed,
|
||||
source_file=file_path,
|
||||
garmin_activity_id=garmin_activity_id,
|
||||
)
|
||||
|
||||
# Deduplicate: same user + sport_type + start_time within ±60s
|
||||
from datetime import timedelta
|
||||
|
||||
def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
|
||||
garmin_activity_id: str = None, strava_activity_id: str = None) -> dict:
|
||||
"""Insert a parsed activity (+ data points, laps) and dispatch the
|
||||
PR/route/segment follow-up tasks. Shared by the file-upload path
|
||||
(process_activity_file) and the Strava API sync, so both get identical
|
||||
dedup, HR-zone and downstream behaviour. `parsed` is the fit_parser-shaped
|
||||
dict. Returns {"activity_id", "status"}."""
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap, User as UserModel
|
||||
from app.services.fit_parser import calculate_hr_zones
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
|
||||
# Fast-path dedup for re-syncs: same external id already imported.
|
||||
if strava_activity_id:
|
||||
existing = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
Activity.sport_type == parsed["sport_type"],
|
||||
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
select(Activity).where(Activity.strava_activity_id == str(strava_activity_id))
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
# Stamp garmin_activity_id if this came from a Garmin Connect sync
|
||||
# so future syncs skip the fast-path dedup and don't re-download.
|
||||
if garmin_activity_id and not existing.garmin_activity_id:
|
||||
existing.garmin_activity_id = garmin_activity_id
|
||||
db.commit()
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Get user max HR for zone calculation
|
||||
from app.models.user import User as UserModel
|
||||
user_obj = db.execute(
|
||||
select(UserModel).where(UserModel.id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
user_max_hr = None
|
||||
if user_obj:
|
||||
user_max_hr = user_obj.max_heart_rate
|
||||
if not user_max_hr and user_obj.birth_year:
|
||||
from datetime import date as _date
|
||||
age = _date.today().year - user_obj.birth_year
|
||||
user_max_hr = 220 - age
|
||||
if not user_max_hr:
|
||||
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||
|
||||
hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr)
|
||||
|
||||
activity = Activity(
|
||||
user_id=user_id,
|
||||
name=parsed["name"],
|
||||
sport_type=parsed["sport_type"],
|
||||
garmin_activity_id=garmin_activity_id,
|
||||
start_time=start_time,
|
||||
distance_m=parsed.get("distance_m"),
|
||||
duration_s=parsed.get("duration_s"),
|
||||
moving_time_s=parsed.get("moving_time_s"),
|
||||
elevation_gain_m=parsed.get("elevation_gain_m"),
|
||||
elevation_loss_m=parsed.get("elevation_loss_m"),
|
||||
avg_heart_rate=parsed.get("avg_heart_rate"),
|
||||
max_heart_rate=parsed.get("max_heart_rate"),
|
||||
avg_cadence=parsed.get("avg_cadence"),
|
||||
avg_power=parsed.get("avg_power"),
|
||||
normalized_power=parsed.get("normalized_power"),
|
||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||
max_speed_ms=parsed.get("max_speed_ms"),
|
||||
active_spans=parsed.get("active_spans"),
|
||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||
calories=parsed.get("calories"),
|
||||
training_stress_score=parsed.get("training_stress_score"),
|
||||
polyline=parsed.get("polyline"),
|
||||
bounding_box=parsed.get("bounding_box"),
|
||||
source_file=file_path,
|
||||
source_type=parsed.get("source_type"),
|
||||
hr_zones=hr_zones,
|
||||
# Deduplicate across sources: same user + sport_type + start_time within ±60s.
|
||||
# This also collapses an activity synced from both Garmin and Strava into one.
|
||||
existing = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
Activity.sport_type == parsed["sport_type"],
|
||||
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||
)
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
).scalars().first()
|
||||
|
||||
seen = set()
|
||||
batch = []
|
||||
for p in parsed.get("data_points", []):
|
||||
if not p.get("timestamp"):
|
||||
continue
|
||||
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
||||
key = (activity.id, ts)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
batch.append(ActivityDataPoint(
|
||||
activity_id=activity.id,
|
||||
timestamp=ts,
|
||||
latitude=p.get("latitude"),
|
||||
longitude=p.get("longitude"),
|
||||
altitude_m=p.get("altitude_m"),
|
||||
heart_rate=p.get("heart_rate"),
|
||||
cadence=p.get("cadence"),
|
||||
speed_ms=p.get("speed_ms"),
|
||||
power=p.get("power"),
|
||||
temperature_c=p.get("temperature_c"),
|
||||
distance_m=p.get("distance_m"),
|
||||
))
|
||||
if len(batch) >= 500:
|
||||
db.add_all(batch)
|
||||
db.flush()
|
||||
batch = []
|
||||
if batch:
|
||||
if existing:
|
||||
# Stamp the external id so future syncs skip straight to the fast path.
|
||||
stamped = False
|
||||
if garmin_activity_id and not existing.garmin_activity_id:
|
||||
existing.garmin_activity_id = garmin_activity_id
|
||||
stamped = True
|
||||
if strava_activity_id and not existing.strava_activity_id:
|
||||
existing.strava_activity_id = str(strava_activity_id)
|
||||
stamped = True
|
||||
if stamped:
|
||||
db.commit()
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Get user max HR for zone calculation
|
||||
user_obj = db.execute(
|
||||
select(UserModel).where(UserModel.id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
user_max_hr = None
|
||||
if user_obj:
|
||||
user_max_hr = user_obj.max_heart_rate
|
||||
if not user_max_hr and user_obj.birth_year:
|
||||
from datetime import date as _date
|
||||
age = _date.today().year - user_obj.birth_year
|
||||
user_max_hr = 220 - age
|
||||
if not user_max_hr:
|
||||
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||
|
||||
hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr)
|
||||
|
||||
activity = Activity(
|
||||
user_id=user_id,
|
||||
name=parsed["name"],
|
||||
sport_type=parsed["sport_type"],
|
||||
garmin_activity_id=garmin_activity_id,
|
||||
strava_activity_id=str(strava_activity_id) if strava_activity_id else None,
|
||||
start_time=start_time,
|
||||
distance_m=parsed.get("distance_m"),
|
||||
duration_s=parsed.get("duration_s"),
|
||||
moving_time_s=parsed.get("moving_time_s"),
|
||||
elevation_gain_m=parsed.get("elevation_gain_m"),
|
||||
elevation_loss_m=parsed.get("elevation_loss_m"),
|
||||
avg_heart_rate=parsed.get("avg_heart_rate"),
|
||||
max_heart_rate=parsed.get("max_heart_rate"),
|
||||
avg_cadence=parsed.get("avg_cadence"),
|
||||
avg_power=parsed.get("avg_power"),
|
||||
normalized_power=parsed.get("normalized_power"),
|
||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||
max_speed_ms=parsed.get("max_speed_ms"),
|
||||
active_spans=parsed.get("active_spans"),
|
||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||
calories=parsed.get("calories"),
|
||||
training_stress_score=parsed.get("training_stress_score"),
|
||||
polyline=parsed.get("polyline"),
|
||||
bounding_box=parsed.get("bounding_box"),
|
||||
source_file=source_file,
|
||||
source_type=parsed.get("source_type"),
|
||||
hr_zones=hr_zones,
|
||||
)
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
|
||||
seen = set()
|
||||
batch = []
|
||||
for p in parsed.get("data_points", []):
|
||||
if not p.get("timestamp"):
|
||||
continue
|
||||
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
||||
key = (activity.id, ts)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
batch.append(ActivityDataPoint(
|
||||
activity_id=activity.id,
|
||||
timestamp=ts,
|
||||
latitude=p.get("latitude"),
|
||||
longitude=p.get("longitude"),
|
||||
altitude_m=p.get("altitude_m"),
|
||||
heart_rate=p.get("heart_rate"),
|
||||
cadence=p.get("cadence"),
|
||||
speed_ms=p.get("speed_ms"),
|
||||
power=p.get("power"),
|
||||
temperature_c=p.get("temperature_c"),
|
||||
distance_m=p.get("distance_m"),
|
||||
))
|
||||
if len(batch) >= 500:
|
||||
db.add_all(batch)
|
||||
db.flush()
|
||||
batch = []
|
||||
if batch:
|
||||
db.add_all(batch)
|
||||
db.flush()
|
||||
|
||||
for lap in parsed.get("laps", []):
|
||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||
db.add(ActivityLap(
|
||||
activity_id=activity.id,
|
||||
lap_number=lap["lap_number"],
|
||||
start_time=ls,
|
||||
duration_s=lap.get("duration_s"),
|
||||
distance_m=lap.get("distance_m"),
|
||||
avg_heart_rate=lap.get("avg_heart_rate"),
|
||||
avg_cadence=lap.get("avg_cadence"),
|
||||
avg_speed_ms=lap.get("avg_speed_ms"),
|
||||
avg_power=lap.get("avg_power"),
|
||||
))
|
||||
for lap in parsed.get("laps", []):
|
||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||
db.add(ActivityLap(
|
||||
activity_id=activity.id,
|
||||
lap_number=lap["lap_number"],
|
||||
start_time=ls,
|
||||
duration_s=lap.get("duration_s"),
|
||||
distance_m=lap.get("distance_m"),
|
||||
avg_heart_rate=lap.get("avg_heart_rate"),
|
||||
avg_cadence=lap.get("avg_cadence"),
|
||||
avg_speed_ms=lap.get("avg_speed_ms"),
|
||||
avg_power=lap.get("avg_power"),
|
||||
))
|
||||
|
||||
db.commit()
|
||||
activity_id = activity.id
|
||||
db.commit()
|
||||
activity_id = activity.id
|
||||
|
||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||
@@ -841,6 +881,96 @@ def sync_all_garmin_connect():
|
||||
return {"dispatched": len(user_ids)}
|
||||
|
||||
|
||||
@celery_app.task(name="sync_strava_user")
|
||||
def sync_strava_user(user_id: int):
|
||||
"""Sync activities from Strava for one user via the Strava API."""
|
||||
from app.services.strava_sync import sync_strava_activities
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import StravaConfig
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
cancel_key = f"strava_sync_cancel:{user_id}"
|
||||
try:
|
||||
import redis as redis_lib
|
||||
_redis = redis_lib.Redis.from_url(settings.redis_url)
|
||||
except Exception:
|
||||
_redis = None
|
||||
|
||||
def _cancelled():
|
||||
try:
|
||||
return bool(_redis and _redis.exists(cancel_key))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
cfg = db.execute(
|
||||
select(StravaConfig).where(StravaConfig.user_id == user_id)
|
||||
).scalar_one_or_none()
|
||||
if not cfg or not cfg.sync_enabled:
|
||||
return {"status": "skipped"}
|
||||
|
||||
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||
|
||||
cfg.last_sync_status = "Connecting to Strava..."
|
||||
db.commit()
|
||||
|
||||
def _set_status(text):
|
||||
if _cancelled():
|
||||
raise SyncCancelled()
|
||||
cfg.last_sync_status = text
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
imported = sync_strava_activities(
|
||||
cfg, user_id, db, lookback_days=lookback, status_callback=_set_status,
|
||||
)
|
||||
except SyncCancelled:
|
||||
db.rollback()
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = "Cancelled"
|
||||
db.commit()
|
||||
try:
|
||||
if _redis:
|
||||
_redis.delete(cancel_key)
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "cancelled"}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
msg = str(exc)
|
||||
cfg.last_sync_status = f"Auth error: {msg}" if "401" in msg or "auth" in msg.lower() else f"Error: {msg}"
|
||||
db.commit()
|
||||
return {"status": "error", "error": msg}
|
||||
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = f"OK — {imported} activities imported"
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "activities_imported": imported}
|
||||
|
||||
|
||||
@celery_app.task(name="sync_all_strava")
|
||||
def sync_all_strava():
|
||||
"""Beat task: dispatch a per-user Strava sync for all enabled configs."""
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import StravaConfig
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
configs = db.execute(
|
||||
select(StravaConfig).where(StravaConfig.sync_enabled == True)
|
||||
).scalars().all()
|
||||
user_ids = [c.user_id for c in configs]
|
||||
|
||||
for uid in user_ids:
|
||||
sync_strava_user.delay(uid)
|
||||
|
||||
return {"dispatched": len(user_ids)}
|
||||
|
||||
|
||||
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
||||
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
||||
|
||||
Reference in New Issue
Block a user