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
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s

This commit is contained in:
2026-06-21 10:18:09 +01:00
parent e8615dd12d
commit 07139120df
12 changed files with 1169 additions and 116 deletions
+237 -107
View File
@@ -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."""