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
+69 -19
View File
@@ -3,12 +3,13 @@ import zipfile
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.config import settings from app.core.config import settings
from app.models.user import User from app.models.user import User
from app.workers.tasks import process_activity_file, process_garmin_health_zip from app.workers.tasks import process_activity_file, process_garmin_health_zip, analyze_strava_export
router = APIRouter() router = APIRouter()
@@ -187,13 +188,39 @@ async def upload_garmin_export(
} }
def _collect_activity_files(extracted: list[Path]) -> list[Path]:
"""From extracted archive members, gunzip any .gz wrappers (Strava ships most
activities as <id>.fit.gz / .gpx.gz / .tcx.gz) and return the .fit/.gpx/.tcx
paths ready to parse. Without this, the gzipped majority is silently skipped."""
files: list[Path] = []
for path in extracted:
if path.suffix.lower() == ".gz":
inner = _gunzip(path)
if inner is None:
continue
path = inner
if path.suffix.lower() in (".fit", ".gpx", ".tcx"):
files.append(path)
return files
class StravaConfirmIn(BaseModel):
token: str
@router.post("/strava-export") @router.post("/strava-export")
async def upload_strava_export( async def upload_strava_export(
file: UploadFile = File(...), file: UploadFile = File(...),
dry_run: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files).""" """Upload a Strava bulk export ZIP (activities/ folder with GPX/FIT/TCX files).
With `dry_run=true` nothing is imported: the archive is extracted and a single
analysis task reports how many activities are new vs. already present (Garmin
data is preferred), returning a `token` to confirm the real import with.
Otherwise activities import straight away (existing data is kept on a match)."""
if not file.filename.endswith(".zip"): if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export") raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
@@ -202,7 +229,6 @@ async def upload_strava_export(
extract_dir = dest_dir / f"strava_{dest.stem}" extract_dir = dest_dir / f"strava_{dest.stem}"
task_ids = []
try: try:
with zipfile.ZipFile(dest) as zf: with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir) extracted = _safe_extract(zf, extract_dir)
@@ -210,27 +236,25 @@ async def upload_strava_export(
dest.unlink(missing_ok=True) dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive") raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
for path in extracted: files = _collect_activity_files(extracted)
# Strava compresses most exported activities as <id>.fit.gz / .gpx.gz / if not files:
# .tcx.gz. Decompress those to their underlying file first, otherwise the
# gzipped majority of the archive is silently skipped.
if path.suffix.lower() == ".gz":
inner = _gunzip(path)
if inner is None:
continue
path = inner
suffix = path.suffix.lower()
if suffix in (".fit", ".gpx", ".tcx"):
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task_ids.append(task.id)
if not task_ids:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive", detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
) )
if dry_run:
# Preview only — classify new/duplicate without writing. The extracted
# files are kept so the confirm step can import them without re-uploading.
task = analyze_strava_export.delay([str(p) for p in files], current_user.id)
return {"status": "analyzing", "task_id": task.id, "token": dest.stem,
"activity_files": len(files)}
task_ids = [
process_activity_file.delay(str(p), current_user.id, p.suffix.lower()[1:],
prefer_existing=True).id
for p in files
]
return { return {
"status": "queued", "status": "queued",
"activity_tasks": len(task_ids), "activity_tasks": len(task_ids),
@@ -238,6 +262,32 @@ async def upload_strava_export(
} }
@router.post("/strava-export/confirm")
async def confirm_strava_export(
body: StravaConfirmIn,
current_user: User = Depends(get_current_user),
):
"""Import the activities from a previously previewed (dry-run) Strava export.
`token` is the value returned by the dry-run upload; the already-extracted
files are imported with Garmin data preferred over Strava duplicates."""
token = _safe_name(body.token)
extract_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" / f"strava_{token}"
if not extract_dir.is_dir():
raise HTTPException(status_code=404, detail="Preview not found — please upload and preview again")
files = [p for p in extract_dir.rglob("*")
if p.is_file() and p.suffix.lower() in (".fit", ".gpx", ".tcx")]
if not files:
raise HTTPException(status_code=400, detail="No activity files found for this preview")
task_ids = [
process_activity_file.delay(str(p), current_user.id, p.suffix.lower()[1:],
prefer_existing=True).id
for p in files
]
return {"status": "queued", "activity_tasks": len(task_ids)}
@router.get("/task/{task_id}") @router.get("/task/{task_id}")
async def check_task_status( async def check_task_status(
task_id: str, task_id: str,
+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") @celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str, 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. """Parse a FIT/GPX file. Routes wellness files to health parser.
`summary` (optional, from Garmin Connect sync) carries Garmin's corrected `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): if is_wellness_file(file_path):
parse_wellness_fit.delay(file_path, user_id) 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, db, user_id, parsed,
source_file=file_path, source_file=file_path,
garmin_activity_id=garmin_activity_id, 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, 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 """Insert a parsed activity (+ data points, laps) and dispatch the
PR/route/segment follow-up tasks. Shared by the file-upload path PR/route/segment follow-up tasks. Shared by the file-upload path
(process_activity_file) and the Strava API sync, so both get identical (process_activity_file) and the Strava API sync, so both get identical
dedup, HR-zone and downstream behaviour. `parsed` is the fit_parser-shaped 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.models.user import Activity, ActivityDataPoint, ActivityLap, User as UserModel
from app.services.fit_parser import calculate_hr_zones from app.services.fit_parser import calculate_hr_zones
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timedelta from datetime import datetime
start_time = datetime.fromisoformat(parsed["start_time"]) 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: if existing:
return {"activity_id": existing.id, "status": "duplicate"} return {"activity_id": existing.id, "status": "duplicate"}
# Deduplicate across sources: same user + sport_type + start_time within ±60s. # Deduplicate across sources (see _find_existing_activity). This collapses an
# This also collapses an activity synced from both Garmin and Strava into one. # activity present from both Garmin and Strava into the one already stored.
existing = db.execute( existing = _find_existing_activity(
select(Activity).where( db, user_id, parsed["sport_type"], start_time, prefer_existing=prefer_existing,
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()
if existing: if existing:
# Stamp the external id so future syncs skip straight to the fast path. # 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"} 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") @celery_app.task(name="parse_wellness_fit")
def parse_wellness_fit(file_path: str, user_id: int): def parse_wellness_fit(file_path: str, user_id: int):
"""Parse a Garmin wellness FIT file and upsert into health_metrics.""" """Parse a Garmin wellness FIT file and upsert into health_metrics."""
+162 -8
View File
@@ -140,6 +140,166 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
) )
} }
// Strava bulk export with a dry-run preview: upload → analyse (new vs. already
// present, Garmin preferred) → confirm import of just the new ones. Avoids
// re-uploading by importing the files the preview already extracted (via token).
function StravaExportZone() {
const qc = useQueryClient()
const [phase, setPhase] = useState('idle') // idle|uploading|analyzing|report|importing|done|error
const [report, setReport] = useState(null)
const [token, setToken] = useState(null)
const [error, setError] = useState('')
const pollRef = useRef(null)
const reset = () => { setReport(null); setToken(null); setError('') }
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const pollAnalyze = useCallback((taskId) => {
pollRef.current = setInterval(async () => {
try {
const { data } = await api.get(`/upload/task/${taskId}`)
if (data.status === 'SUCCESS') {
clearInterval(pollRef.current)
setReport(data.result)
setPhase('report')
} else if (data.status === 'FAILURE') {
clearInterval(pollRef.current)
setError('Analysis failed — please try again')
setPhase('error')
}
} catch { /* ignore transient poll errors */ }
}, 2000)
}, [])
const onDrop = useCallback(async (accepted) => {
const file = accepted[0]
if (!file) return
reset()
setPhase('uploading')
try {
const form = new FormData()
form.append('file', file)
const { data } = await api.post('/upload/strava-export?dry_run=true', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
setToken(data.token)
setPhase('analyzing')
pollAnalyze(data.task_id)
} catch (e) {
setError(e.response?.data?.detail || 'Upload failed')
setPhase('error')
}
}, [pollAnalyze])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, accept: { 'application/zip': ['.zip'] }, multiple: false,
})
const doImport = async () => {
setPhase('importing')
try {
await api.post('/upload/strava-export/confirm', { token })
setPhase('done')
qc.invalidateQueries({ queryKey: ['activities'] })
} catch (e) {
setError(e.response?.data?.detail || 'Import failed')
setPhase('error')
}
}
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">🚴</span>
<div>
<h3 className="font-semibold text-white">Strava bulk export</h3>
<p className="text-xs text-gray-500">Preview duplicates before importing Garmin data is kept on a match</p>
</div>
</div>
{(phase === 'idle' || phase === 'uploading') && (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragActive ? 'border-blue-500 bg-blue-950/30' : 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
}`}
>
<input {...getInputProps()} />
{phase === 'uploading'
? <p className="text-blue-400 text-sm animate-pulse">Uploading</p>
: <div>
<p className="text-gray-400 text-sm">Drag & drop your Strava archive .zip, or click to browse</p>
<p className="text-gray-600 text-xs mt-1">Well scan it and show whats new before importing</p>
</div>}
</div>
)}
{phase === 'analyzing' && (
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Scanning archive for duplicates</p>
)}
{phase === 'report' && report && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-green-950/30 border border-green-900/40 rounded-lg py-2">
<div className="text-xl font-bold text-green-400">{report.new}</div>
<div className="text-xs text-gray-500">new</div>
</div>
<div className="bg-gray-800 border border-gray-700 rounded-lg py-2">
<div className="text-xl font-bold text-gray-300">{report.duplicate}</div>
<div className="text-xs text-gray-500">already have</div>
</div>
<div className="bg-amber-950/20 border border-amber-900/40 rounded-lg py-2">
<div className="text-xl font-bold text-amber-400">{report.unreadable}</div>
<div className="text-xs text-gray-500">unreadable</div>
</div>
</div>
{report.new_samples?.length > 0 && (
<p className="text-xs text-gray-500">
New e.g.: <span className="text-gray-400">{report.new_samples.slice(0, 4).join(' · ')}</span>
{report.new > 4 ? ' …' : ''}
</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={doImport}
disabled={report.new === 0}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{report.new === 0 ? 'Nothing new to import' : `Import ${report.new} new ${report.new === 1 ? 'activity' : 'activities'}`}
</button>
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
Choose another file
</button>
</div>
<p className="text-xs text-gray-600">Duplicates are skipped automatically your existing Garmin activities are kept.</p>
</div>
)}
{phase === 'importing' && (
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Queuing import</p>
)}
{phase === 'done' && (
<div className="py-3 space-y-2">
<p className="text-sm text-green-400"> Import queued new activities will appear shortly as they process.</p>
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
Import another export
</button>
</div>
)}
{phase === 'error' && (
<div className="py-3 space-y-2">
<p className="text-sm text-red-400">{error || 'Something went wrong'}</p>
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
Try again
</button>
</div>
)}
</div>
)
}
export default function UploadPage() { export default function UploadPage() {
return ( return (
<div className="p-4 md:p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
@@ -196,14 +356,8 @@ export default function UploadPage() {
accept={{ 'application/zip': ['.zip'] }} accept={{ 'application/zip': ['.zip'] }}
/> />
{/* Strava export */} {/* Strava export — preview duplicates, then import */}
<UploadZone <StravaExportZone />
title="Strava bulk export"
description="Upload your Strava archive ZIP"
icon="🚴"
endpoint="/upload/strava-export"
accept={{ 'application/zip': ['.zip'] }}
/>
{/* Ongoing FIT files */} {/* Ongoing FIT files */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-5">