Multi-user via PocketID: account linking, group gating, admin user management
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the data layer was already fully user-scoped. This adds the missing pieces for running real multi-user: - auth.py callback: link by email to an existing un-linked account (so the admin keeps their data when first signing in by passkey), collision-safe username generation, and request the `groups` scope. - Group gating: optional pocketid_allowed_group (admin-config or POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the callback and redirected to /login?auth_error=not_authorized. - New admin users API (app/api/users.py): list users, promote/demote admin (guards against demoting/locking out the last admin or yourself), and delete a user with ordered bulk deletes of all their data + on-disk files. - ProfilePage: allowed-group field; LoginPage: rejected-login message; Layout: admin-only Users nav; new UsersPage. Resync milevault_export to current source (it had drifted many features behind — missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own CI) and add POCKETID_ALLOWED_GROUP to .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy import select, func, desc, delete
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
@@ -75,6 +75,30 @@ class LapOut(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/stats/ytd")
|
||||
async def ytd_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return year-to-date distance totals grouped by sport type."""
|
||||
from datetime import date, timezone
|
||||
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
|
||||
result = await db.execute(
|
||||
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
|
||||
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
|
||||
.group_by(Activity.sport_type)
|
||||
)
|
||||
rows = result.all()
|
||||
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
|
||||
return {
|
||||
"running_km": round(totals.get("running", 0), 2),
|
||||
"cycling_km": round(totals.get("cycling", 0), 2),
|
||||
"hiking_km": round(totals.get("hiking", 0), 2),
|
||||
"walking_km": round(totals.get("walking", 0), 2),
|
||||
"total_km": round(sum(totals.values()), 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ActivitySummary])
|
||||
async def list_activities(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -126,7 +150,6 @@ async def get_data_points(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Verify ownership
|
||||
act = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
@@ -211,3 +234,38 @@ async def delete_activity(
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
await db.delete(activity)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{activity_id}/reprocess")
|
||||
async def reprocess_activity(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Re-parse the source FIT file and update polyline, data points etc."""
|
||||
import os
|
||||
result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
activity = result.scalar_one_or_none()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
if not activity.source_file:
|
||||
raise HTTPException(status_code=400, detail="No source file stored for this activity")
|
||||
if not os.path.exists(activity.source_file):
|
||||
raise HTTPException(status_code=404, detail="Source file no longer exists on disk")
|
||||
|
||||
source_file = activity.source_file
|
||||
source_type = activity.source_type or "fit"
|
||||
|
||||
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
|
||||
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
|
||||
await db.delete(activity)
|
||||
await db.commit()
|
||||
|
||||
from app.workers.tasks import process_activity_file
|
||||
task = process_activity_file.delay(source_file, current_user.id, source_type)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
@@ -24,6 +24,27 @@ async def _get_pocketid_config(db: AsyncSession):
|
||||
return issuer, client_id, client_secret
|
||||
|
||||
|
||||
async def _get_allowed_group(db: AsyncSession):
|
||||
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
|
||||
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
|
||||
admin = result.scalar_one_or_none()
|
||||
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
|
||||
return (group or "").strip() or None
|
||||
|
||||
|
||||
async def _unique_username(db: AsyncSession, base: str) -> str:
|
||||
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
|
||||
base = (base or "user").strip() or "user"
|
||||
candidate = base
|
||||
n = 1
|
||||
while True:
|
||||
existing = await db.execute(select(User).where(User.username == candidate))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return candidate
|
||||
n += 1
|
||||
candidate = f"{base}-{n}"
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -77,9 +98,9 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
from urllib.parse import urlencode
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
"scope": "openid profile email groups",
|
||||
}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
@@ -92,31 +113,58 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{issuer}/token",
|
||||
f"{issuer}/api/oidc/token",
|
||||
data={"grant_type": "authorization_code", "code": code,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"client_id": client_id, "client_secret": client_secret},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||
tokens = resp.json()
|
||||
userinfo_resp = await client.get(
|
||||
f"{issuer}/userinfo",
|
||||
f"{issuer}/api/oidc/userinfo",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
userinfo = userinfo_resp.json()
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
sub = userinfo.get("sub")
|
||||
email = userinfo.get("email")
|
||||
preferred_username = userinfo.get("preferred_username") or email
|
||||
|
||||
# Group gating: if an allowed group is configured, the user must be in it.
|
||||
allowed_group = await _get_allowed_group(db)
|
||||
if allowed_group:
|
||||
groups = userinfo.get("groups") or []
|
||||
if allowed_group not in groups:
|
||||
return RedirectResponse(url="/login?auth_error=not_authorized")
|
||||
|
||||
# 1) Existing passkey identity → use it.
|
||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2) No passkey identity yet, but an account with this email exists and is
|
||||
# not already linked to a different passkey → link them (preserves data).
|
||||
if not user and email:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing and existing.pocketid_sub is None:
|
||||
existing.pocketid_sub = sub
|
||||
user = existing
|
||||
|
||||
# 3) Otherwise provision a new account with a collision-safe username.
|
||||
if not user:
|
||||
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
||||
base = preferred_username or (email.split("@")[0] if email else "user")
|
||||
username = await _unique_username(db, base)
|
||||
# Only set email if no other account already claims it (unique column).
|
||||
email_taken = False
|
||||
if email:
|
||||
dup = await db.execute(select(User).where(User.email == email))
|
||||
email_taken = dup.scalar_one_or_none() is not None
|
||||
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/?token={token}")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, GarminConnectConfig
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class GarminConfigIn(BaseModel):
|
||||
email: str
|
||||
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
|
||||
sync_enabled: bool = True
|
||||
sync_activities: bool = True
|
||||
sync_wellness: bool = True
|
||||
sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time
|
||||
|
||||
|
||||
class GarminConfigOut(BaseModel):
|
||||
email: str
|
||||
sync_enabled: bool
|
||||
sync_activities: bool
|
||||
sync_wellness: bool
|
||||
sync_lookback_days: int
|
||||
last_sync_at: Optional[datetime]
|
||||
last_sync_status: Optional[str]
|
||||
connected: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/config", response_model=GarminConfigOut)
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||
)
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
return GarminConfigOut(
|
||||
email="", sync_enabled=False, sync_activities=True,
|
||||
sync_wellness=True, sync_lookback_days=30,
|
||||
last_sync_at=None, last_sync_status=None, connected=False,
|
||||
)
|
||||
return GarminConfigOut(
|
||||
email=cfg.email,
|
||||
sync_enabled=cfg.sync_enabled,
|
||||
sync_activities=cfg.sync_activities,
|
||||
sync_wellness=cfg.sync_wellness,
|
||||
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
|
||||
last_sync_at=cfg.last_sync_at,
|
||||
last_sync_status=cfg.last_sync_status,
|
||||
connected=True,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config", response_model=GarminConfigOut)
|
||||
async def save_config(
|
||||
body: GarminConfigIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Save Garmin Connect settings. If a password is provided, re-authenticates and
|
||||
refreshes the stored OAuth token. If no password is provided, only updates the
|
||||
non-credential settings (toggles, lookback days) without re-logging in.
|
||||
"""
|
||||
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
|
||||
|
||||
result = await db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||
)
|
||||
cfg = result.scalar_one_or_none()
|
||||
|
||||
if body.password:
|
||||
# Credentials update — test-login before saving
|
||||
enc = encrypt_password(body.password)
|
||||
try:
|
||||
garmin, token_store = authenticate_garmin(body.email, enc, None)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}")
|
||||
|
||||
if cfg:
|
||||
cfg.email = body.email
|
||||
cfg.password_enc = enc
|
||||
cfg.token_store = token_store
|
||||
cfg.last_sync_status = "Credentials updated"
|
||||
else:
|
||||
cfg = GarminConnectConfig(
|
||||
user_id=current_user.id,
|
||||
email=body.email,
|
||||
password_enc=enc,
|
||||
token_store=token_store,
|
||||
last_sync_status="Connected",
|
||||
)
|
||||
db.add(cfg)
|
||||
else:
|
||||
# Settings-only update — password unchanged
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
|
||||
|
||||
cfg.sync_enabled = body.sync_enabled
|
||||
cfg.sync_activities = body.sync_activities
|
||||
cfg.sync_wellness = body.sync_wellness
|
||||
cfg.sync_lookback_days = body.sync_lookback_days
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(cfg)
|
||||
|
||||
return GarminConfigOut(
|
||||
email=cfg.email,
|
||||
sync_enabled=cfg.sync_enabled,
|
||||
sync_activities=cfg.sync_activities,
|
||||
sync_wellness=cfg.sync_wellness,
|
||||
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
|
||||
last_sync_at=cfg.last_sync_at,
|
||||
last_sync_status=cfg.last_sync_status,
|
||||
connected=True,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||
)
|
||||
cfg = result.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),
|
||||
):
|
||||
"""Enqueue an immediate Garmin Connect sync for this user."""
|
||||
result = await db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
|
||||
)
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg or not cfg.sync_enabled:
|
||||
raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled")
|
||||
|
||||
from app.workers.tasks import sync_garmin_connect_user
|
||||
task = sync_garmin_connect_user.delay(current_user.id)
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
@@ -1,9 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from pydantic import BaseModel, model_validator
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
|
||||
active_calories: Optional[float]
|
||||
total_calories: Optional[float]
|
||||
spo2_avg: Optional[float]
|
||||
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _strip_bb_values(self):
|
||||
if isinstance(self.body_battery, dict):
|
||||
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
|
||||
return self
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
|
||||
async def list_health_metrics(
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
limit: int = Query(365, ge=1, le=1000),
|
||||
limit: int = Query(365, ge=1, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
|
||||
if from_date:
|
||||
q = q.where(HealthMetric.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(HealthMetric.date <= to_date)
|
||||
q = q.order_by(desc(HealthMetric.date)).limit(limit)
|
||||
|
||||
if from_date:
|
||||
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
|
||||
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
|
||||
if to_date:
|
||||
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
|
||||
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
|
||||
|
||||
q = q.order_by(desc(HealthMetric.date)).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -73,8 +83,6 @@ async def health_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Latest values + 30-day averages for dashboard widgets."""
|
||||
# Latest record
|
||||
latest_result = await db.execute(
|
||||
select(HealthMetric)
|
||||
.where(HealthMetric.user_id == current_user.id)
|
||||
@@ -83,9 +91,7 @@ async def health_summary(
|
||||
)
|
||||
latest = latest_result.scalar_one_or_none()
|
||||
|
||||
# 30-day averages
|
||||
from datetime import timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
|
||||
avg_result = await db.execute(
|
||||
select(
|
||||
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
|
||||
@@ -97,7 +103,7 @@ async def health_summary(
|
||||
func.avg(HealthMetric.weight_kg).label("avg_weight"),
|
||||
).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
HealthMetric.date >= cutoff,
|
||||
func.date(HealthMetric.date) >= cutoff,
|
||||
)
|
||||
)
|
||||
avgs = avg_result.one()
|
||||
@@ -116,23 +122,48 @@ async def health_summary(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/intraday")
|
||||
async def intraday_health(
|
||||
date: str = Query(..., description="YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return intraday heart rate series for a specific day."""
|
||||
from datetime import date as _date
|
||||
from fastapi import HTTPException
|
||||
try:
|
||||
metric_date = _date.fromisoformat(date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
|
||||
|
||||
result = await db.execute(
|
||||
select(HealthMetric).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
func.date(HealthMetric.date) == metric_date,
|
||||
)
|
||||
)
|
||||
metric = result.scalar_one_or_none()
|
||||
return {
|
||||
"hr_values": metric.intraday_hr if metric else None,
|
||||
"body_battery": metric.body_battery if metric else None,
|
||||
"body_battery_hires": metric.body_battery_hires if metric else None,
|
||||
"sleep_stages": metric.sleep_stages if metric else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/manual")
|
||||
async def add_manual_metric(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually add or update a health metric for a given date."""
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from fastapi import HTTPException
|
||||
date_str = body.get("date")
|
||||
if not date_str:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="date required")
|
||||
|
||||
metric_date = datetime.fromisoformat(date_str)
|
||||
|
||||
# Check for existing
|
||||
existing = await db.execute(
|
||||
select(HealthMetric).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
@@ -153,4 +184,4 @@ async def add_manual_metric(
|
||||
db.add(metric)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
@@ -19,6 +19,7 @@ class ProfileUpdate(BaseModel):
|
||||
resting_heart_rate: Optional[int] = None
|
||||
birth_year: Optional[int] = None
|
||||
height_cm: Optional[float] = None
|
||||
biological_sex: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
@@ -29,6 +30,7 @@ class ProfileOut(BaseModel):
|
||||
resting_heart_rate: Optional[int]
|
||||
birth_year: Optional[int]
|
||||
height_cm: Optional[float]
|
||||
biological_sex: Optional[str]
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
|
||||
@@ -55,6 +57,7 @@ async def update_profile(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
old_max_hr = current_user.max_heart_rate
|
||||
if body.max_heart_rate is not None:
|
||||
if not (100 <= body.max_heart_rate <= 250):
|
||||
raise HTTPException(400, "Max HR must be 100–250")
|
||||
@@ -71,9 +74,18 @@ async def update_profile(
|
||||
if not (50 <= body.height_cm <= 300):
|
||||
raise HTTPException(400, "Height must be 50–300 cm")
|
||||
current_user.height_cm = body.height_cm
|
||||
if body.biological_sex is not None:
|
||||
if body.biological_sex not in ('male', 'female', ''):
|
||||
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
|
||||
current_user.biological_sex = body.biological_sex or None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
|
||||
from app.workers.tasks import recalculate_hr_zones_for_user
|
||||
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
|
||||
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
@@ -109,6 +121,7 @@ class PocketIDConfig(BaseModel):
|
||||
issuer: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
allowed_group: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/pocketid-config")
|
||||
@@ -119,10 +132,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
||||
# Show DB config if set, fall back to env
|
||||
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
||||
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
|
||||
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
|
||||
return {
|
||||
"issuer": issuer or "",
|
||||
"client_id": client_id or "",
|
||||
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
||||
"allowed_group": allowed_group or "",
|
||||
"enabled": bool(issuer and client_id),
|
||||
}
|
||||
|
||||
@@ -141,6 +156,8 @@ async def save_pocketid_config(
|
||||
current_user.pocketid_client_id = body.client_id or None
|
||||
if body.client_secret is not None:
|
||||
current_user.pocketid_client_secret = body.client_secret or None
|
||||
if body.allowed_group is not None:
|
||||
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -44,6 +44,36 @@ async def list_records(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/routes")
|
||||
async def route_records(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Fastest activity per named route (course records)."""
|
||||
from sqlalchemy import text
|
||||
rows = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (nr.id)
|
||||
nr.id AS route_id,
|
||||
nr.name AS route_name,
|
||||
nr.sport_type,
|
||||
nr.distance_m,
|
||||
nr.reference_polyline,
|
||||
a.id AS activity_id,
|
||||
a.name AS activity_name,
|
||||
a.duration_s,
|
||||
a.start_time,
|
||||
a.avg_speed_ms
|
||||
FROM named_routes nr
|
||||
JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id
|
||||
WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL
|
||||
ORDER BY nr.id, a.duration_s ASC
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
)
|
||||
return [dict(r._mapping) for r in rows]
|
||||
|
||||
|
||||
@router.get("/history/{distance_label}")
|
||||
async def record_history(
|
||||
distance_label: str,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
|
||||
distance_m: Optional[float]
|
||||
auto_detected: Optional[bool]
|
||||
created_at: datetime
|
||||
activity_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -47,22 +48,51 @@ class SegmentOut(BaseModel):
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str]
|
||||
auto_generated: Optional[bool] = False
|
||||
auto_generated_type: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AutoGenerateRequest(BaseModel):
|
||||
type: str # "1km" | "turns" | "hills"
|
||||
gradient_pct: float = 5.0
|
||||
turn_angle_deg: float = 45.0
|
||||
|
||||
|
||||
class SegmentTimeEntry(BaseModel):
|
||||
activity_id: int
|
||||
date: datetime
|
||||
name: str
|
||||
duration_s: float
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RouteOut])
|
||||
async def list_routes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Fetch routes with activity counts in one query
|
||||
count_subq = (
|
||||
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
|
||||
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
|
||||
.group_by(Activity.named_route_id)
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(NamedRoute)
|
||||
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
|
||||
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
|
||||
.where(NamedRoute.user_id == current_user.id)
|
||||
.order_by(desc(NamedRoute.created_at))
|
||||
)
|
||||
return result.scalars().all()
|
||||
rows = result.all()
|
||||
out = []
|
||||
for route, cnt in rows:
|
||||
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
|
||||
d["activity_count"] = cnt
|
||||
out.append(RouteOut(**d))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/recent-activities")
|
||||
@@ -176,6 +206,61 @@ async def route_activities(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
|
||||
async def merge_routes(
|
||||
route_id: int,
|
||||
source_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Move all activities from source route into route_id, then delete source route."""
|
||||
from sqlalchemy import update
|
||||
|
||||
target = (await db.execute(
|
||||
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
source = (await db.execute(
|
||||
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if not target or not source:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
if route_id == source_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
|
||||
|
||||
await db.execute(
|
||||
update(Activity)
|
||||
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
|
||||
.values(named_route_id=route_id)
|
||||
)
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
await db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{route_id}")
|
||||
async def delete_route(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from sqlalchemy import update as sa_update
|
||||
route = (await db.execute(
|
||||
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
|
||||
)).scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
# Unlink activities before deleting
|
||||
await db.execute(
|
||||
sa_update(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.values(named_route_id=None)
|
||||
)
|
||||
await db.delete(route)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{route_id}/assign-activity")
|
||||
async def assign_activity_to_route(
|
||||
route_id: int,
|
||||
@@ -198,12 +283,23 @@ async def assign_activity_to_route(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
|
||||
result = await db.execute(
|
||||
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
|
||||
)
|
||||
route = result.scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
return route
|
||||
|
||||
|
||||
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
|
||||
async def list_segments(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
result = await db.execute(
|
||||
select(RouteSegment)
|
||||
.where(RouteSegment.route_id == route_id)
|
||||
@@ -219,14 +315,258 @@ async def create_segment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
segment = RouteSegment(
|
||||
route_id=route_id,
|
||||
name=body.name,
|
||||
start_distance_m=body.start_distance_m,
|
||||
end_distance_m=body.end_distance_m,
|
||||
description=body.description,
|
||||
auto_generated=False,
|
||||
)
|
||||
db.add(segment)
|
||||
await db.commit()
|
||||
await db.refresh(segment)
|
||||
return segment
|
||||
|
||||
|
||||
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
|
||||
async def delete_segment(
|
||||
route_id: int,
|
||||
segment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
result = await db.execute(
|
||||
select(RouteSegment).where(
|
||||
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
|
||||
)
|
||||
)
|
||||
seg = result.scalar_one_or_none()
|
||||
if not seg:
|
||||
raise HTTPException(status_code=404, detail="Segment not found")
|
||||
await db.delete(seg)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
|
||||
async def auto_generate_segments(
|
||||
route_id: int,
|
||||
body: AutoGenerateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Auto-generate segments: 1km splits, turns, or hills."""
|
||||
from app.services.route_matcher import (
|
||||
generate_1km_segments, generate_turn_segments, generate_hill_segments,
|
||||
)
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
route = await _get_owned_route(route_id, current_user.id, db)
|
||||
|
||||
if body.type not in ("1km", "turns", "hills"):
|
||||
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
|
||||
|
||||
# Clear only auto-generated segments of the same type so other auto types are preserved
|
||||
await db.execute(
|
||||
sql_delete(RouteSegment).where(
|
||||
RouteSegment.route_id == route_id,
|
||||
RouteSegment.auto_generated == True,
|
||||
RouteSegment.auto_generated_type == body.type,
|
||||
)
|
||||
)
|
||||
|
||||
raw_segments: list[tuple[str, float, float]] = []
|
||||
|
||||
if body.type == "1km":
|
||||
if not route.distance_m:
|
||||
raise HTTPException(status_code=400, detail="Route has no distance recorded")
|
||||
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
|
||||
|
||||
elif body.type == "turns":
|
||||
if not route.reference_polyline:
|
||||
raise HTTPException(status_code=400, detail="Route has no polyline")
|
||||
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
|
||||
|
||||
elif body.type == "hills":
|
||||
if not route.reference_polyline:
|
||||
raise HTTPException(status_code=400, detail="Route has no polyline")
|
||||
# Find most recent matched activity for elevation data
|
||||
act_result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.order_by(desc(Activity.start_time))
|
||||
.limit(1)
|
||||
)
|
||||
act = act_result.scalar_one_or_none()
|
||||
if not act:
|
||||
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
|
||||
from app.models.user import ActivityDataPoint
|
||||
dp_result = await db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id == act.id)
|
||||
.order_by(ActivityDataPoint.timestamp)
|
||||
)
|
||||
dps = dp_result.scalars().all()
|
||||
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
|
||||
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
|
||||
|
||||
new_segments = []
|
||||
for name, start_m, end_m in raw_segments:
|
||||
seg = RouteSegment(
|
||||
route_id=route_id,
|
||||
name=name,
|
||||
start_distance_m=start_m,
|
||||
end_distance_m=end_m,
|
||||
auto_generated=True,
|
||||
auto_generated_type=body.type,
|
||||
)
|
||||
db.add(seg)
|
||||
new_segments.append(seg)
|
||||
|
||||
await db.commit()
|
||||
for seg in new_segments:
|
||||
await db.refresh(seg)
|
||||
return new_segments
|
||||
|
||||
|
||||
class SegmentBestOut(BaseModel):
|
||||
segment_id: int
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
auto_generated: bool
|
||||
best_s: Optional[float]
|
||||
best_activity_id: Optional[int]
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
|
||||
async def get_segment_bests(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return best time per segment across all matched activities for a route."""
|
||||
from app.services.route_matcher import find_segment_times
|
||||
from app.models.user import ActivityDataPoint
|
||||
from collections import defaultdict
|
||||
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
|
||||
segs_result = await db.execute(
|
||||
select(RouteSegment)
|
||||
.where(RouteSegment.route_id == route_id)
|
||||
.order_by(RouteSegment.start_distance_m)
|
||||
)
|
||||
segments = segs_result.scalars().all()
|
||||
if not segments:
|
||||
return []
|
||||
|
||||
acts_result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.order_by(desc(Activity.start_time))
|
||||
.limit(20)
|
||||
)
|
||||
activities = acts_result.scalars().all()
|
||||
if not activities:
|
||||
return [
|
||||
SegmentBestOut(
|
||||
segment_id=s.id, name=s.name,
|
||||
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
|
||||
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
|
||||
)
|
||||
for s in segments
|
||||
]
|
||||
|
||||
act_ids = [a.id for a in activities]
|
||||
|
||||
dp_result = await db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id.in_(act_ids))
|
||||
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
|
||||
)
|
||||
all_dps = dp_result.scalars().all()
|
||||
|
||||
# Group data points by activity_id
|
||||
dp_by_act = defaultdict(list)
|
||||
for dp in all_dps:
|
||||
if dp.distance_m is not None:
|
||||
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
|
||||
|
||||
bests = []
|
||||
for seg in segments:
|
||||
best_s = None
|
||||
best_act_id = None
|
||||
count = 0
|
||||
for act_id in act_ids:
|
||||
dp_list = dp_by_act.get(act_id, [])
|
||||
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
|
||||
if duration is not None:
|
||||
count += 1
|
||||
if best_s is None or duration < best_s:
|
||||
best_s = duration
|
||||
best_act_id = act_id
|
||||
bests.append(SegmentBestOut(
|
||||
segment_id=seg.id, name=seg.name,
|
||||
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
|
||||
auto_generated=bool(seg.auto_generated),
|
||||
best_s=best_s, best_activity_id=best_act_id, count=count,
|
||||
))
|
||||
return bests
|
||||
|
||||
|
||||
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
|
||||
async def get_segment_times(
|
||||
route_id: int,
|
||||
segment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the last 10 times this segment was traversed across matched activities."""
|
||||
from app.services.route_matcher import find_segment_times
|
||||
from app.models.user import ActivityDataPoint
|
||||
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
|
||||
seg_result = await db.execute(
|
||||
select(RouteSegment).where(
|
||||
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
|
||||
)
|
||||
)
|
||||
seg = seg_result.scalar_one_or_none()
|
||||
if not seg:
|
||||
raise HTTPException(status_code=404, detail="Segment not found")
|
||||
|
||||
acts_result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.order_by(desc(Activity.start_time))
|
||||
.limit(10)
|
||||
)
|
||||
activities = acts_result.scalars().all()
|
||||
|
||||
times = []
|
||||
for act in activities:
|
||||
dp_result = await db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id == act.id)
|
||||
.order_by(ActivityDataPoint.timestamp)
|
||||
)
|
||||
dps = dp_result.scalars().all()
|
||||
dp_list = [
|
||||
{"distance_m": p.distance_m, "timestamp": p.timestamp}
|
||||
for p in dps
|
||||
if p.distance_m is not None
|
||||
]
|
||||
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
|
||||
if duration:
|
||||
times.append(SegmentTimeEntry(
|
||||
activity_id=act.id,
|
||||
date=act.start_time,
|
||||
name=act.name,
|
||||
duration_s=duration,
|
||||
))
|
||||
return times
|
||||
|
||||
@@ -75,6 +75,22 @@ async def upload_garmin_export(
|
||||
fit_path = extract_dir / name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
elif lower.endswith(".zip"):
|
||||
# Garmin exports nest activity FIT files inside sub-zips
|
||||
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
|
||||
nested_zip_path = extract_dir / name
|
||||
nested_extract = nested_zip_path.parent / nested_zip_path.stem
|
||||
nested_extract.mkdir(exist_ok=True)
|
||||
try:
|
||||
with zipfile.ZipFile(nested_zip_path) as nzf:
|
||||
nzf.extractall(nested_extract)
|
||||
for nested_name in nzf.namelist():
|
||||
if nested_name.lower().endswith(".fit"):
|
||||
fit_path = nested_extract / nested_name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
except zipfile.BadZipFile:
|
||||
pass
|
||||
|
||||
# Queue health/wellness data extraction
|
||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||
@@ -82,7 +98,7 @@ async def upload_garmin_export(
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"health_task": health_task.id,
|
||||
"task_id": health_task.id,
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +132,7 @@ async def upload_strava_export(
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"task_id": task_ids[-1] if task_ids else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Admin-only user management: list provisioned users, promote/demote admin,
|
||||
and delete a user together with all of their data.
|
||||
|
||||
New users are normally provisioned just-in-time on first PocketID login
|
||||
(see app/api/auth.py). This router is the in-app surface for managing them.
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
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, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(current_user: User):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
|
||||
async def _admin_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
has_passkey: bool
|
||||
activity_count: int
|
||||
created_at: Optional[str]
|
||||
|
||||
|
||||
class AdminUpdate(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
# activity counts per user in one grouped query
|
||||
counts = dict(
|
||||
(await db.execute(
|
||||
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
|
||||
)).all()
|
||||
)
|
||||
result = await db.execute(select(User).order_by(User.id))
|
||||
users = result.scalars().all()
|
||||
return [
|
||||
UserOut(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
is_admin=u.is_admin,
|
||||
has_passkey=u.pocketid_sub is not None,
|
||||
activity_count=counts.get(u.id, 0),
|
||||
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def set_admin(
|
||||
user_id: int,
|
||||
body: AdminUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "You cannot change your own admin status")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# Demoting the last remaining admin would lock everyone out.
|
||||
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
|
||||
raise HTTPException(400, "Cannot demote the last admin")
|
||||
|
||||
user.is_admin = body.is_admin
|
||||
await db.commit()
|
||||
return {"status": "ok", "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "You cannot delete your own account")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
if user.is_admin and await _admin_count(db) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the last admin")
|
||||
|
||||
# Ordered deletes: PersonalRecord and the activity/route child tables have no
|
||||
# cascade path from User, so remove them before the parents to avoid FK errors.
|
||||
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
|
||||
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
|
||||
|
||||
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
|
||||
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
|
||||
await db.execute(delete(Activity).where(Activity.user_id == user_id))
|
||||
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
|
||||
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
|
||||
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
# Remove the user's uploaded files from disk (best-effort).
|
||||
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -6,28 +6,24 @@ from typing import Optional
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = Field(..., env="DATABASE_URL")
|
||||
|
||||
# Redis
|
||||
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
||||
|
||||
# Auth
|
||||
secret_key: str = Field(..., env="SECRET_KEY")
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Admin account - optional so the worker (which doesn't seed users) can start
|
||||
# without it. The backend service checks this at seed time.
|
||||
# Admin account
|
||||
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
||||
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
|
||||
|
||||
# Base URL - used for OAuth callbacks
|
||||
base_url: str = Field("https://milevault.jarrett.eu", env="BASE_URL")
|
||||
# PocketID OIDC (optional)
|
||||
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
|
||||
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
|
||||
|
||||
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
||||
# Files
|
||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||
|
||||
# Environment
|
||||
environment: str = Field("production", env="ENVIRONMENT")
|
||||
|
||||
@@ -36,4 +32,4 @@ class Settings(BaseSettings):
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload, profile
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -40,6 +40,131 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"TimescaleDB hypertable skipped: {e}")
|
||||
|
||||
# Add columns that were introduced after initial table creation (non-fatal)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE garmin_connect_configs "
|
||||
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Column migration skipped: {e}")
|
||||
|
||||
# health_metrics columns added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
for stmt in [
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
|
||||
]:
|
||||
await conn.execute(text(stmt))
|
||||
except Exception as e:
|
||||
print(f"health_metrics column migration skipped: {e}")
|
||||
|
||||
# biological_sex column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.biological_sex column migration skipped: {e}")
|
||||
|
||||
# pocketid_allowed_group column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.pocketid_allowed_group column migration skipped: {e}")
|
||||
|
||||
# route_segments auto_generated column added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"route_segments column migration skipped: {e}")
|
||||
|
||||
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("""
|
||||
UPDATE health_metrics SET
|
||||
avg_hr_day = sub.avg_hr,
|
||||
max_hr_day = sub.max_hr
|
||||
FROM (
|
||||
SELECT id,
|
||||
AVG((elem->>1)::float) AS avg_hr,
|
||||
MAX((elem->>1)::float) AS max_hr
|
||||
FROM health_metrics,
|
||||
json_array_elements(intraday_hr) AS elem
|
||||
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
|
||||
AND intraday_hr IS NOT NULL
|
||||
AND (elem->>1)::float > 0
|
||||
GROUP BY id
|
||||
) sub
|
||||
WHERE health_metrics.id = sub.id
|
||||
"""))
|
||||
except Exception as e:
|
||||
print(f"avg_hr_day backfill skipped: {e}")
|
||||
|
||||
# Replace the all-columns unique constraint on personal_records with a partial
|
||||
# index (only current records must be unique per user/sport/distance).
|
||||
# The old constraint also covered is_current_record=False rows, causing
|
||||
# UniqueViolation crashes when multiple workers deactivate the same PR.
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE personal_records "
|
||||
"DROP CONSTRAINT IF EXISTS uq_pr_current"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
|
||||
"ON personal_records (user_id, sport_type, distance_m) "
|
||||
"WHERE is_current_record = true"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"PR constraint migration skipped: {e}")
|
||||
|
||||
# Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted
|
||||
# without first manually unlinking every activity.
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities "
|
||||
"DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities "
|
||||
"ADD CONSTRAINT activities_named_route_id_fkey "
|
||||
"FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"FK migration skipped: {e}")
|
||||
|
||||
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
|
||||
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
|
||||
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"avg_speed_ms fix skipped: {e}")
|
||||
|
||||
# Seed admin user (only if password is configured)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
@@ -98,6 +223,8 @@ app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
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(users.router, prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Float, DateTime, Boolean,
|
||||
ForeignKey, Text, JSON, Index, UniqueConstraint
|
||||
ForeignKey, Text, JSON, Index, UniqueConstraint, text
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
@@ -27,16 +27,40 @@ class User(Base):
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
height_cm = Column(Float, nullable=True)
|
||||
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
|
||||
|
||||
# PocketID config (stored per-user so admin can set via UI)
|
||||
pocketid_issuer = Column(String(512), nullable=True)
|
||||
pocketid_client_id = Column(String(256), nullable=True)
|
||||
pocketid_client_secret = Column(String(256), nullable=True)
|
||||
# Only PocketID users in this group may sign in. Null/blank = allow all.
|
||||
pocketid_allowed_group = Column(String(128), nullable=True)
|
||||
|
||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||
health_metrics = relationship("HealthMetric", 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")
|
||||
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class GarminConnectConfig(Base):
|
||||
"""Per-user Garmin Connect credentials and sync state."""
|
||||
__tablename__ = "garmin_connect_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||
email = Column(String(256), nullable=False)
|
||||
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
|
||||
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
|
||||
sync_enabled = Column(Boolean, default=True)
|
||||
sync_activities = Column(Boolean, default=True)
|
||||
sync_wellness = Column(Boolean, default=True)
|
||||
sync_lookback_days = Column(Integer, default=30) # -1 = all-time
|
||||
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="garmin_connect_config")
|
||||
|
||||
|
||||
class WeightLog(Base):
|
||||
@@ -81,7 +105,7 @@ class Activity(Base):
|
||||
calories = Column(Float, nullable=True)
|
||||
training_stress_score = Column(Float, nullable=True)
|
||||
vo2max_estimate = Column(Float, nullable=True)
|
||||
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
||||
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
|
||||
polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
source_file = Column(String(512), nullable=True)
|
||||
@@ -160,6 +184,8 @@ class RouteSegment(Base):
|
||||
start_distance_m = Column(Float, nullable=False)
|
||||
end_distance_m = Column(Float, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
auto_generated = Column(Boolean, default=False)
|
||||
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
|
||||
|
||||
route = relationship("NamedRoute", back_populates="segments")
|
||||
|
||||
@@ -178,8 +204,12 @@ class PersonalRecord(Base):
|
||||
is_current_record = Column(Boolean, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
||||
name="uq_pr_current"),
|
||||
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
|
||||
# (created in init_db), which only covers is_current_record=true rows.
|
||||
# The old all-columns UniqueConstraint was dropped because it incorrectly
|
||||
# constrained is_current_record=false rows too, causing multi-worker races.
|
||||
Index("uq_pr_current_active", "user_id", "sport_type", "distance_m",
|
||||
postgresql_where=text("is_current_record = true"), unique=True),
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +248,10 @@ class HealthMetric(Base):
|
||||
active_calories = Column(Float, nullable=True)
|
||||
total_calories = Column(Float, nullable=True)
|
||||
spo2_avg = Column(Float, nullable=True)
|
||||
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
|
||||
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
|
||||
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
|
||||
sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
"""
|
||||
FIT and GPX file parser using:
|
||||
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
|
||||
- gpxpy for .gpx files
|
||||
|
||||
The official SDK correctly handles scale/offset, component expansion,
|
||||
semicircle-to-degree conversion, and HR message merging.
|
||||
FIT and GPX file parser.
|
||||
Parses FIT files directly using the Garmin SDK but applies manual
|
||||
scale conversion for fields where the SDK doesn't auto-convert.
|
||||
"""
|
||||
import math
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import gpxpy
|
||||
import polyline as polyline_lib
|
||||
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||
"""Distance in metres between two GPS points."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
def _semicircles_to_deg(val):
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
result = float(val) * SEMICIRCLES_TO_DEG
|
||||
if -90 <= result <= 90 or -180 <= result <= 180:
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _safe_float(val) -> Optional[float]:
|
||||
@@ -34,7 +34,17 @@ def _safe_float(val) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _bounding_box(coords: list) -> Optional[dict]:
|
||||
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
|
||||
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
|
||||
fv = _safe_float(val)
|
||||
if fv is None or fv >= 65.0:
|
||||
if dist_m and dur_s and float(dur_s) > 0:
|
||||
return float(dist_m) / float(dur_s)
|
||||
return None
|
||||
return fv
|
||||
|
||||
|
||||
def _bounding_box(coords):
|
||||
if not coords:
|
||||
return None
|
||||
lats = [c[0] for c in coords]
|
||||
@@ -43,18 +53,35 @@ def _bounding_box(coords: list) -> Optional[dict]:
|
||||
"min_lon": min(lons), "max_lon": max(lons)}
|
||||
|
||||
|
||||
def parse_fit_file(filepath: str) -> dict:
|
||||
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
def _to_dt(val) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
|
||||
if isinstance(val, (int, float)):
|
||||
try:
|
||||
return datetime.fromtimestamp(int(val) + FIT_EPOCH_S, tz=timezone.utc)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
session = {}
|
||||
|
||||
def _is_valid_lat(v):
|
||||
return v is not None and -90 <= v <= 90
|
||||
|
||||
|
||||
def _is_valid_lon(v):
|
||||
return v is not None and -180 <= v <= 180
|
||||
|
||||
|
||||
def parse_fit_file(filepath: str) -> dict:
|
||||
session_data = {}
|
||||
records = []
|
||||
laps = []
|
||||
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
nonlocal session
|
||||
if mesg_num == 18: # session
|
||||
session = msg
|
||||
session_data.update(msg)
|
||||
elif mesg_num == 20: # record
|
||||
records.append(msg)
|
||||
elif mesg_num == 19: # lap
|
||||
@@ -73,68 +100,113 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
mesg_listener=listener,
|
||||
)
|
||||
|
||||
# Map sport type
|
||||
sport = str(session.get("sport", "generic")).lower()
|
||||
sport_map = {
|
||||
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
||||
"hiking": "hiking", "walking": "walking", "generic": "other",
|
||||
"open_water_swimming": "swimming", "trail_running": "running",
|
||||
"e_biking": "cycling",
|
||||
}
|
||||
sport_type = sport_map.get(sport, sport)
|
||||
# The SDK may return field names in camelCase or snake_case depending on version.
|
||||
# Try both. Also handle raw timestamp integers for start_time.
|
||||
def get(d, *keys):
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
start_time = session.get("start_time")
|
||||
if isinstance(start_time, datetime) and start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
|
||||
sport_map = {
|
||||
"running": "running", "cycling": "cycling",
|
||||
"hiking": "hiking", "walking": "walking",
|
||||
"generic": "other", "trail_running": "running",
|
||||
"e_biking": "cycling", "open_water_swimming": "other",
|
||||
}
|
||||
sport_type = sport_map.get(sport_raw, sport_raw)
|
||||
|
||||
# start_time — SDK may return datetime or raw int
|
||||
start_time_raw = get(session_data, "startTime", "start_time")
|
||||
start_time = _to_dt(start_time_raw)
|
||||
|
||||
# Position fields — the SDK may or may not convert semicircles.
|
||||
# Check if values look like semicircles (>= 90 for lat) and convert if so.
|
||||
def get_lat(d):
|
||||
v = get(d, "positionLat", "position_lat")
|
||||
if v is None:
|
||||
return None
|
||||
fv = _safe_float(v)
|
||||
if fv is None:
|
||||
return None
|
||||
# If absolute value > 90, it's semicircles
|
||||
if abs(fv) > 90:
|
||||
fv = fv * SEMICIRCLES_TO_DEG
|
||||
return fv if _is_valid_lat(fv) else None
|
||||
|
||||
def get_lon(d):
|
||||
v = get(d, "positionLong", "position_long")
|
||||
if v is None:
|
||||
return None
|
||||
fv = _safe_float(v)
|
||||
if fv is None:
|
||||
return None
|
||||
if abs(fv) > 180:
|
||||
fv = fv * SEMICIRCLES_TO_DEG
|
||||
return fv if _is_valid_lon(fv) else None
|
||||
|
||||
# Build GPS track
|
||||
coords = [
|
||||
(r["position_lat"], r["position_long"])
|
||||
for r in records
|
||||
if r.get("position_lat") is not None and r.get("position_long") is not None
|
||||
]
|
||||
coords = []
|
||||
for r in records:
|
||||
lat = get_lat(r)
|
||||
lon = get_lon(r)
|
||||
if lat is not None and lon is not None:
|
||||
coords.append((lat, lon))
|
||||
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Normalize data points
|
||||
normalized_points = []
|
||||
for r in records:
|
||||
ts = r.get("timestamp")
|
||||
if isinstance(ts, datetime) and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
ts = _to_dt(get(r, "timestamp"))
|
||||
lat = get_lat(r)
|
||||
lon = get_lon(r)
|
||||
|
||||
altitude = get(r, "altitude", "enhancedAltitude", "enhanced_altitude")
|
||||
hr = get(r, "heartRate", "heart_rate")
|
||||
cadence = get(r, "cadence")
|
||||
speed = get(r, "speed", "enhancedSpeed", "enhanced_speed")
|
||||
power = get(r, "power")
|
||||
temp = get(r, "temperature")
|
||||
distance = get(r, "distance")
|
||||
|
||||
normalized_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": r.get("position_lat"),
|
||||
"longitude": r.get("position_long"),
|
||||
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
|
||||
"heart_rate": r.get("heart_rate"),
|
||||
"cadence": r.get("cadence") or r.get("fractional_cadence"),
|
||||
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
|
||||
"power": r.get("power"),
|
||||
"temperature_c": r.get("temperature"),
|
||||
"distance_m": r.get("distance"),
|
||||
"latitude": _safe_float(lat),
|
||||
"longitude": _safe_float(lon),
|
||||
"altitude_m": _safe_float(altitude),
|
||||
"heart_rate": _safe_float(hr),
|
||||
"cadence": _safe_float(cadence),
|
||||
"speed_ms": _safe_float(speed),
|
||||
"power": _safe_float(power),
|
||||
"temperature_c": _safe_float(temp),
|
||||
"distance_m": _safe_float(distance),
|
||||
})
|
||||
|
||||
# Normalize laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = lap.get("start_time")
|
||||
if isinstance(ls, datetime) and ls.tzinfo is None:
|
||||
ls = ls.replace(tzinfo=timezone.utc)
|
||||
ls = _to_dt(get(lap, "startTime", "start_time"))
|
||||
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
|
||||
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
||||
"distance_m": _safe_float(lap.get("total_distance")),
|
||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
||||
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
||||
"avg_power": _safe_float(lap.get("avg_power")),
|
||||
"duration_s": lap_dur,
|
||||
"distance_m": lap_dist,
|
||||
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
|
||||
"avg_speed_ms": _sanitize_speed(
|
||||
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||
dist_m=lap_dist, dur_s=lap_dur,
|
||||
),
|
||||
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
|
||||
})
|
||||
|
||||
# Build activity name
|
||||
name = session.get("sport", "Activity").title()
|
||||
name = sport_type.title()
|
||||
if start_time:
|
||||
name += " " + start_time.strftime("%Y-%m-%d")
|
||||
|
||||
@@ -142,21 +214,28 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
"name": name,
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": _safe_float(session.get("total_distance")),
|
||||
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
||||
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
||||
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
||||
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
||||
"avg_power": _safe_float(session.get("avg_power")),
|
||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
||||
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
|
||||
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
|
||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
||||
"calories": _safe_float(session.get("total_calories")),
|
||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
||||
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
||||
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
|
||||
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
|
||||
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
|
||||
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
|
||||
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
|
||||
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
|
||||
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
|
||||
"avg_speed_ms": _sanitize_speed(
|
||||
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
|
||||
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
|
||||
),
|
||||
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
|
||||
"enhancedMaxSpeed", "enhanced_max_speed")),
|
||||
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
|
||||
"calories": _safe_float(get(session_data, "totalCalories", "total_calories")),
|
||||
"training_stress_score": _safe_float(get(session_data, "trainingStressScore",
|
||||
"training_stress_score")),
|
||||
"vo2max_estimate": _safe_float(get(session_data, "totalTrainingEffect",
|
||||
"total_training_effect")),
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
@@ -166,7 +245,6 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
"""Parse a GPX file."""
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
@@ -180,7 +258,6 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
ts = pt.time
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
extensions = {}
|
||||
if pt.extensions:
|
||||
for ext in pt.extensions:
|
||||
@@ -190,11 +267,9 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
extensions[tag] = float(child.text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
data_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": pt.latitude,
|
||||
"longitude": pt.longitude,
|
||||
"latitude": pt.latitude, "longitude": pt.longitude,
|
||||
"altitude_m": pt.elevation,
|
||||
"heart_rate": extensions.get("hr"),
|
||||
"cadence": extensions.get("cad"),
|
||||
@@ -204,91 +279,61 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
"distance_m": None,
|
||||
})
|
||||
|
||||
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
||||
if p["latitude"] and p["longitude"]]
|
||||
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)
|
||||
|
||||
# Add cumulative distance
|
||||
total_dist = 0.0
|
||||
prev = None
|
||||
for p in data_points:
|
||||
if p["latitude"] and p["longitude"]:
|
||||
if prev:
|
||||
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
|
||||
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
|
||||
|
||||
# Elevation gain/loss
|
||||
uphill, downhill = 0.0, 0.0
|
||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||
for i in range(1, len(alts)):
|
||||
diff = alts[i] - alts[i-1]
|
||||
if diff > 0:
|
||||
uphill += diff
|
||||
else:
|
||||
downhill += abs(diff)
|
||||
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 = data_points[0]["timestamp"] if data_points else None
|
||||
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||
|
||||
sport = "running"
|
||||
if track.type:
|
||||
sport = track.type.lower()
|
||||
sport = track.type.lower() if track.type else "running"
|
||||
|
||||
return {
|
||||
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||
"sport_type": sport,
|
||||
"start_time": start_time_str,
|
||||
"distance_m": total_dist,
|
||||
"duration_s": duration,
|
||||
"elevation_gain_m": uphill,
|
||||
"elevation_loss_m": downhill,
|
||||
"sport_type": sport, "start_time": start_time_str,
|
||||
"distance_m": total_dist, "duration_s": duration,
|
||||
"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_cadence": None, "avg_power": None, "normalized_power": None,
|
||||
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||
"max_speed_ms": None,
|
||||
"avg_temperature_c": None,
|
||||
"calories": None,
|
||||
"training_stress_score": None,
|
||||
"vo2max_estimate": None,
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "gpx",
|
||||
"data_points": data_points,
|
||||
"laps": [],
|
||||
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
|
||||
"training_stress_score": None, "vo2max_estimate": None,
|
||||
"polyline": encoded_polyline, "bounding_box": bounding_box,
|
||||
"source_type": "gpx", "data_points": data_points, "laps": [],
|
||||
}
|
||||
|
||||
|
||||
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||
"""
|
||||
Calculate % time in each HR zone using the user's configured max HR.
|
||||
|
||||
Zones follow the standard 5-zone model as % of max HR:
|
||||
Z1 Recovery: < 60%
|
||||
Z2 Base: 60 - 70%
|
||||
Z3 Tempo: 70 - 80%
|
||||
Z4 Threshold: 80 - 90%
|
||||
Z5 Max: > 90%
|
||||
|
||||
user_max_hr should be the user's actual physiological max HR, NOT the
|
||||
highest HR recorded in this activity. Using activity max shifts all zones
|
||||
upward and makes easy runs look harder than they are.
|
||||
"""
|
||||
if not user_max_hr or user_max_hr < 100:
|
||||
return {}
|
||||
|
||||
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||
zones = {k: 0 for k in zone_keys}
|
||||
total = 0
|
||||
|
||||
for p in data_points:
|
||||
hr = p.get("heart_rate")
|
||||
if not hr or hr < 20:
|
||||
@@ -300,8 +345,7 @@ def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||
zones[key] += 1
|
||||
break
|
||||
else:
|
||||
zones["z5"] += 1 # anything above 90% goes to z5
|
||||
|
||||
zones["z5"] += 1
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
return {}
|
||||
@@ -524,7 +524,7 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
||||
|
||||
if stats:
|
||||
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
||||
_set(row, "avg_hr_day", stats.get("averageHeartRate"))
|
||||
# averageHeartRate is absent from get_stats; avg_hr_day is computed below from intraday HR
|
||||
_set(row, "max_hr_day", stats.get("maxHeartRate"))
|
||||
_set(row, "steps", stats.get("totalSteps"))
|
||||
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||
|
||||
@@ -63,11 +63,21 @@ def routes_are_similar(
|
||||
bb1: Optional[dict],
|
||||
bb2: Optional[dict],
|
||||
dtw_threshold_m: float = 80.0,
|
||||
dist1: Optional[float] = None,
|
||||
dist2: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if two activities are on sufficiently similar routes.
|
||||
First does a cheap bounding box check, then DTW on downsampled tracks.
|
||||
When dist1/dist2 are provided:
|
||||
- Rejects if distance differs by more than 2.5%
|
||||
- Uses 3% of route distance as the DTW threshold (capped at 300m)
|
||||
"""
|
||||
if dist1 and dist2 and dist1 > 0 and dist2 > 0:
|
||||
if abs(dist1 - dist2) / max(dist1, dist2) > 0.025:
|
||||
return False
|
||||
dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0)
|
||||
|
||||
if bb1 and bb2:
|
||||
if not bounding_boxes_overlap(bb1, bb2):
|
||||
return False
|
||||
@@ -164,6 +174,154 @@ def find_best_split_time(
|
||||
return best
|
||||
|
||||
|
||||
def _bearing(p1: tuple, p2: tuple) -> float:
|
||||
"""Compass bearing in degrees (0-360) from p1 to p2."""
|
||||
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
|
||||
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
|
||||
dlon = lon2 - lon1
|
||||
x = math.sin(dlon) * math.cos(lat2)
|
||||
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||
return math.degrees(math.atan2(x, y)) % 360
|
||||
|
||||
|
||||
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
|
||||
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
|
||||
if not encoded_polyline:
|
||||
return []
|
||||
km_count = int(total_dist_m / 1000)
|
||||
segments = []
|
||||
for i in range(km_count):
|
||||
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
|
||||
remainder = total_dist_m - km_count * 1000
|
||||
if remainder >= 200:
|
||||
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
|
||||
return segments
|
||||
|
||||
|
||||
def generate_turn_segments(
|
||||
encoded_polyline: str,
|
||||
turn_angle_deg: float = 45.0,
|
||||
) -> list[tuple[str, float, float]]:
|
||||
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
|
||||
coords = decode_polyline_to_coords(encoded_polyline)
|
||||
if len(coords) < 3:
|
||||
return []
|
||||
|
||||
cum_dists = [0.0]
|
||||
for i in range(1, len(coords)):
|
||||
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
|
||||
total = cum_dists[-1]
|
||||
|
||||
HALF_WINDOW = 100.0 # metres either side of candidate turn point
|
||||
|
||||
turn_centers: list[float] = []
|
||||
for i in range(1, len(coords) - 1):
|
||||
# Find index ~HALF_WINDOW before and after
|
||||
start_i = i
|
||||
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
|
||||
start_i -= 1
|
||||
end_i = i
|
||||
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
|
||||
end_i += 1
|
||||
if start_i == i or end_i == i:
|
||||
continue
|
||||
|
||||
b1 = _bearing(coords[start_i], coords[i])
|
||||
b2 = _bearing(coords[i], coords[end_i])
|
||||
diff = abs(b2 - b1) % 360
|
||||
if diff > 180:
|
||||
diff = 360 - diff
|
||||
if diff >= turn_angle_deg:
|
||||
turn_centers.append(cum_dists[i])
|
||||
|
||||
if not turn_centers:
|
||||
return []
|
||||
|
||||
# Cluster turns within 150 m of each other → one segment per cluster
|
||||
clusters: list[list[float]] = [[turn_centers[0]]]
|
||||
for d in turn_centers[1:]:
|
||||
if d - clusters[-1][-1] < 150:
|
||||
clusters[-1].append(d)
|
||||
else:
|
||||
clusters.append([d])
|
||||
|
||||
segments = []
|
||||
for cluster in clusters:
|
||||
center = sum(cluster) / len(cluster)
|
||||
start = max(0.0, center - HALF_WINDOW)
|
||||
end = min(total, center + HALF_WINDOW)
|
||||
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
|
||||
return segments
|
||||
|
||||
|
||||
def generate_hill_segments(
|
||||
data_points: list[dict],
|
||||
gradient_pct: float = 5.0,
|
||||
) -> list[tuple[str, float, float]]:
|
||||
"""
|
||||
Detect uphill sections using activity data points (with altitude_m + distance_m).
|
||||
Returns list of (name, start_m, end_m).
|
||||
"""
|
||||
pts = [
|
||||
(p["distance_m"], p["altitude_m"])
|
||||
for p in data_points
|
||||
if p.get("distance_m") is not None and p.get("altitude_m") is not None
|
||||
]
|
||||
if len(pts) < 10:
|
||||
return []
|
||||
pts.sort(key=lambda x: x[0])
|
||||
dists = [p[0] for p in pts]
|
||||
alts = [p[1] for p in pts]
|
||||
|
||||
# Smooth altitude with a sliding window to reduce GPS noise
|
||||
SMOOTH = 10
|
||||
smooth_alts = []
|
||||
for i in range(len(alts)):
|
||||
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
|
||||
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
|
||||
|
||||
grad_threshold = gradient_pct / 100.0
|
||||
MIN_HILL_M = 200.0
|
||||
|
||||
in_hill = False
|
||||
hill_start_idx = 0
|
||||
segments = []
|
||||
|
||||
for i in range(1, len(dists)):
|
||||
d_dist = dists[i] - dists[i - 1]
|
||||
if d_dist <= 0:
|
||||
continue
|
||||
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
|
||||
|
||||
if grad >= grad_threshold and not in_hill:
|
||||
in_hill = True
|
||||
hill_start_idx = i - 1
|
||||
elif grad < grad_threshold and in_hill:
|
||||
length = dists[i - 1] - dists[hill_start_idx]
|
||||
if length >= MIN_HILL_M:
|
||||
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
|
||||
start_km = dists[hill_start_idx] / 1000
|
||||
segments.append((
|
||||
f"Hill at {start_km:.1f} km (+{gain} m)",
|
||||
dists[hill_start_idx],
|
||||
dists[i - 1],
|
||||
))
|
||||
in_hill = False
|
||||
|
||||
if in_hill:
|
||||
length = dists[-1] - dists[hill_start_idx]
|
||||
if length >= MIN_HILL_M:
|
||||
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
|
||||
start_km = dists[hill_start_idx] / 1000
|
||||
segments.append((
|
||||
f"Hill at {start_km:.1f} km (+{gain} m)",
|
||||
dists[hill_start_idx],
|
||||
dists[-1],
|
||||
))
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
STANDARD_DISTANCES = [
|
||||
(400, "400m"),
|
||||
(800, "800m"),
|
||||
|
||||
@@ -1,56 +1,61 @@
|
||||
"""
|
||||
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
|
||||
The SDK with convert_types_to_strings=True returns snake_case field names.
|
||||
|
||||
The official SDK (garmin-fit-sdk) correctly handles:
|
||||
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
|
||||
- Garmin proprietary messages stored by numeric mesg_num
|
||||
- Unknown fields stored by field definition number
|
||||
- Scale/offset application, component expansion, HR merging
|
||||
|
||||
Fenix 6X proprietary message numbers identified by binary analysis:
|
||||
55 - activity accumulation snapshots (cumulative steps, HR per interval)
|
||||
103 - daily totals summary (total steps, floors, calories)
|
||||
211 - resting HR + HRV summary
|
||||
227 - per-minute stress level + heart rate (most valuable for health dashboard)
|
||||
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
|
||||
and a stage name. Duration of each stage = gap to the next stage's timestamp.
|
||||
The sleep session stop time (from event message 21, event_type='stop') closes
|
||||
the last stage.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from datetime import datetime, timezone, date
|
||||
from typing import Optional
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
|
||||
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
|
||||
FIT_EPOCH_S = 631065600
|
||||
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
||||
|
||||
|
||||
def fit_ts(seconds) -> Optional[datetime]:
|
||||
"""Convert FIT timestamp to UTC datetime."""
|
||||
if seconds is None:
|
||||
def _fit_ts(raw) -> Optional[datetime]:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
s = int(seconds)
|
||||
if s == 0 or s == 0xFFFFFFFF:
|
||||
s = int(raw)
|
||||
if s <= 0 or s == 0xFFFFFFFF:
|
||||
return None
|
||||
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_datetime(v) -> bool:
|
||||
return isinstance(v, datetime)
|
||||
def _to_date(val) -> Optional[date]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
if val.tzinfo is None:
|
||||
val = val.replace(tzinfo=timezone.utc)
|
||||
return val.date()
|
||||
if isinstance(val, (int, float)):
|
||||
dt = _fit_ts(val)
|
||||
return dt.date() if dt else None
|
||||
return None
|
||||
|
||||
|
||||
def _to_dt(val) -> Optional[datetime]:
|
||||
if isinstance(val, datetime):
|
||||
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
|
||||
if isinstance(val, (int, float)):
|
||||
return _fit_ts(val)
|
||||
return None
|
||||
|
||||
|
||||
def parse_wellness_fit(file_path: str) -> dict:
|
||||
"""
|
||||
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
|
||||
|
||||
Parse a Garmin wellness/monitoring FIT file.
|
||||
Returns {"days": {date: metrics_dict}, "error": str|None}
|
||||
"""
|
||||
try:
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
except ImportError:
|
||||
# Fall back to fitparse-based parser if SDK not installed yet
|
||||
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
|
||||
return _fb(file_path)
|
||||
|
||||
daily = {} # date -> aggregation dict
|
||||
daily = {}
|
||||
last_date_seen = [None]
|
||||
|
||||
def ensure_day(d: date) -> dict:
|
||||
if d not in daily:
|
||||
@@ -58,195 +63,213 @@ def parse_wellness_fit(file_path: str) -> dict:
|
||||
"heart_rates": [],
|
||||
"stress_values": [],
|
||||
"spo2_readings": [],
|
||||
"sleep_levels": [],
|
||||
# Each entry: (datetime, level_int) — duration computed from gaps
|
||||
"sleep_epochs": [],
|
||||
"sleep_start": None,
|
||||
"sleep_end": None,
|
||||
"steps": None,
|
||||
"floors_climbed": None,
|
||||
"active_calories": None,
|
||||
"total_calories": None,
|
||||
"bmr": None,
|
||||
"resting_hr": None,
|
||||
"hrv_nightly_avg": None,
|
||||
"hrv_5min_high": None,
|
||||
"hrv_status": None,
|
||||
"sleep_score": None,
|
||||
}
|
||||
return daily[d]
|
||||
|
||||
def get_date(msg: dict, *keys) -> Optional[date]:
|
||||
"""Extract a date from a message, trying multiple field names."""
|
||||
for key in keys:
|
||||
v = msg.get(key)
|
||||
if v is None:
|
||||
continue
|
||||
if _is_datetime(v):
|
||||
return v.date()
|
||||
if isinstance(v, (int, float)):
|
||||
dt = fit_ts(v)
|
||||
if dt:
|
||||
return dt.date()
|
||||
return None
|
||||
def _add_sleep_epoch(ts: datetime, level_raw):
|
||||
d = _to_date(ts)
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
if isinstance(level_raw, str):
|
||||
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
|
||||
else:
|
||||
level = level_raw
|
||||
if level is not None:
|
||||
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
|
||||
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
"""Called for every message after full decoding."""
|
||||
|
||||
# ── Standard: monitoring (148) ────────────────────────────────────
|
||||
if mesg_num == 148:
|
||||
d = get_date(msg, "timestamp", "local_timestamp")
|
||||
# ── monitoring_info (147) - older firmware ─────────────────────────
|
||||
if mesg_num == 147:
|
||||
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
|
||||
rhr = msg.get("resting_heart_rate")
|
||||
if d and rhr and 20 < rhr < 120:
|
||||
last_date_seen[0] = d
|
||||
ensure_day(d)["resting_hr"] = int(rhr)
|
||||
|
||||
# ── monitoring (148) - older firmware ──────────────────────────────
|
||||
elif mesg_num == 148:
|
||||
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
|
||||
hr = msg.get("heart_rate")
|
||||
if hr and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
|
||||
steps = msg.get("steps") or msg.get("cycles")
|
||||
if steps and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
|
||||
stress = msg.get("stress_level_value")
|
||||
if stress is not None and stress >= 0:
|
||||
entry["stress_values"].append(int(stress))
|
||||
|
||||
# ── Standard: monitoring_info (147) ───────────────────────────────
|
||||
elif mesg_num == 147:
|
||||
d = get_date(msg, "timestamp", "local_timestamp")
|
||||
if not d:
|
||||
return
|
||||
rhr = msg.get("resting_heart_rate")
|
||||
if rhr and 20 < rhr < 120:
|
||||
ensure_day(d)["resting_hr"] = int(rhr)
|
||||
|
||||
# ── Standard: hrv_status_summary (275) ────────────────────────────
|
||||
elif mesg_num == 275:
|
||||
d = get_date(msg, "timestamp")
|
||||
# ── monitoring (55) - modern, per-interval running totals ──────────
|
||||
elif mesg_num == 55:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
||||
v = msg.get(key)
|
||||
if v:
|
||||
entry["hrv_nightly_avg"] = float(v)
|
||||
break
|
||||
high = msg.get("last_night_5_min_high")
|
||||
if high:
|
||||
entry["hrv_5min_high"] = float(high)
|
||||
status = msg.get("hrv_status")
|
||||
hr = msg.get("heart_rate")
|
||||
if hr and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
steps = msg.get("steps")
|
||||
if steps and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
active_cal = msg.get("active_calories")
|
||||
if active_cal and active_cal > 0:
|
||||
entry["active_calories"] = max(entry["active_calories"] or 0, float(active_cal))
|
||||
ascent = msg.get("ascent")
|
||||
if ascent and ascent > 0:
|
||||
# Garmin counts 1 floor ≈ 3 m of ascent
|
||||
floors = max(1, round(float(ascent) / 3))
|
||||
entry["floors_climbed"] = max(entry["floors_climbed"] or 0, floors)
|
||||
|
||||
# ── monitoring_info (103) - calibration; carries BMR ───────────────
|
||||
elif mesg_num == 103:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
bmr = msg.get("resting_metabolic_rate")
|
||||
if bmr and bmr > 0:
|
||||
ensure_day(d)["bmr"] = int(bmr)
|
||||
|
||||
# ── hrv_status_summary (370) - modern HRV ─────────────────────────
|
||||
elif mesg_num == 370:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hrv_avg = msg.get("last_night_average")
|
||||
if hrv_avg and hrv_avg > 0:
|
||||
entry["hrv_nightly_avg"] = float(hrv_avg)
|
||||
hrv_high = msg.get("last_night_5_min_high")
|
||||
if hrv_high and hrv_high > 0:
|
||||
entry["hrv_5min_high"] = float(hrv_high)
|
||||
status = msg.get("status")
|
||||
if status:
|
||||
entry["hrv_status"] = str(status)
|
||||
|
||||
# ── Standard: stress_level (132) ──────────────────────────────────
|
||||
elif mesg_num == 132:
|
||||
d = get_date(msg, "stress_level_time", "timestamp")
|
||||
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
|
||||
elif mesg_num == 275:
|
||||
sleep_level = msg.get("sleep_level")
|
||||
ts = _to_dt(msg.get("timestamp"))
|
||||
if sleep_level is not None and ts:
|
||||
_add_sleep_epoch(ts, sleep_level)
|
||||
elif ts:
|
||||
# Older firmware: HRV summary in message 275
|
||||
d = _to_date(ts)
|
||||
if d:
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
||||
v = msg.get(key)
|
||||
if v and v > 0:
|
||||
entry["hrv_nightly_avg"] = float(v)
|
||||
break
|
||||
high = msg.get("last_night_5_min_high")
|
||||
if high:
|
||||
entry["hrv_5min_high"] = float(high)
|
||||
status = msg.get("hrv_status") or msg.get("status")
|
||||
if status:
|
||||
entry["hrv_status"] = str(status)
|
||||
|
||||
# ── sleep_level (269) - older firmware sleep epochs ────────────────
|
||||
elif mesg_num == 269:
|
||||
ts = _to_dt(msg.get("timestamp"))
|
||||
level = msg.get("sleep_level")
|
||||
if ts and level is not None:
|
||||
_add_sleep_epoch(ts, level)
|
||||
|
||||
# ── event (21) - sleep session start / stop ────────────────────────
|
||||
elif mesg_num == 21:
|
||||
ts = _to_dt(msg.get("timestamp"))
|
||||
if not ts:
|
||||
return
|
||||
d = _to_date(ts)
|
||||
if not d:
|
||||
return
|
||||
event_type = msg.get("event_type")
|
||||
if event_type == "start":
|
||||
last_date_seen[0] = d
|
||||
ensure_day(d)["sleep_start"] = ts
|
||||
elif event_type == "stop":
|
||||
last_date_seen[0] = d
|
||||
ensure_day(d)["sleep_end"] = ts
|
||||
|
||||
# ── sleep_assessment (346) - overall sleep score, no timestamp ────
|
||||
elif mesg_num == 346:
|
||||
d = last_date_seen[0]
|
||||
if not d:
|
||||
return
|
||||
score = msg.get("overall_sleep_score")
|
||||
if score and score > 0:
|
||||
ensure_day(d)["sleep_score"] = int(score)
|
||||
|
||||
# ── stress_level (132) ─────────────────────────────────────────────
|
||||
elif mesg_num == 132:
|
||||
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
stress = msg.get("stress_level_value")
|
||||
if stress is not None and stress >= 0:
|
||||
ensure_day(d)["stress_values"].append(int(stress))
|
||||
|
||||
# ── Standard: spo2_data (258) ─────────────────────────────────────
|
||||
# ── spo2_data (258) ────────────────────────────────────────────────
|
||||
elif mesg_num == 258:
|
||||
d = get_date(msg, "timestamp")
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
last_date_seen[0] = d
|
||||
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
||||
if spo2 and 50 < spo2 <= 100:
|
||||
ensure_day(d)["spo2_readings"].append(float(spo2))
|
||||
|
||||
# ── Standard: sleep_level (269) ───────────────────────────────────
|
||||
elif mesg_num == 269:
|
||||
d = get_date(msg, "timestamp")
|
||||
# ── per-minute stress + HR (227) proprietary ───────────────────────
|
||||
elif mesg_num == 227:
|
||||
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
level = msg.get("sleep_level")
|
||||
if level is not None:
|
||||
# Convert string level names to numeric codes if SDK decoded them
|
||||
if isinstance(level, str):
|
||||
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
||||
level = level_map.get(level.lower())
|
||||
if level is not None:
|
||||
ensure_day(d)["sleep_levels"].append(int(level))
|
||||
|
||||
# ── Proprietary 227: per-minute stress + HR ───────────────────────
|
||||
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
|
||||
elif mesg_num == 227:
|
||||
# SDK stores unknown fields as "unknown_N" or by def_num
|
||||
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
|
||||
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
|
||||
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
|
||||
|
||||
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
|
||||
ts_raw if _is_datetime(ts_raw) else None
|
||||
)
|
||||
if not ts:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
hr_raw = msg.get(2)
|
||||
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
||||
entry["heart_rates"].append(int(hr_raw))
|
||||
stress = msg.get("stress_level_value")
|
||||
if stress is None:
|
||||
stress = msg.get(0)
|
||||
if stress is not None and isinstance(stress, (int, float)) and stress >= 0:
|
||||
entry["stress_values"].append(int(stress))
|
||||
|
||||
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
|
||||
entry["stress_values"].append(int(stress_raw))
|
||||
|
||||
# ── Proprietary 103: daily totals summary ─────────────────────────
|
||||
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
|
||||
elif mesg_num == 103:
|
||||
ts_v = msg.get(253) or msg.get("timestamp")
|
||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||
if not ts:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
steps = msg.get(3)
|
||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
||||
entry["steps"] = int(steps)
|
||||
|
||||
floors = msg.get(4)
|
||||
if floors and isinstance(floors, (int, float)) and floors > 0:
|
||||
f = float(floors)
|
||||
if f > 1000:
|
||||
f = f / 100
|
||||
entry["floors_climbed"] = round(f, 1)
|
||||
|
||||
active_cal = msg.get(5)
|
||||
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
|
||||
entry["active_calories"] = float(active_cal)
|
||||
|
||||
total_cal = msg.get(7)
|
||||
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
|
||||
entry["total_calories"] = float(total_cal)
|
||||
|
||||
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
|
||||
# ── daily resting HR (211) proprietary ─────────────────────────────
|
||||
elif mesg_num == 211:
|
||||
ts_v = msg.get(253) or msg.get("timestamp")
|
||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||
if not ts:
|
||||
d = _to_date(msg.get("timestamp"))
|
||||
if not d:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
rhr = msg.get(0)
|
||||
last_date_seen[0] = d
|
||||
entry = ensure_day(d)
|
||||
rhr = msg.get("resting_heart_rate") or msg.get("current_day_resting_heart_rate")
|
||||
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
|
||||
entry["resting_hr"] = int(rhr)
|
||||
|
||||
hrv = msg.get(1)
|
||||
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
|
||||
entry["hrv_nightly_avg"] = float(hrv)
|
||||
|
||||
# ── Proprietary 55: activity accumulation snapshots ───────────────
|
||||
elif mesg_num == 55:
|
||||
ts_v = msg.get(253) or msg.get("timestamp")
|
||||
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||
if not ts:
|
||||
return
|
||||
entry = ensure_day(ts.date())
|
||||
|
||||
steps = msg.get(2)
|
||||
if steps and isinstance(steps, (int, float)) and steps > 0:
|
||||
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||
|
||||
hr = msg.get(19)
|
||||
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
|
||||
entry["heart_rates"].append(int(hr))
|
||||
|
||||
# Decode the file
|
||||
try:
|
||||
stream = Stream.from_file(file_path)
|
||||
decoder = Decoder(stream)
|
||||
@@ -254,37 +277,57 @@ def parse_wellness_fit(file_path: str) -> dict:
|
||||
apply_scale_and_offset=True,
|
||||
convert_datetimes_to_dates=True,
|
||||
convert_types_to_strings=True,
|
||||
enable_crc_check=False, # wellness files sometimes have bad CRCs
|
||||
enable_crc_check=False,
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=True,
|
||||
merge_heart_rates=False,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "days": {}}
|
||||
|
||||
# Aggregate per-day
|
||||
result = {}
|
||||
for day_date, data in daily.items():
|
||||
hrs = data.pop("heart_rates", [])
|
||||
stresses = data.pop("stress_values", [])
|
||||
spo2s = data.pop("spo2_readings", [])
|
||||
sleep_levels = data.pop("sleep_levels", [])
|
||||
sleep_epochs = data.pop("sleep_epochs", [])
|
||||
sleep_end_ts = data.pop("sleep_end", None)
|
||||
sleep_start_ts = data.pop("sleep_start", None)
|
||||
|
||||
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
|
||||
max_hr = max(hrs) if hrs else None
|
||||
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
|
||||
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
||||
|
||||
# Sleep stage seconds (each level record = 30s epoch)
|
||||
if sleep_levels:
|
||||
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
|
||||
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
|
||||
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
|
||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
|
||||
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
|
||||
# Compute sleep stage durations from epoch timestamps
|
||||
if sleep_epochs:
|
||||
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
|
||||
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
|
||||
for i, (ts, level) in enumerate(epochs_sorted):
|
||||
if i + 1 < len(epochs_sorted):
|
||||
next_ts = epochs_sorted[i + 1][0]
|
||||
elif sleep_end_ts:
|
||||
next_ts = sleep_end_ts
|
||||
else:
|
||||
continue
|
||||
dur = (next_ts - ts).total_seconds()
|
||||
if level in level_secs and dur > 0:
|
||||
level_secs[level] += dur
|
||||
sleep_deep_s = level_secs[3] or None
|
||||
sleep_light_s = level_secs[2] or None
|
||||
sleep_rem_s = level_secs[4] or None
|
||||
sleep_awake_s = level_secs[1] or None
|
||||
sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None
|
||||
sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted]
|
||||
else:
|
||||
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
||||
sleep_stages = None
|
||||
|
||||
active_cal = data.get("active_calories")
|
||||
bmr = data.get("bmr")
|
||||
# Require active_cal so we don't store BMR-only as "total" calories
|
||||
total_cal = float(bmr + active_cal) if (bmr and active_cal) else None
|
||||
|
||||
result[day_date] = {
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
@@ -297,13 +340,17 @@ def parse_wellness_fit(file_path: str) -> dict:
|
||||
"hrv_status": data.get("hrv_status"),
|
||||
"steps": data.get("steps"),
|
||||
"floors_climbed": data.get("floors_climbed"),
|
||||
"active_calories": data.get("active_calories"),
|
||||
"total_calories": data.get("total_calories"),
|
||||
"active_calories": active_cal,
|
||||
"total_calories": total_cal,
|
||||
"sleep_duration_s": sleep_duration_s,
|
||||
"sleep_deep_s": sleep_deep_s,
|
||||
"sleep_light_s": sleep_light_s,
|
||||
"sleep_rem_s": sleep_rem_s,
|
||||
"sleep_awake_s": sleep_awake_s,
|
||||
"sleep_score": data.get("sleep_score"),
|
||||
"sleep_start": sleep_start_ts,
|
||||
"sleep_end": sleep_end_ts,
|
||||
"sleep_stages": sleep_stages,
|
||||
}
|
||||
|
||||
return {"days": result, "error": None}
|
||||
|
||||
@@ -22,18 +22,27 @@ celery_app.conf.update(
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
beat_schedule={
|
||||
"sync-garmin-connect": {
|
||||
"task": "sync_all_garmin_connect",
|
||||
"schedule": 1800.0, # every 30 minutes
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Garmin FIT file suffixes that are health/wellness data, not activities
|
||||
WELLNESS_SUFFIXES = (
|
||||
"_METRICS.fit",
|
||||
"_WELLNESS.fit",
|
||||
"_SLEEP.fit",
|
||||
"_SLEEP_DATA.fit",
|
||||
"_STRESS.fit",
|
||||
"_SPO2.fit",
|
||||
"_HRV.fit",
|
||||
"_HRV_STATUS.fit",
|
||||
"_MONITORING.fit",
|
||||
"_MONITORING_B.fit",
|
||||
"_RESPIRATION.fit",
|
||||
"_PULSE_OX.fit",
|
||||
)
|
||||
|
||||
|
||||
@@ -43,10 +52,10 @@ def is_wellness_file(file_path: str) -> bool:
|
||||
|
||||
|
||||
@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):
|
||||
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
||||
|
||||
# Route wellness/metrics files to health parser instead
|
||||
if is_wellness_file(file_path):
|
||||
parse_wellness_fit.delay(file_path, user_id)
|
||||
return {"status": "routed_to_wellness", "file": file_path}
|
||||
@@ -54,7 +63,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"step": "parsing"})
|
||||
@@ -67,25 +76,37 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
except Exception as e:
|
||||
raise self.retry(exc=e, countdown=10, max_retries=3)
|
||||
|
||||
# Skip files with no usable activity data
|
||||
if not parsed.get("start_time"):
|
||||
return {"status": "skipped", "reason": "no start_time", "file": file_path}
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
# Check for duplicate by garmin activity ID
|
||||
if parsed.get("garmin_activity_id"):
|
||||
existing = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.garmin_activity_id == parsed["garmin_activity_id"]
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
|
||||
# Get user's configured max HR for accurate zone calculation
|
||||
# Falls back to: user-set value → 220-age → activity max → 190
|
||||
# Deduplicate: same user + sport_type + start_time within ±60s
|
||||
from datetime import timedelta
|
||||
existing = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
Activity.sport_type == parsed["sport_type"],
|
||||
Activity.start_time >= start_time - timedelta(seconds=60),
|
||||
Activity.start_time <= start_time + timedelta(seconds=60),
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if existing:
|
||||
# Stamp garmin_activity_id if this came from a Garmin Connect sync
|
||||
# so future syncs skip the fast-path dedup and don't re-download.
|
||||
if garmin_activity_id and not existing.garmin_activity_id:
|
||||
existing.garmin_activity_id = garmin_activity_id
|
||||
db.commit()
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Get user max HR for zone calculation
|
||||
from app.models.user import User as UserModel
|
||||
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
|
||||
user_obj = db.execute(
|
||||
select(UserModel).where(UserModel.id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
user_max_hr = None
|
||||
if user_obj:
|
||||
user_max_hr = user_obj.max_heart_rate
|
||||
@@ -94,20 +115,15 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
age = _date.today().year - user_obj.birth_year
|
||||
user_max_hr = 220 - age
|
||||
if not user_max_hr:
|
||||
# Last resort: use activity max but warn this may shift zones
|
||||
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||
|
||||
hr_zones = calculate_hr_zones(
|
||||
parsed.get("data_points", []),
|
||||
user_max_hr
|
||||
)
|
||||
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
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"),
|
||||
@@ -132,11 +148,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
db.add(activity)
|
||||
db.flush()
|
||||
|
||||
# Insert data points, deduping on (activity_id, timestamp)
|
||||
seen = set()
|
||||
points = parsed.get("data_points", [])
|
||||
batch = []
|
||||
for p in points:
|
||||
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"]
|
||||
@@ -165,7 +179,6 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
db.add_all(batch)
|
||||
db.flush()
|
||||
|
||||
# Laps
|
||||
for lap in parsed.get("laps", []):
|
||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||
db.add(ActivityLap(
|
||||
@@ -184,7 +197,6 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
activity_id = activity.id
|
||||
|
||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||
# Auto route detection for running and cycling
|
||||
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||
detect_route.delay(activity_id, user_id)
|
||||
return {"activity_id": activity_id, "status": "ok"}
|
||||
@@ -192,16 +204,17 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
|
||||
@celery_app.task(name="parse_wellness_fit")
|
||||
def parse_wellness_fit(file_path: str, user_id: int):
|
||||
"""
|
||||
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
||||
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
|
||||
"""
|
||||
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
|
||||
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||
from app.core.database import SyncSessionLocal
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import text
|
||||
|
||||
result = _parse(file_path)
|
||||
try:
|
||||
result = _parse(file_path)
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e), "file": file_path}
|
||||
|
||||
if result.get("error"):
|
||||
return {"status": "error", "error": result["error"], "file": file_path}
|
||||
|
||||
@@ -216,11 +229,13 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
||||
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
|
||||
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
||||
steps, floors_climbed, active_calories, total_calories,
|
||||
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
|
||||
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s,
|
||||
sleep_score, sleep_start, sleep_end, sleep_stages)
|
||||
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
||||
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||
:steps, :floors, :active_cal, :total_cal,
|
||||
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
|
||||
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake,
|
||||
:sleep_score, :sleep_start, :sleep_end, :sleep_stages::json)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||
@@ -233,12 +248,16 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||
total_calories = GREATEST(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
||||
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
||||
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
||||
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
|
||||
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
||||
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s),
|
||||
sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score),
|
||||
sleep_start = COALESCE(EXCLUDED.sleep_start, health_metrics.sleep_start),
|
||||
sleep_end = COALESCE(EXCLUDED.sleep_end, health_metrics.sleep_end),
|
||||
sleep_stages = COALESCE(EXCLUDED.sleep_stages, health_metrics.sleep_stages)
|
||||
"""), {
|
||||
"user_id": user_id, "date": date_dt,
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
@@ -258,35 +277,35 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
||||
"sleep_light": data.get("sleep_light_s"),
|
||||
"sleep_rem": data.get("sleep_rem_s"),
|
||||
"sleep_awake": data.get("sleep_awake_s"),
|
||||
"sleep_score": data.get("sleep_score"),
|
||||
"sleep_start": data.get("sleep_start"),
|
||||
"sleep_end": data.get("sleep_end"),
|
||||
"sleep_stages": __import__('json').dumps(data.get("sleep_stages")) if data.get("sleep_stages") else None,
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
||||
|
||||
|
||||
@celery_app.task(name="detect_route")
|
||||
def detect_route(activity_id: int, user_id: int):
|
||||
"""
|
||||
After importing an activity, check if it matches any existing named routes.
|
||||
If two+ unassigned activities match each other, auto-create a named route.
|
||||
"""
|
||||
"""Auto-detect and link activities to named routes."""
|
||||
from app.services.route_matcher import routes_are_similar
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, NamedRoute
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
# Get the new activity
|
||||
new_act = db.execute(
|
||||
select(Activity).where(Activity.id == activity_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not new_act or not new_act.polyline:
|
||||
return {"status": "no_polyline"}
|
||||
|
||||
# Already assigned to a route?
|
||||
if new_act.named_route_id:
|
||||
return {"status": "already_assigned"}
|
||||
|
||||
# Check against existing named routes first
|
||||
routes = db.execute(
|
||||
select(NamedRoute).where(
|
||||
NamedRoute.user_id == user_id,
|
||||
@@ -298,12 +317,12 @@ def detect_route(activity_id: int, user_id: int):
|
||||
if route.reference_polyline and routes_are_similar(
|
||||
new_act.polyline, route.reference_polyline,
|
||||
new_act.bounding_box, route.bounding_box,
|
||||
dist1=new_act.distance_m, dist2=route.distance_m,
|
||||
):
|
||||
new_act.named_route_id = route.id
|
||||
db.commit()
|
||||
return {"status": "matched_existing", "route_id": route.id}
|
||||
|
||||
# No existing route matched - check unassigned activities for a match
|
||||
candidates = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
@@ -311,9 +330,8 @@ def detect_route(activity_id: int, user_id: int):
|
||||
Activity.named_route_id == None,
|
||||
Activity.id != activity_id,
|
||||
Activity.polyline != None,
|
||||
# Within 20% distance
|
||||
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
|
||||
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
|
||||
Activity.distance_m >= (new_act.distance_m or 0) * 0.95,
|
||||
Activity.distance_m <= (new_act.distance_m or 0) * 1.05,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
@@ -321,8 +339,8 @@ def detect_route(activity_id: int, user_id: int):
|
||||
if routes_are_similar(
|
||||
new_act.polyline, candidate.polyline,
|
||||
new_act.bounding_box, candidate.bounding_box,
|
||||
dist1=new_act.distance_m, dist2=candidate.distance_m,
|
||||
):
|
||||
# Auto-create a route from the older activity
|
||||
older = candidate if candidate.start_time < new_act.start_time else new_act
|
||||
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
||||
|
||||
@@ -400,52 +418,208 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
import zipfile
|
||||
import json
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import HealthMetric
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import text
|
||||
|
||||
INSERT_SQL = text("""
|
||||
INSERT INTO health_metrics (user_id, date, resting_hr, max_hr_day, steps,
|
||||
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
|
||||
VALUES (:user_id, :date, :resting_hr, :max_hr_day, :steps,
|
||||
:floors, :active_cal, :total_cal, :stress, :spo2)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
|
||||
""")
|
||||
|
||||
def _extract_stress(item):
|
||||
stress_data = item.get("allDayStress")
|
||||
if not stress_data or not isinstance(stress_data, dict):
|
||||
return item.get("averageStressLevel")
|
||||
for agg in stress_data.get("aggregatorList", []):
|
||||
if agg.get("type") == "TOTAL":
|
||||
return agg.get("averageStressLevel")
|
||||
return None
|
||||
|
||||
def _floors_from_item(item):
|
||||
# UDS format reports meters; 1 floor = 3.048 m
|
||||
meters = item.get("floorsAscendedInMeters")
|
||||
if meters is not None:
|
||||
return round(meters / 3.048)
|
||||
return item.get("floorsAscended")
|
||||
|
||||
def _process_record(db, item):
|
||||
date_str = item.get("calendarDate") or item.get("date")
|
||||
if not date_str:
|
||||
return
|
||||
try:
|
||||
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return
|
||||
db.execute(INSERT_SQL, {
|
||||
"user_id": user_id, "date": date_dt,
|
||||
"resting_hr": item.get("restingHeartRate"),
|
||||
"max_hr_day": item.get("maxHeartRate"),
|
||||
"steps": item.get("totalSteps"),
|
||||
"floors": _floors_from_item(item),
|
||||
"active_cal": item.get("activeKilocalories"),
|
||||
"total_cal": item.get("totalKilocalories"),
|
||||
"stress": _extract_stress(item),
|
||||
"spo2": item.get("avgSpo2"),
|
||||
})
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for name in zf.namelist():
|
||||
if "DailyMetrics" not in name or not name.endswith(".json"):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
# Garmin Connect export stores daily summaries in UDSFile_*.json
|
||||
# (DI-Connect-Aggregator). Older/alternative exports may use DailyMetrics.
|
||||
is_uds = "UDSFile" in name
|
||||
is_legacy = "DailyMetrics" in name
|
||||
if not (is_uds or is_legacy):
|
||||
continue
|
||||
with zf.open(name) as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
# UDS files are lists of daily records; legacy format is a single object
|
||||
records = data if isinstance(data, list) else [data]
|
||||
for item in records:
|
||||
if isinstance(item, dict):
|
||||
_process_record(db, item)
|
||||
db.commit()
|
||||
|
||||
date_str = data.get("calendarDate") or data.get("date")
|
||||
if not date_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
@celery_app.task(name="sync_garmin_connect_user")
|
||||
def sync_garmin_connect_user(user_id: int):
|
||||
"""Sync Garmin Connect data (activities + wellness) for one user."""
|
||||
from app.services.garmin_connect_sync import authenticate_garmin, sync_activities, sync_wellness
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import GarminConnectConfig
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text as _text
|
||||
db.execute(_text("""
|
||||
INSERT INTO health_metrics (user_id, date, resting_hr, steps,
|
||||
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
|
||||
VALUES (:user_id, :date, :resting_hr, :steps,
|
||||
:floors, :active_cal, :total_cal, :stress, :spo2)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
|
||||
"""), {
|
||||
"user_id": user_id, "date": date_dt,
|
||||
"resting_hr": data.get("restingHeartRate"),
|
||||
"steps": data.get("totalSteps"),
|
||||
"floors": data.get("floorsAscended"),
|
||||
"active_cal": data.get("activeKilocalories"),
|
||||
"total_cal": data.get("totalKilocalories"),
|
||||
"stress": data.get("averageStressLevel"),
|
||||
"spo2": data.get("avgSpo2"),
|
||||
})
|
||||
with SyncSessionLocal() as db:
|
||||
cfg = db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not cfg or not cfg.sync_enabled:
|
||||
return {"status": "skipped"}
|
||||
|
||||
# Snapshot config values before any intermediate commits (commits expire ORM attrs)
|
||||
email = cfg.email
|
||||
password_enc = cfg.password_enc
|
||||
token_store = cfg.token_store
|
||||
last_sync_at = cfg.last_sync_at
|
||||
sync_acts = cfg.sync_activities
|
||||
sync_well = cfg.sync_wellness
|
||||
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
|
||||
|
||||
cfg.last_sync_status = "Connecting to Garmin..."
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
garmin, new_token = authenticate_garmin(email, password_enc, token_store)
|
||||
except Exception as exc:
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = f"Auth error: {exc}"
|
||||
db.commit()
|
||||
return {"status": "auth_error", "error": str(exc)}
|
||||
|
||||
if new_token:
|
||||
cfg.token_store = new_token
|
||||
db.commit()
|
||||
|
||||
activities_queued = 0
|
||||
wellness_days = 0
|
||||
errors = []
|
||||
|
||||
def _set_status(text):
|
||||
cfg.last_sync_status = text
|
||||
db.commit()
|
||||
|
||||
if sync_acts:
|
||||
_set_status("Syncing activities...")
|
||||
try:
|
||||
activities_queued = sync_activities(
|
||||
garmin, user_id, last_sync_at, db, settings.file_store_path,
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(f"activities: {exc}")
|
||||
|
||||
if sync_well:
|
||||
_set_status("Syncing wellness...")
|
||||
try:
|
||||
wellness_days = sync_wellness(
|
||||
garmin, user_id, last_sync_at, db,
|
||||
lookback_days=lookback,
|
||||
status_callback=_set_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(f"wellness: {exc}")
|
||||
db.rollback() # recover session so the final status commit can succeed
|
||||
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
cfg.last_sync_status = (
|
||||
f"OK — {activities_queued} activities queued, {wellness_days} wellness days synced"
|
||||
if not errors else
|
||||
f"Partial — {'; '.join(errors)}"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "activities_queued": activities_queued, "wellness_days": wellness_days}
|
||||
|
||||
|
||||
@celery_app.task(name="sync_all_garmin_connect")
|
||||
def sync_all_garmin_connect():
|
||||
"""Hourly beat task: dispatch per-user sync for all enabled configs."""
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import GarminConnectConfig
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
configs = db.execute(
|
||||
select(GarminConnectConfig).where(GarminConnectConfig.sync_enabled == True)
|
||||
).scalars().all()
|
||||
user_ids = [c.user_id for c in configs]
|
||||
|
||||
for uid in user_ids:
|
||||
sync_garmin_connect_user.delay(uid)
|
||||
|
||||
return {"dispatched": len(user_ids)}
|
||||
|
||||
|
||||
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
||||
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
||||
from app.services.fit_parser import calculate_hr_zones
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
activities = db.execute(
|
||||
select(Activity).where(Activity.user_id == user_id)
|
||||
).scalars().all()
|
||||
|
||||
for activity in activities:
|
||||
data_points = db.execute(
|
||||
select(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity.id)
|
||||
).scalars().all()
|
||||
points_dicts = [{"heart_rate": dp.heart_rate} for dp in data_points]
|
||||
new_zones = calculate_hr_zones(points_dicts, new_max_hr)
|
||||
if new_zones:
|
||||
activity.hr_zones = new_zones
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -13,7 +13,6 @@ httpx==0.27.0
|
||||
redis[hiredis]==5.0.4
|
||||
celery[redis]==5.4.0
|
||||
garmin-fit-sdk==21.195.0
|
||||
fitparse==1.2.0
|
||||
gpxpy==1.6.2
|
||||
numpy==1.26.4
|
||||
scipy==1.13.0
|
||||
@@ -24,3 +23,5 @@ aiofiles==23.2.1
|
||||
python-dateutil==2.9.0
|
||||
pytz==2024.1
|
||||
psycopg2-binary==2.9.9
|
||||
garminconnect==0.2.24
|
||||
cryptography==42.0.8
|
||||
Reference in New Issue
Block a user