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:
@@ -0,0 +1,271 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user, create_access_token
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User, StravaConfig
|
||||||
|
from app.services.strava_sync import (
|
||||||
|
STRAVA_AUTHORIZE_URL, STRAVA_SCOPE, exchange_code, encrypt_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
STATE_PURPOSE = "strava-link"
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_client():
|
||||||
|
import redis as redis_lib
|
||||||
|
return redis_lib.Redis.from_url(settings.redis_url)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_task_key(user_id: int) -> str:
|
||||||
|
return f"strava_sync_task:{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_cancel_key(user_id: int) -> str:
|
||||||
|
return f"strava_sync_cancel:{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_uri() -> str:
|
||||||
|
return f"{settings.base_url.rstrip('/')}/api/strava-sync/callback"
|
||||||
|
|
||||||
|
|
||||||
|
class StravaConfigIn(BaseModel):
|
||||||
|
sync_enabled: bool = True
|
||||||
|
sync_lookback_days: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class StravaConfigOut(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
athlete_name: Optional[str] = None
|
||||||
|
sync_enabled: bool = False
|
||||||
|
sync_lookback_days: int = 30
|
||||||
|
sync_interval_minutes: int = settings.garmin_sync_interval_minutes
|
||||||
|
last_sync_at: Optional[datetime] = None
|
||||||
|
last_sync_status: Optional[str] = None
|
||||||
|
configured: bool = True # whether the server has Strava API credentials at all
|
||||||
|
|
||||||
|
|
||||||
|
def _out(cfg: Optional[StravaConfig]) -> StravaConfigOut:
|
||||||
|
configured = bool(settings.strava_client_id and settings.strava_client_secret)
|
||||||
|
if not cfg:
|
||||||
|
return StravaConfigOut(
|
||||||
|
connected=False, sync_enabled=False, sync_lookback_days=30,
|
||||||
|
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||||
|
configured=configured,
|
||||||
|
)
|
||||||
|
return StravaConfigOut(
|
||||||
|
connected=True,
|
||||||
|
athlete_name=cfg.athlete_name,
|
||||||
|
sync_enabled=cfg.sync_enabled,
|
||||||
|
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
|
||||||
|
sync_interval_minutes=settings.garmin_sync_interval_minutes,
|
||||||
|
last_sync_at=cfg.last_sync_at,
|
||||||
|
last_sync_status=cfg.last_sync_status,
|
||||||
|
configured=configured,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=StravaConfigOut)
|
||||||
|
async def get_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = (await db.execute(
|
||||||
|
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
return _out(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authorize")
|
||||||
|
async def authorize(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Return the Strava OAuth URL to redirect the browser to. A signed `state`
|
||||||
|
carries the user id through the callback (which has no JWT header)."""
|
||||||
|
if not (settings.strava_client_id and settings.strava_client_secret):
|
||||||
|
raise HTTPException(status_code=400, detail="Strava API is not configured on this server")
|
||||||
|
state = create_access_token(
|
||||||
|
{"sub": str(current_user.id), "purpose": STATE_PURPOSE},
|
||||||
|
expires_delta=timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"client_id": settings.strava_client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": _redirect_uri(),
|
||||||
|
"approval_prompt": "auto",
|
||||||
|
"scope": STRAVA_SCOPE,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
return {"url": f"{STRAVA_AUTHORIZE_URL}?{urlencode(params)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(
|
||||||
|
code: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
scope: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Strava redirects here after the user authorizes. Exchange the code, store
|
||||||
|
tokens, then bounce back to the SPA profile page."""
|
||||||
|
profile_url = f"{settings.base_url.rstrip('/')}/profile"
|
||||||
|
|
||||||
|
if error or not code:
|
||||||
|
return RedirectResponse(f"{profile_url}?strava=error")
|
||||||
|
|
||||||
|
# Validate state → user id
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(state or "", settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
if payload.get("purpose") != STATE_PURPOSE:
|
||||||
|
raise ValueError("bad purpose")
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (JWTError, KeyError, TypeError, ValueError):
|
||||||
|
return RedirectResponse(f"{profile_url}?strava=error")
|
||||||
|
|
||||||
|
# Require activity:read_all so private activities sync too.
|
||||||
|
if scope and "activity:read_all" not in scope:
|
||||||
|
return RedirectResponse(f"{profile_url}?strava=scope")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tok = exchange_code(code)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse(f"{profile_url}?strava=error")
|
||||||
|
|
||||||
|
athlete = tok.get("athlete") or {}
|
||||||
|
athlete_name = " ".join(
|
||||||
|
x for x in [athlete.get("firstname"), athlete.get("lastname")] if x
|
||||||
|
).strip() or (athlete.get("username") or None)
|
||||||
|
|
||||||
|
cfg = (await db.execute(
|
||||||
|
select(StravaConfig).where(StravaConfig.user_id == user_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc)
|
||||||
|
if cfg:
|
||||||
|
cfg.access_token_enc = encrypt_token(tok["access_token"])
|
||||||
|
cfg.refresh_token_enc = encrypt_token(tok["refresh_token"])
|
||||||
|
cfg.expires_at = expires_at
|
||||||
|
cfg.athlete_id = str(athlete.get("id") or "") or cfg.athlete_id
|
||||||
|
cfg.athlete_name = athlete_name or cfg.athlete_name
|
||||||
|
cfg.last_sync_status = "Connected"
|
||||||
|
else:
|
||||||
|
cfg = StravaConfig(
|
||||||
|
user_id=user_id,
|
||||||
|
athlete_id=str(athlete.get("id") or "") or None,
|
||||||
|
athlete_name=athlete_name,
|
||||||
|
access_token_enc=encrypt_token(tok["access_token"]),
|
||||||
|
refresh_token_enc=encrypt_token(tok["refresh_token"]),
|
||||||
|
expires_at=expires_at,
|
||||||
|
sync_enabled=True,
|
||||||
|
sync_lookback_days=30,
|
||||||
|
last_sync_status="Connected",
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Kick off an initial sync immediately.
|
||||||
|
try:
|
||||||
|
from app.workers.tasks import sync_strava_user
|
||||||
|
sync_strava_user.delay(user_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return RedirectResponse(f"{profile_url}?strava=connected")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config", response_model=StravaConfigOut)
|
||||||
|
async def save_config(
|
||||||
|
body: StravaConfigIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = (await db.execute(
|
||||||
|
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=400, detail="Strava is not connected")
|
||||||
|
|
||||||
|
# Asking for more history than before → reset last_sync_at so the next sync
|
||||||
|
# backfills the wider window (mirrors the Garmin behaviour).
|
||||||
|
old = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||||
|
new = body.sync_lookback_days
|
||||||
|
wants_more = (new != old) and (new == -1 or (old != -1 and new > old))
|
||||||
|
if wants_more:
|
||||||
|
cfg.last_sync_at = None
|
||||||
|
cfg.last_sync_status = "Lookback increased — backfill on next sync"
|
||||||
|
|
||||||
|
cfg.sync_enabled = body.sync_enabled
|
||||||
|
cfg.sync_lookback_days = body.sync_lookback_days
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return _out(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/config")
|
||||||
|
async def delete_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cfg = (await db.execute(
|
||||||
|
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
await db.delete(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger")
|
||||||
|
async def trigger_sync(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = (await db.execute(
|
||||||
|
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not cfg or not cfg.sync_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Strava sync is not configured or disabled")
|
||||||
|
|
||||||
|
from app.workers.tasks import sync_strava_user
|
||||||
|
task = sync_strava_user.delay(current_user.id)
|
||||||
|
try:
|
||||||
|
r = _redis_client()
|
||||||
|
r.delete(sync_cancel_key(current_user.id))
|
||||||
|
r.set(sync_task_key(current_user.id), task.id, ex=3600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cancel")
|
||||||
|
async def cancel_sync(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.workers.tasks import celery_app
|
||||||
|
try:
|
||||||
|
r = _redis_client()
|
||||||
|
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
|
||||||
|
task_id = r.get(sync_task_key(current_user.id))
|
||||||
|
if task_id:
|
||||||
|
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
|
||||||
|
celery_app.control.revoke(tid, terminate=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cfg = (await db.execute(
|
||||||
|
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.last_sync_status = "Cancelling…"
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "cancelling"}
|
||||||
@@ -74,6 +74,33 @@ def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
|
|||||||
return extracted
|
return extracted
|
||||||
|
|
||||||
|
|
||||||
|
def _gunzip(path: Path) -> Path | None:
|
||||||
|
"""Decompress a .gz member to a sibling file without the .gz suffix,
|
||||||
|
enforcing the same uncompressed-size cap. Returns the new path, or None on
|
||||||
|
failure. The .gz is removed once expanded."""
|
||||||
|
import gzip
|
||||||
|
out_path = path.with_suffix("") # strips the trailing .gz
|
||||||
|
try:
|
||||||
|
total = 0
|
||||||
|
with gzip.open(path, "rb") as src, open(out_path, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = src.read(_CHUNK)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_EXTRACT_SIZE:
|
||||||
|
out.close()
|
||||||
|
out_path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
out.write(chunk)
|
||||||
|
except (OSError, EOFError):
|
||||||
|
out_path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activity")
|
@router.post("/activity")
|
||||||
async def upload_activity(
|
async def upload_activity(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -81,10 +108,10 @@ async def upload_activity(
|
|||||||
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 single .fit or .gpx activity file."""
|
"""Upload a single .fit, .gpx or .tcx activity file."""
|
||||||
suffix = Path(file.filename).suffix.lower()
|
suffix = Path(file.filename).suffix.lower()
|
||||||
if suffix not in {".fit", ".gpx"}:
|
if suffix not in {".fit", ".gpx", ".tcx"}:
|
||||||
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
raise HTTPException(status_code=400, detail="Only .fit, .gpx and .tcx files are supported")
|
||||||
|
|
||||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||||
dest = save_upload(file, dest_dir)
|
dest = save_upload(file, dest_dir)
|
||||||
@@ -184,15 +211,24 @@ async def upload_strava_export(
|
|||||||
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:
|
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()
|
suffix = path.suffix.lower()
|
||||||
if suffix in (".fit", ".gpx"):
|
if suffix in (".fit", ".gpx", ".tcx"):
|
||||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||||
task_ids.append(task.id)
|
task_ids.append(task.id)
|
||||||
|
|
||||||
if not task_ids:
|
if not task_ids:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="No activity files (.fit or .gpx) found in this Strava archive",
|
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
|
|||||||
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
||||||
# Garmin Connect — how often the beat scheduler runs the automatic sync
|
# Garmin Connect — how often the beat scheduler runs the automatic sync
|
||||||
garmin_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES")
|
garmin_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES")
|
||||||
|
# Strava API (optional) — register an app at https://www.strava.com/settings/api.
|
||||||
|
# The Authorization Callback Domain there must match BASE_URL's host.
|
||||||
|
strava_client_id: Optional[str] = Field(None, env="STRAVA_CLIENT_ID")
|
||||||
|
strava_client_secret: Optional[str] = Field(None, env="STRAVA_CLIENT_SECRET")
|
||||||
# Files
|
# Files
|
||||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
|
|
||||||
from app.core.database import engine, AsyncSessionLocal, Base
|
from app.core.database import engine, AsyncSessionLocal, Base
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
|
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, strava_sync, users, segments
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
@@ -260,6 +260,7 @@ app.include_router(records.router, prefix="/api/records", tags=["records"])
|
|||||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||||
|
app.include_router(strava_sync.router, prefix="/api/strava-sync", tags=["strava-sync"])
|
||||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
|
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class User(Base):
|
|||||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||||
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||||
|
strava_config = relationship("StravaConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class GarminConnectConfig(Base):
|
class GarminConnectConfig(Base):
|
||||||
@@ -67,6 +68,27 @@ class GarminConnectConfig(Base):
|
|||||||
user = relationship("User", back_populates="garmin_connect_config")
|
user = relationship("User", back_populates="garmin_connect_config")
|
||||||
|
|
||||||
|
|
||||||
|
class StravaConfig(Base):
|
||||||
|
"""Per-user Strava OAuth tokens and sync state. Tokens are Fernet-encrypted
|
||||||
|
(same SECRET_KEY scheme as Garmin credentials)."""
|
||||||
|
__tablename__ = "strava_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||||
|
athlete_id = Column(String(64), nullable=True)
|
||||||
|
athlete_name = Column(String(256), nullable=True)
|
||||||
|
access_token_enc = Column(String(512), nullable=False)
|
||||||
|
refresh_token_enc = Column(String(512), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True) # access-token expiry
|
||||||
|
sync_enabled = Column(Boolean, default=True)
|
||||||
|
sync_lookback_days = Column(Integer, default=30) # -1 = all-time, first sync only
|
||||||
|
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_sync_status = Column(String(512), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="strava_config")
|
||||||
|
|
||||||
|
|
||||||
class WeightLog(Base):
|
class WeightLog(Base):
|
||||||
"""Manual weight entries separate from health_metrics for easy tracking."""
|
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||||
__tablename__ = "weight_logs"
|
__tablename__ = "weight_logs"
|
||||||
|
|||||||
@@ -406,6 +406,143 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tcx_file(filepath: str) -> dict:
|
||||||
|
"""Parse a Garmin Training Center XML (.tcx) activity file.
|
||||||
|
|
||||||
|
Strava bulk exports include older device uploads as .tcx (often gzipped).
|
||||||
|
TCX is namespaced XML; we match by local tag name so the parser is robust to
|
||||||
|
the various TCX namespace declarations in the wild. Output mirrors
|
||||||
|
parse_gpx_file so downstream ingestion is identical."""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
def local(tag: str) -> str:
|
||||||
|
return tag.split("}")[-1] if "}" in tag else tag
|
||||||
|
|
||||||
|
def find(el, name):
|
||||||
|
for child in el.iter():
|
||||||
|
if local(child.tag) == name:
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
|
def findall(el, name):
|
||||||
|
return [c for c in el.iter() if local(c.tag) == name]
|
||||||
|
|
||||||
|
def child_text(el, name):
|
||||||
|
for c in list(el):
|
||||||
|
if local(c.tag) == name and c.text:
|
||||||
|
return c.text.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
tree = ET.parse(filepath)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
activity_el = find(root, "Activity")
|
||||||
|
sport_raw = (activity_el.get("Sport") if activity_el is not None else None) or "Other"
|
||||||
|
sport = {"running": "running", "biking": "cycling",
|
||||||
|
"walking": "walking", "hiking": "hiking"}.get(sport_raw.lower(), sport_raw.lower())
|
||||||
|
|
||||||
|
data_points = []
|
||||||
|
for tp in findall(root, "Trackpoint"):
|
||||||
|
ts_str = child_text(tp, "Time")
|
||||||
|
ts = None
|
||||||
|
if ts_str:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
ts = None
|
||||||
|
|
||||||
|
lat = lng = None
|
||||||
|
pos = next((c for c in list(tp) if local(c.tag) == "Position"), None)
|
||||||
|
if pos is not None:
|
||||||
|
lat = _safe_float(child_text(pos, "LatitudeDegrees"))
|
||||||
|
lng = _safe_float(child_text(pos, "LongitudeDegrees"))
|
||||||
|
|
||||||
|
hr = None
|
||||||
|
hr_el = next((c for c in list(tp) if local(c.tag) == "HeartRateBpm"), None)
|
||||||
|
if hr_el is not None:
|
||||||
|
hr = _safe_float(child_text(hr_el, "Value"))
|
||||||
|
|
||||||
|
# Speed/Watts live in a TPX extension; search descendants by local name.
|
||||||
|
speed = watts = None
|
||||||
|
for ext in findall(tp, "Speed"):
|
||||||
|
speed = _safe_float(ext.text)
|
||||||
|
break
|
||||||
|
for ext in findall(tp, "Watts"):
|
||||||
|
watts = _safe_float(ext.text)
|
||||||
|
break
|
||||||
|
|
||||||
|
data_points.append({
|
||||||
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
|
"latitude": lat, "longitude": lng,
|
||||||
|
"altitude_m": _safe_float(child_text(tp, "AltitudeMeters")),
|
||||||
|
"heart_rate": hr,
|
||||||
|
"cadence": _safe_float(child_text(tp, "Cadence")),
|
||||||
|
"speed_ms": speed,
|
||||||
|
"power": watts,
|
||||||
|
"temperature_c": None,
|
||||||
|
"distance_m": _safe_float(child_text(tp, "DistanceMeters")),
|
||||||
|
})
|
||||||
|
|
||||||
|
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
|
||||||
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
|
# Distance: prefer the cumulative DistanceMeters from the file; fall back to
|
||||||
|
# haversine over GPS points when absent (some TCX trackpoints omit it).
|
||||||
|
dist_vals = [p["distance_m"] for p in data_points if p["distance_m"] is not None]
|
||||||
|
if dist_vals:
|
||||||
|
total_dist = max(dist_vals)
|
||||||
|
else:
|
||||||
|
total_dist = 0.0
|
||||||
|
prev = None
|
||||||
|
for p in data_points:
|
||||||
|
if p["latitude"] and p["longitude"]:
|
||||||
|
if prev:
|
||||||
|
R = 6371000
|
||||||
|
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
|
||||||
|
dphi = math.radians(p["latitude"] - prev[0])
|
||||||
|
dlam = math.radians(p["longitude"] - prev[1])
|
||||||
|
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||||
|
total_dist += 2 * R * math.asin(math.sqrt(a))
|
||||||
|
prev = (p["latitude"], p["longitude"])
|
||||||
|
p["distance_m"] = total_dist
|
||||||
|
|
||||||
|
uphill, downhill = 0.0, 0.0
|
||||||
|
alts = [p["altitude_m"] for p in data_points if p["altitude_m"] is not None]
|
||||||
|
for i in range(1, len(alts)):
|
||||||
|
diff = alts[i] - alts[i-1]
|
||||||
|
if diff > 0: uphill += diff
|
||||||
|
else: downhill += abs(diff)
|
||||||
|
|
||||||
|
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||||
|
start_time_str = next((p["timestamp"] for p in data_points if p["timestamp"]), None)
|
||||||
|
last_time_str = next((p["timestamp"] for p in reversed(data_points) if p["timestamp"]), None)
|
||||||
|
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||||
|
end_dt = datetime.fromisoformat(last_time_str) if last_time_str else None
|
||||||
|
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||||
|
avg_speed = (total_dist / duration) if (total_dist and duration) else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": f"{sport.title()} {start_dt.date() if start_dt else ''}".strip(),
|
||||||
|
"sport_type": sport, "start_time": start_time_str,
|
||||||
|
"distance_m": total_dist or None, "duration_s": duration, "moving_time_s": None,
|
||||||
|
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
|
||||||
|
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||||
|
"max_heart_rate": max(hrs) if hrs else None,
|
||||||
|
"avg_cadence": None, "avg_power": None, "normalized_power": None,
|
||||||
|
"avg_speed_ms": avg_speed,
|
||||||
|
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||||
|
"training_stress_score": None, "vo2max_estimate": None,
|
||||||
|
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||||
|
"active_spans": _active_spans(data_points),
|
||||||
|
"source_type": "tcx",
|
||||||
|
"rejected_reason": _vehicle_reason(sport, avg_speed, total_dist, duration),
|
||||||
|
"data_points": data_points, "laps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||||
if not user_max_hr or user_max_hr < 100:
|
if not user_max_hr or user_max_hr < 100:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Strava API sync helpers.
|
||||||
|
|
||||||
|
OAuth: exchange_code() / refresh_tokens() talk to Strava's token endpoint.
|
||||||
|
get_valid_access_token() transparently refreshes an expired access token and
|
||||||
|
persists the rotated refresh token.
|
||||||
|
|
||||||
|
sync_strava_activities() lists the athlete's activities, fetches per-activity
|
||||||
|
streams, builds a fit_parser-shaped dict and hands it to persist_activity()
|
||||||
|
(shared with the file-upload path) so Strava activities get identical dedup,
|
||||||
|
PR, route and segment handling.
|
||||||
|
|
||||||
|
Tokens are Fernet-encrypted with the same SECRET_KEY scheme as Garmin creds.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import polyline as polyline_lib
|
||||||
|
|
||||||
|
from app.services.fit_parser import _bounding_box, _active_spans
|
||||||
|
from app.services.garmin_connect_sync import _fernet # reuse the SECRET_KEY-derived Fernet
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STRAVA_AUTHORIZE_URL = "https://www.strava.com/oauth/authorize"
|
||||||
|
STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
|
||||||
|
STRAVA_API_BASE = "https://www.strava.com/api/v3"
|
||||||
|
|
||||||
|
# Scope needed to read all activities (including those marked "Only You").
|
||||||
|
STRAVA_SCOPE = "read,activity:read_all"
|
||||||
|
|
||||||
|
# Like Garmin: incremental syncs only re-scan the last day or two for late edits.
|
||||||
|
INCREMENTAL_BUFFER_DAYS = 1
|
||||||
|
|
||||||
|
# Strava activity type / sport_type → MileVault internal sport_type. Gym types map
|
||||||
|
# onto the internal vocabulary so sportColor() paints them red, GPS types green/etc.
|
||||||
|
STRAVA_SPORT_MAP = {
|
||||||
|
"run": "running", "trailrun": "running", "virtualrun": "running",
|
||||||
|
"ride": "cycling", "virtualride": "cycling", "mountainbikeride": "cycling",
|
||||||
|
"gravelride": "cycling", "ebikeride": "cycling", "emountainbikeride": "cycling",
|
||||||
|
"handcycle": "cycling", "velomobile": "cycling",
|
||||||
|
"walk": "walking", "hike": "hiking",
|
||||||
|
"swim": "swimming",
|
||||||
|
"weighttraining": "strength_training", "workout": "training",
|
||||||
|
"crossfit": "hiit", "hiit": "hiit",
|
||||||
|
"elliptical": "fitness_equipment", "stairstepper": "fitness_equipment",
|
||||||
|
"golf": "golf", "yoga": "yoga",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_token(token: str) -> str:
|
||||||
|
return _fernet().encrypt(token.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_token(enc: str) -> str:
|
||||||
|
return _fernet().decrypt(enc.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ── OAuth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _token_request(payload: dict) -> dict:
|
||||||
|
from app.core.config import settings
|
||||||
|
payload = {
|
||||||
|
"client_id": settings.strava_client_id,
|
||||||
|
"client_secret": settings.strava_client_secret,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
resp = client.post(STRAVA_TOKEN_URL, data=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code(code: str) -> dict:
|
||||||
|
"""Exchange an authorization code for tokens. Returns the raw token dict
|
||||||
|
(access_token, refresh_token, expires_at, athlete{...})."""
|
||||||
|
return _token_request({"code": code, "grant_type": "authorization_code"})
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_tokens(refresh_token: str) -> dict:
|
||||||
|
"""Get a fresh access token (and possibly rotated refresh token)."""
|
||||||
|
return _token_request({"refresh_token": refresh_token, "grant_type": "refresh_token"})
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid_access_token(cfg, db) -> str:
|
||||||
|
"""Return a usable access token for `cfg`, refreshing and persisting if the
|
||||||
|
current one is expired or about to expire. `cfg` is a StravaConfig row."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = cfg.expires_at
|
||||||
|
if expires_at is not None and expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if expires_at is None or expires_at <= now + timedelta(seconds=60):
|
||||||
|
tok = refresh_tokens(decrypt_token(cfg.refresh_token_enc))
|
||||||
|
cfg.access_token_enc = encrypt_token(tok["access_token"])
|
||||||
|
cfg.refresh_token_enc = encrypt_token(tok["refresh_token"])
|
||||||
|
cfg.expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return tok["access_token"]
|
||||||
|
|
||||||
|
return decrypt_token(cfg.access_token_enc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Activity sync ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _api_get(client: httpx.Client, token: str, path: str, **params):
|
||||||
|
resp = client.get(
|
||||||
|
f"{STRAVA_API_BASE}{path}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
if resp.status_code == 429:
|
||||||
|
raise StravaRateLimited()
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
class StravaRateLimited(Exception):
|
||||||
|
"""Raised when Strava returns HTTP 429 (rate limit exceeded)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _streams_to_points(streams: dict, start_dt: datetime) -> list:
|
||||||
|
"""Convert Strava's key_by_type streams into fit_parser-shaped data points."""
|
||||||
|
time_s = (streams.get("time") or {}).get("data") or []
|
||||||
|
if not time_s:
|
||||||
|
return []
|
||||||
|
latlng = (streams.get("latlng") or {}).get("data") or []
|
||||||
|
altitude = (streams.get("altitude") or {}).get("data") or []
|
||||||
|
heartrate = (streams.get("heartrate") or {}).get("data") or []
|
||||||
|
cadence = (streams.get("cadence") or {}).get("data") or []
|
||||||
|
watts = (streams.get("watts") or {}).get("data") or []
|
||||||
|
velocity = (streams.get("velocity_smooth") or {}).get("data") or []
|
||||||
|
temp = (streams.get("temp") or {}).get("data") or []
|
||||||
|
distance = (streams.get("distance") or {}).get("data") or []
|
||||||
|
|
||||||
|
def at(arr, i):
|
||||||
|
return arr[i] if i < len(arr) else None
|
||||||
|
|
||||||
|
points = []
|
||||||
|
for i, off in enumerate(time_s):
|
||||||
|
ll = at(latlng, i)
|
||||||
|
lat = ll[0] if ll else None
|
||||||
|
lng = ll[1] if ll else None
|
||||||
|
points.append({
|
||||||
|
"timestamp": (start_dt + timedelta(seconds=off)).isoformat(),
|
||||||
|
"latitude": lat, "longitude": lng,
|
||||||
|
"altitude_m": at(altitude, i),
|
||||||
|
"heart_rate": at(heartrate, i),
|
||||||
|
"cadence": at(cadence, i),
|
||||||
|
"speed_ms": at(velocity, i),
|
||||||
|
"power": at(watts, i),
|
||||||
|
"temperature_c": at(temp, i),
|
||||||
|
"distance_m": at(distance, i),
|
||||||
|
})
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parsed(summary: dict, points: list) -> dict:
|
||||||
|
"""Assemble a fit_parser-shaped dict from a Strava summary + stream points."""
|
||||||
|
raw_type = (summary.get("sport_type") or summary.get("type") or "workout")
|
||||||
|
sport = STRAVA_SPORT_MAP.get(str(raw_type).lower(), str(raw_type).lower())
|
||||||
|
|
||||||
|
start_str = summary.get("start_date") # UTC ISO, e.g. 2020-01-01T08:00:00Z
|
||||||
|
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_str else None
|
||||||
|
|
||||||
|
coords = [(p["latitude"], p["longitude"]) for p in points if p["latitude"] and p["longitude"]]
|
||||||
|
encoded = polyline_lib.encode(coords) if coords else (summary.get("map") or {}).get("summary_polyline")
|
||||||
|
|
||||||
|
# Elevation loss from the altitude stream (summary only gives gain).
|
||||||
|
alts = [p["altitude_m"] for p in points if p["altitude_m"] is not None]
|
||||||
|
downhill = sum(max(0.0, alts[i-1] - alts[i]) for i in range(1, len(alts))) if alts else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": summary.get("name") or f"{sport.title()} {start_dt.date() if start_dt else ''}".strip(),
|
||||||
|
"sport_type": sport,
|
||||||
|
"start_time": start_dt.isoformat() if start_dt else None,
|
||||||
|
"distance_m": summary.get("distance"),
|
||||||
|
"duration_s": summary.get("elapsed_time"),
|
||||||
|
"moving_time_s": summary.get("moving_time"),
|
||||||
|
"elevation_gain_m": summary.get("total_elevation_gain"),
|
||||||
|
"elevation_loss_m": downhill,
|
||||||
|
"avg_heart_rate": summary.get("average_heartrate"),
|
||||||
|
"max_heart_rate": summary.get("max_heartrate"),
|
||||||
|
"avg_cadence": summary.get("average_cadence"),
|
||||||
|
"avg_power": summary.get("average_watts"),
|
||||||
|
"normalized_power": summary.get("weighted_average_watts"),
|
||||||
|
"avg_speed_ms": summary.get("average_speed"),
|
||||||
|
"max_speed_ms": summary.get("max_speed"),
|
||||||
|
"active_spans": _active_spans(points),
|
||||||
|
"avg_temperature_c": summary.get("average_temp"),
|
||||||
|
"calories": summary.get("calories"),
|
||||||
|
"training_stress_score": None,
|
||||||
|
"vo2max_estimate": None,
|
||||||
|
"polyline": encoded,
|
||||||
|
"bounding_box": _bounding_box(coords),
|
||||||
|
"source_type": "strava",
|
||||||
|
"rejected_reason": None,
|
||||||
|
"data_points": points,
|
||||||
|
"laps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_strava_activities(cfg, user_id: int, db, lookback_days: int = 30,
|
||||||
|
status_callback=None) -> int:
|
||||||
|
"""List the athlete's activities since the appropriate window, fetch streams
|
||||||
|
for each new one and persist it. Returns the number of activities imported."""
|
||||||
|
import time as _time
|
||||||
|
from app.workers.tasks import persist_activity
|
||||||
|
|
||||||
|
since = cfg.last_sync_at
|
||||||
|
if since is not None and since.tzinfo is None:
|
||||||
|
since = since.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if since:
|
||||||
|
after_dt = since - timedelta(days=INCREMENTAL_BUFFER_DAYS)
|
||||||
|
elif lookback_days == -1:
|
||||||
|
after_dt = datetime(2010, 1, 1, tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
after_dt = datetime.now(timezone.utc) - timedelta(days=max(lookback_days, 1))
|
||||||
|
after_epoch = int(after_dt.timestamp())
|
||||||
|
|
||||||
|
token = get_valid_access_token(cfg, db)
|
||||||
|
imported = 0
|
||||||
|
|
||||||
|
with httpx.Client(timeout=60) as client:
|
||||||
|
# 1) Page through activity summaries (newest first within each page).
|
||||||
|
summaries = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
batch = _api_get(client, token, "/athlete/activities",
|
||||||
|
after=after_epoch, page=page, per_page=100)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
summaries.extend(batch)
|
||||||
|
if len(batch) < 100:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
_time.sleep(0.3)
|
||||||
|
|
||||||
|
total = len(summaries)
|
||||||
|
if status_callback:
|
||||||
|
status_callback(f"Syncing activities: 0/{total} imported")
|
||||||
|
|
||||||
|
# 2) For each, fetch streams and persist. Oldest-first so PRs accrue in order.
|
||||||
|
for idx, summary in enumerate(sorted(summaries, key=lambda s: s.get("start_date") or "")):
|
||||||
|
sid = str(summary.get("id") or "").strip()
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
points = []
|
||||||
|
try:
|
||||||
|
streams = _api_get(
|
||||||
|
client, token, f"/activities/{sid}/streams",
|
||||||
|
keys="time,latlng,altitude,heartrate,cadence,watts,velocity_smooth,temp,distance",
|
||||||
|
key_by_type="true",
|
||||||
|
)
|
||||||
|
start_str = summary.get("start_date")
|
||||||
|
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_str else None
|
||||||
|
if start_dt:
|
||||||
|
points = _streams_to_points(streams, start_dt)
|
||||||
|
except StravaRateLimited:
|
||||||
|
# Out of quota — stop here; the next scheduled sync resumes the rest.
|
||||||
|
if status_callback:
|
||||||
|
status_callback(f"Rate-limited by Strava — imported {imported}, will resume next sync")
|
||||||
|
logger.warning("Strava rate limit hit for user %s after %d imports", user_id, imported)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch streams for Strava activity %s: %s", sid, exc)
|
||||||
|
# Still persist from the summary (map polyline only, no per-point data).
|
||||||
|
|
||||||
|
parsed = _build_parsed(summary, points)
|
||||||
|
if not parsed.get("start_time"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = persist_activity(db, user_id, parsed, strava_activity_id=sid)
|
||||||
|
if result.get("status") == "ok":
|
||||||
|
imported += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to persist Strava activity %s: %s", sid, exc)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
if status_callback and (idx % 5 == 0 or idx == total - 1):
|
||||||
|
status_callback(f"Syncing activities: {imported}/{total} imported")
|
||||||
|
|
||||||
|
_time.sleep(0.3) # be gentle on the rate limit
|
||||||
|
|
||||||
|
return imported
|
||||||
+237
-107
@@ -28,6 +28,12 @@ celery_app.conf.update(
|
|||||||
# Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
|
# Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
|
||||||
"schedule": float(settings.garmin_sync_interval_minutes * 60),
|
"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)
|
parse_wellness_fit.delay(file_path, user_id)
|
||||||
return {"status": "routed_to_wellness", "file": file_path}
|
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.core.database import SyncSessionLocal
|
||||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
@@ -105,6 +111,8 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
|||||||
try:
|
try:
|
||||||
if source_type == "fit" or file_path.endswith(".fit"):
|
if source_type == "fit" or file_path.endswith(".fit"):
|
||||||
parsed = parse_fit_file(file_path)
|
parsed = parse_fit_file(file_path)
|
||||||
|
elif source_type == "tcx" or file_path.endswith(".tcx"):
|
||||||
|
parsed = parse_tcx_file(file_path)
|
||||||
else:
|
else:
|
||||||
parsed = parse_gpx_file(file_path)
|
parsed = parse_gpx_file(file_path)
|
||||||
except Exception as e:
|
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}
|
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
|
||||||
|
|
||||||
with SyncSessionLocal() as db:
|
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(
|
existing = db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(Activity.strava_activity_id == str(strava_activity_id))
|
||||||
Activity.user_id == user_id,
|
).scalar_one_or_none()
|
||||||
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 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"}
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
# Get user max HR for zone calculation
|
# Deduplicate across sources: same user + sport_type + start_time within ±60s.
|
||||||
from app.models.user import User as UserModel
|
# This also collapses an activity synced from both Garmin and Strava into one.
|
||||||
user_obj = db.execute(
|
existing = db.execute(
|
||||||
select(UserModel).where(UserModel.id == user_id)
|
select(Activity).where(
|
||||||
).scalar_one_or_none()
|
Activity.user_id == user_id,
|
||||||
|
Activity.sport_type == parsed["sport_type"],
|
||||||
user_max_hr = None
|
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||||
if user_obj:
|
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
).scalars().first()
|
||||||
db.flush()
|
|
||||||
|
|
||||||
seen = set()
|
if existing:
|
||||||
batch = []
|
# Stamp the external id so future syncs skip straight to the fast path.
|
||||||
for p in parsed.get("data_points", []):
|
stamped = False
|
||||||
if not p.get("timestamp"):
|
if garmin_activity_id and not existing.garmin_activity_id:
|
||||||
continue
|
existing.garmin_activity_id = garmin_activity_id
|
||||||
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
stamped = True
|
||||||
key = (activity.id, ts)
|
if strava_activity_id and not existing.strava_activity_id:
|
||||||
if key in seen:
|
existing.strava_activity_id = str(strava_activity_id)
|
||||||
continue
|
stamped = True
|
||||||
seen.add(key)
|
if stamped:
|
||||||
batch.append(ActivityDataPoint(
|
db.commit()
|
||||||
activity_id=activity.id,
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
timestamp=ts,
|
|
||||||
latitude=p.get("latitude"),
|
# Get user max HR for zone calculation
|
||||||
longitude=p.get("longitude"),
|
user_obj = db.execute(
|
||||||
altitude_m=p.get("altitude_m"),
|
select(UserModel).where(UserModel.id == user_id)
|
||||||
heart_rate=p.get("heart_rate"),
|
).scalar_one_or_none()
|
||||||
cadence=p.get("cadence"),
|
|
||||||
speed_ms=p.get("speed_ms"),
|
user_max_hr = None
|
||||||
power=p.get("power"),
|
if user_obj:
|
||||||
temperature_c=p.get("temperature_c"),
|
user_max_hr = user_obj.max_heart_rate
|
||||||
distance_m=p.get("distance_m"),
|
if not user_max_hr and user_obj.birth_year:
|
||||||
))
|
from datetime import date as _date
|
||||||
if len(batch) >= 500:
|
age = _date.today().year - user_obj.birth_year
|
||||||
db.add_all(batch)
|
user_max_hr = 220 - age
|
||||||
db.flush()
|
if not user_max_hr:
|
||||||
batch = []
|
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||||
if batch:
|
|
||||||
|
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.add_all(batch)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
batch = []
|
||||||
|
if batch:
|
||||||
|
db.add_all(batch)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
for lap in parsed.get("laps", []):
|
for lap in parsed.get("laps", []):
|
||||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||||
db.add(ActivityLap(
|
db.add(ActivityLap(
|
||||||
activity_id=activity.id,
|
activity_id=activity.id,
|
||||||
lap_number=lap["lap_number"],
|
lap_number=lap["lap_number"],
|
||||||
start_time=ls,
|
start_time=ls,
|
||||||
duration_s=lap.get("duration_s"),
|
duration_s=lap.get("duration_s"),
|
||||||
distance_m=lap.get("distance_m"),
|
distance_m=lap.get("distance_m"),
|
||||||
avg_heart_rate=lap.get("avg_heart_rate"),
|
avg_heart_rate=lap.get("avg_heart_rate"),
|
||||||
avg_cadence=lap.get("avg_cadence"),
|
avg_cadence=lap.get("avg_cadence"),
|
||||||
avg_speed_ms=lap.get("avg_speed_ms"),
|
avg_speed_ms=lap.get("avg_speed_ms"),
|
||||||
avg_power=lap.get("avg_power"),
|
avg_power=lap.get("avg_power"),
|
||||||
))
|
))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
activity_id = activity.id
|
activity_id = activity.id
|
||||||
|
|
||||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||||
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||||
@@ -841,6 +881,96 @@ def sync_all_garmin_connect():
|
|||||||
return {"dispatched": len(user_ids)}
|
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")
|
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||||
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
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."""
|
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ services:
|
|||||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||||
|
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||||
|
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||||
|
BASE_URL: ${BASE_URL:-https://milevault.jarrett.eu}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
volumes:
|
volumes:
|
||||||
@@ -82,6 +85,8 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||||
|
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||||
|
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- file_data:/data/files
|
||||||
@@ -100,6 +105,8 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||||
|
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||||
|
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- file_data:/data/files
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ services:
|
|||||||
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||||
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||||
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||||
|
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||||
|
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||||
|
BASE_URL: ${BASE_URL:-https://milevault.jarrett.eu}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
ENVIRONMENT: ${ENVIRONMENT:-production}
|
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||||
volumes:
|
volumes:
|
||||||
@@ -74,6 +77,8 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
|
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||||
|
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- ./file_data:/data/files
|
- ./file_data:/data/files
|
||||||
@@ -94,6 +99,8 @@ services:
|
|||||||
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
|
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
||||||
|
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- ./file_data:/data/files
|
- ./file_data:/data/files
|
||||||
|
|||||||
@@ -175,6 +175,65 @@ export default function ProfilePage() {
|
|||||||
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
|
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// Strava sync
|
||||||
|
const { data: stravaConfig, refetch: refetchStrava } = useQuery({
|
||||||
|
queryKey: ['strava-config'],
|
||||||
|
queryFn: () => api.get('/strava-sync/config').then(r => r.data),
|
||||||
|
// Poll while a sync is running so the status text stays live.
|
||||||
|
refetchInterval: q => {
|
||||||
|
const s = q.state.data?.last_sync_status || ''
|
||||||
|
const running = s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s)
|
||||||
|
return running ? 3000 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [stForm, setStForm] = useState({ sync_enabled: true, sync_lookback_days: '30' })
|
||||||
|
const [stSaved, setStSaved] = useState(false)
|
||||||
|
const stFormLoaded = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (stravaConfig?.connected && !stFormLoaded.current) {
|
||||||
|
stFormLoaded.current = true
|
||||||
|
setStForm({
|
||||||
|
sync_enabled: stravaConfig.sync_enabled,
|
||||||
|
sync_lookback_days: String(stravaConfig.sync_lookback_days ?? 30),
|
||||||
|
})
|
||||||
|
} else if (!stravaConfig?.connected) {
|
||||||
|
stFormLoaded.current = false
|
||||||
|
}
|
||||||
|
}, [stravaConfig])
|
||||||
|
// OAuth return banner (?strava=connected|error|scope), then strip the query.
|
||||||
|
const [stReturn, setStReturn] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
const p = new URLSearchParams(window.location.search)
|
||||||
|
const s = p.get('strava')
|
||||||
|
if (s) {
|
||||||
|
setStReturn(s)
|
||||||
|
refetchStrava()
|
||||||
|
p.delete('strava')
|
||||||
|
const qs = p.toString()
|
||||||
|
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const connectStrava = useMutation({
|
||||||
|
mutationFn: () => api.get('/strava-sync/authorize').then(r => r.data),
|
||||||
|
onSuccess: d => { if (d?.url) window.location.href = d.url },
|
||||||
|
})
|
||||||
|
const saveStrava = useMutation({
|
||||||
|
mutationFn: data => api.put('/strava-sync/config', data).then(r => r.data),
|
||||||
|
onSuccess: () => { refetchStrava(); setStSaved(true); setTimeout(() => setStSaved(false), 3000) },
|
||||||
|
})
|
||||||
|
const triggerStrava = useMutation({
|
||||||
|
mutationFn: () => api.post('/strava-sync/trigger'),
|
||||||
|
onSuccess: () => setTimeout(refetchStrava, 1000),
|
||||||
|
})
|
||||||
|
const deleteStrava = useMutation({
|
||||||
|
mutationFn: () => api.delete('/strava-sync/config'),
|
||||||
|
onSuccess: () => { refetchStrava(); setStForm({ sync_enabled: true, sync_lookback_days: '30' }) },
|
||||||
|
})
|
||||||
|
const stravaSyncing = (() => {
|
||||||
|
const s = stravaConfig?.last_sync_status || ''
|
||||||
|
return !!s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s)
|
||||||
|
})()
|
||||||
|
|
||||||
// PocketID config
|
// PocketID config
|
||||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||||
const [pidSaved, setPidSaved] = useState(false)
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
@@ -445,6 +504,94 @@ export default function ProfilePage() {
|
|||||||
})()}
|
})()}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Strava Sync */}
|
||||||
|
<Section title="🟠 Strava Sync">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Connect your Strava account to automatically import activities {formatSyncInterval(stravaConfig?.sync_interval_minutes)}.
|
||||||
|
Works for anything that ends up on Strava — Apple Watch, the Strava phone app, or another GPS watch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{stReturn === 'connected' && (
|
||||||
|
<p className="text-xs text-green-400">✓ Strava connected — your first sync has started.</p>
|
||||||
|
)}
|
||||||
|
{stReturn === 'error' && (
|
||||||
|
<p className="text-xs text-red-400">Strava connection failed or was cancelled. Please try again.</p>
|
||||||
|
)}
|
||||||
|
{stReturn === 'scope' && (
|
||||||
|
<p className="text-xs text-yellow-400">Please tick “View data about your activities” when authorizing, so private activities can sync.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!stravaConfig?.configured && (
|
||||||
|
<p className="text-xs text-yellow-400">
|
||||||
|
Strava API credentials aren’t set on the server. An admin must register an app at
|
||||||
|
strava.com/settings/api and set <code className="text-gray-300">STRAVA_CLIENT_ID</code> /
|
||||||
|
<code className="text-gray-300"> STRAVA_CLIENT_SECRET</code> (callback domain = this site’s domain).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stravaConfig?.connected ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between bg-orange-900/20 border border-orange-800/40 rounded-lg px-3 py-2 text-xs flex-wrap gap-2">
|
||||||
|
<span className="text-orange-300">✓ Connected{stravaConfig.athlete_name ? ` as ${stravaConfig.athlete_name}` : ''}</span>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{stravaConfig.last_sync_at && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Last sync: {new Date(stravaConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{stravaConfig.last_sync_status && (
|
||||||
|
<span className={stravaConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : /error/i.test(stravaConfig.last_sync_status) ? 'text-red-400' : 'text-yellow-400'}>
|
||||||
|
{stravaConfig.last_sync_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer pt-1">
|
||||||
|
<input type="checkbox" checked={stForm.sync_enabled}
|
||||||
|
onChange={e => setStForm(f => ({ ...f, sync_enabled: e.target.checked }))}
|
||||||
|
className="w-4 h-4 accent-orange-500" />
|
||||||
|
<span className="text-sm text-gray-300">Enable automatic sync ({formatSyncInterval(stravaConfig?.sync_interval_minutes)})</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history). After that, scheduled syncs only refresh the last day or two. Large backfills may hit Strava's rate limits and resume on the next sync.">
|
||||||
|
<Input type="number" value={stForm.sync_lookback_days} min={-1}
|
||||||
|
onChange={e => setStForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pt-1">
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => saveStrava.mutate({
|
||||||
|
sync_enabled: stForm.sync_enabled,
|
||||||
|
sync_lookback_days: parseInt(stForm.sync_lookback_days, 10) || 30,
|
||||||
|
})}
|
||||||
|
loading={saveStrava.isPending}
|
||||||
|
saved={stSaved}
|
||||||
|
label="Update"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerStrava.mutate()}
|
||||||
|
disabled={stravaSyncing || triggerStrava.isPending}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
{stravaSyncing ? 'Syncing…' : '↻ Sync now'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('Disconnect Strava?')) deleteStrava.mutate() }}
|
||||||
|
className="text-red-400 hover:text-red-300 text-sm transition-colors">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => connectStrava.mutate()}
|
||||||
|
disabled={!stravaConfig?.configured || connectStrava.isPending}
|
||||||
|
className="inline-flex items-center gap-2 bg-[#FC4C02] hover:bg-[#e34402] disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
{connectStrava.isPending ? 'Redirecting…' : 'Connect with Strava'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* PocketID — admin only */}
|
{/* PocketID — admin only */}
|
||||||
{user?.is_admin && (
|
{user?.is_admin && (
|
||||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||||
|
|||||||
@@ -169,20 +169,21 @@ export default function UploadPage() {
|
|||||||
<li>Click "Request Your Archive"</li>
|
<li>Click "Request Your Archive"</li>
|
||||||
<li>Download and upload the ZIP file below</li>
|
<li>Download and upload the ZIP file below</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<p className="text-gray-500 text-xs mt-2">Handles the gzipped <code>.fit.gz</code>/<code>.gpx.gz</code>/<code>.tcx.gz</code> files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
{/* Single FIT/GPX */}
|
{/* Single FIT/GPX/TCX */}
|
||||||
<UploadZone
|
<UploadZone
|
||||||
title="Single activity"
|
title="Single activity"
|
||||||
description="Upload a .fit or .gpx file"
|
description="Upload a .fit, .gpx or .tcx file"
|
||||||
icon="🏃"
|
icon="🏃"
|
||||||
endpoint="/upload/activity"
|
endpoint="/upload/activity"
|
||||||
accept={{
|
accept={{
|
||||||
'application/octet-stream': ['.fit'],
|
'application/octet-stream': ['.fit'],
|
||||||
'application/gpx+xml': ['.gpx'],
|
'application/gpx+xml': ['.gpx'],
|
||||||
'text/xml': ['.gpx'],
|
'text/xml': ['.gpx', '.tcx'],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user