feat: Strava export dry-run preview (new vs duplicate counts + confirm import) and prefer existing Garmin data over Strava on dedup
Build and push images / validate (push) Successful in 2s
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 9s

This commit is contained in:
2026-06-21 10:41:54 +01:00
parent 07139120df
commit 69ecdaa4b2
3 changed files with 327 additions and 42 deletions
+96 -15
View File
@@ -90,11 +90,14 @@ def _apply_garmin_summary(parsed: dict, summary: dict):
@celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
garmin_activity_id: str = None, summary: dict = None):
garmin_activity_id: str = None, summary: dict = None,
prefer_existing: bool = False):
"""Parse a FIT/GPX file. Routes wellness files to health parser.
`summary` (optional, from Garmin Connect sync) carries Garmin's corrected
distance/moving/elapsed values which override the raw FIT figures."""
distance/moving/elapsed values which override the raw FIT figures.
`prefer_existing` (Strava-export imports) keeps an existing same-time
activity instead of adding a duplicate — Garmin data wins."""
if is_wellness_file(file_path):
parse_wellness_fit.delay(file_path, user_id)
@@ -134,20 +137,46 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
db, user_id, parsed,
source_file=file_path,
garmin_activity_id=garmin_activity_id,
prefer_existing=prefer_existing,
)
def _find_existing_activity(db, user_id: int, sport_type: str, start_time,
prefer_existing: bool = False):
"""Find an already-imported activity that matches `start_time` (±60s) for this
user. Normally also requires the same sport_type. When `prefer_existing` is
set (Strava-export imports), the sport_type filter is dropped so a Strava
file is treated as a duplicate of an existing activity — typically the Garmin
original — even when the two sources labelled the sport differently. This is
what makes Garmin data win over Strava re-imports."""
from app.models.user import Activity
from sqlalchemy import select
from datetime import timedelta
conds = [
Activity.user_id == user_id,
Activity.start_time >= start_time - timedelta(seconds=60),
Activity.start_time <= start_time + timedelta(seconds=60),
]
if not prefer_existing:
conds.append(Activity.sport_type == sport_type)
return db.execute(select(Activity).where(*conds)).scalars().first()
def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
garmin_activity_id: str = None, strava_activity_id: str = None) -> dict:
garmin_activity_id: str = None, strava_activity_id: str = None,
prefer_existing: bool = False) -> 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"}."""
dict. `prefer_existing` skips this activity if any existing one matches by
time alone (used by Strava imports to keep the Garmin original).
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
from datetime import datetime
start_time = datetime.fromisoformat(parsed["start_time"])
@@ -159,16 +188,11 @@ def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
if existing:
return {"activity_id": existing.id, "status": "duplicate"}
# 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),
)
).scalars().first()
# Deduplicate across sources (see _find_existing_activity). This collapses an
# activity present from both Garmin and Strava into the one already stored.
existing = _find_existing_activity(
db, user_id, parsed["sport_type"], start_time, prefer_existing=prefer_existing,
)
if existing:
# Stamp the external id so future syncs skip straight to the fast path.
@@ -287,6 +311,63 @@ def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None,
return {"activity_id": activity_id, "status": "ok"}
@celery_app.task(name="analyze_strava_export")
def analyze_strava_export(file_paths: list, user_id: int):
"""Dry-run for a Strava bulk export: parse each activity file and classify it
as new / duplicate / unreadable against existing activities, WITHOUT writing
anything. Uses the same Garmin-priority dedup the real import uses, so the
preview matches what the import would actually do."""
from app.services.fit_parser import parse_fit_file, parse_gpx_file, parse_tcx_file
from app.core.database import SyncSessionLocal
from datetime import datetime
new = duplicate = unreadable = 0
new_samples = []
dup_samples = []
with SyncSessionLocal() as db:
for fp in file_paths:
try:
low = fp.lower()
if low.endswith(".fit"):
parsed = parse_fit_file(fp)
elif low.endswith(".tcx"):
parsed = parse_tcx_file(fp)
else:
parsed = parse_gpx_file(fp)
start_str = parsed.get("start_time")
if not start_str:
unreadable += 1
continue
start_time = datetime.fromisoformat(start_str)
except Exception:
unreadable += 1
continue
existing = _find_existing_activity(
db, user_id, parsed.get("sport_type"), start_time, prefer_existing=True,
)
label = parsed.get("name") or start_str
if existing:
duplicate += 1
if len(dup_samples) < 8:
dup_samples.append(label)
else:
new += 1
if len(new_samples) < 8:
new_samples.append(label)
return {
"status": "ok",
"total": new + duplicate + unreadable,
"new": new,
"duplicate": duplicate,
"unreadable": unreadable,
"new_samples": new_samples,
"duplicate_samples": dup_samples,
}
@celery_app.task(name="parse_wellness_fit")
def parse_wellness_fit(file_path: str, user_id: int):
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""