feat: Strava export dry-run preview (new vs duplicate counts + confirm import) and prefer existing Garmin data over Strava on dedup
This commit is contained in:
+69
-19
@@ -3,12 +3,13 @@ import zipfile
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
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()
|
||||
|
||||
@@ -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")
|
||||
async def upload_strava_export(
|
||||
file: UploadFile = File(...),
|
||||
dry_run: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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"):
|
||||
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}"
|
||||
|
||||
task_ids = []
|
||||
try:
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
extracted = _safe_extract(zf, extract_dir)
|
||||
@@ -210,27 +236,25 @@ async def upload_strava_export(
|
||||
dest.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
|
||||
for path in extracted:
|
||||
# Strava compresses most exported activities as <id>.fit.gz / .gpx.gz /
|
||||
# .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:
|
||||
files = _collect_activity_files(extracted)
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
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 {
|
||||
"status": "queued",
|
||||
"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}")
|
||||
async def check_task_status(
|
||||
task_id: str,
|
||||
|
||||
Reference in New Issue
Block a user