All tweaks added
This commit is contained in:
+1
-1
@@ -13,4 +13,4 @@ COPY . .
|
||||
|
||||
# Single worker avoids race condition during DB initialization.
|
||||
# For a personal app this is fine; async handles concurrent requests well.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+31
-43
@@ -7,13 +7,23 @@ from typing import Optional
|
||||
import httpx
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, hash_password, get_current_user
|
||||
from app.core.security import verify_password, create_access_token, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_pocketid_config(db: AsyncSession):
|
||||
"""Get PocketID config from DB (admin user) falling back to env vars."""
|
||||
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
|
||||
admin = result.scalar_one_or_none()
|
||||
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
|
||||
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
|
||||
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
|
||||
return issuer, client_id, client_secret
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -37,24 +47,15 @@ async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == form_data.username)
|
||||
)
|
||||
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.hashed_password:
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
if not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return Token(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
return Token(access_token=token, token_type="bearer",
|
||||
user_id=user.id, username=user.username, is_admin=user.is_admin)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
@@ -63,51 +64,44 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.get("/pocketid/available")
|
||||
async def pocketid_available():
|
||||
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)}
|
||||
async def pocketid_available(db: AsyncSession = Depends(get_db)):
|
||||
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||
return {"available": bool(issuer and client_id)}
|
||||
|
||||
|
||||
@router.get("/pocketid/login-url")
|
||||
async def pocketid_login_url():
|
||||
"""Return the OIDC authorization URL for PocketID."""
|
||||
if not settings.pocketid_issuer:
|
||||
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||
if not issuer or not client_id:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
from urllib.parse import urlencode
|
||||
params = {
|
||||
"client_id": settings.pocketid_client_id,
|
||||
"client_id": client_id,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
|
||||
return {"url": url}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
|
||||
@router.get("/pocketid/callback")
|
||||
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange OIDC code for tokens and create/login user."""
|
||||
if not settings.pocketid_issuer:
|
||||
issuer, client_id, client_secret = await _get_pocketid_config(db)
|
||||
if not issuer:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{settings.pocketid_issuer}/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"client_id": settings.pocketid_client_id,
|
||||
"client_secret": settings.pocketid_client_secret,
|
||||
},
|
||||
f"{issuer}/token",
|
||||
data={"grant_type": "authorization_code", "code": code,
|
||||
"redirect_uri": "/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"{settings.pocketid_issuer}/userinfo",
|
||||
f"{issuer}/userinfo",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
userinfo = userinfo_resp.json()
|
||||
@@ -118,17 +112,11 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
username=preferred_username,
|
||||
email=email,
|
||||
pocketid_sub=sub,
|
||||
)
|
||||
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
# Redirect to frontend with token
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/?token={token}")
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password, verify_password
|
||||
from app.models.user import User, WeightLog
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Profile ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
max_heart_rate: Optional[int] = None
|
||||
resting_heart_rate: Optional[int] = None
|
||||
birth_year: Optional[int] = None
|
||||
height_cm: Optional[float] = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
max_heart_rate: Optional[int]
|
||||
resting_heart_rate: Optional[int]
|
||||
birth_year: Optional[int]
|
||||
height_cm: Optional[float]
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def _estimated_max_hr(user: User) -> Optional[int]:
|
||||
if user.birth_year:
|
||||
return 220 - (datetime.now().year - user.birth_year)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=ProfileOut)
|
||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
|
||||
|
||||
@router.patch("/", response_model=ProfileOut)
|
||||
async def update_profile(
|
||||
body: ProfileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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")
|
||||
current_user.max_heart_rate = body.max_heart_rate
|
||||
if body.resting_heart_rate is not None:
|
||||
if not (20 <= body.resting_heart_rate <= 120):
|
||||
raise HTTPException(400, "Resting HR must be 20–120")
|
||||
current_user.resting_heart_rate = body.resting_heart_rate
|
||||
if body.birth_year is not None:
|
||||
if not (1920 <= body.birth_year <= 2010):
|
||||
raise HTTPException(400, "Invalid birth year")
|
||||
current_user.birth_year = body.birth_year
|
||||
if body.height_cm is not None:
|
||||
if not (50 <= body.height_cm <= 300):
|
||||
raise HTTPException(400, "Height must be 50–300 cm")
|
||||
current_user.height_cm = body.height_cm
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return {**{c.name: getattr(current_user, c.name)
|
||||
for c in User.__table__.columns},
|
||||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||||
|
||||
|
||||
# ── Password change ────────────────────────────────────────────────────────
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: PasswordChange,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.hashed_password:
|
||||
raise HTTPException(400, "Account uses passkey login — no password to change")
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
raise HTTPException(400, "Current password is incorrect")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(400, "New password must be at least 8 characters")
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── PocketID configuration (admin only) ────────────────────────────────────
|
||||
|
||||
class PocketIDConfig(BaseModel):
|
||||
issuer: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/pocketid-config")
|
||||
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
from app.core.config import settings
|
||||
# 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
|
||||
return {
|
||||
"issuer": issuer or "",
|
||||
"client_id": client_id or "",
|
||||
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
||||
"enabled": bool(issuer and client_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pocketid-config")
|
||||
async def save_pocketid_config(
|
||||
body: PocketIDConfig,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if body.issuer is not None:
|
||||
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
|
||||
if body.client_id is not None:
|
||||
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
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Weight log ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WeightEntry(BaseModel):
|
||||
date: datetime
|
||||
weight_kg: float
|
||||
body_fat_pct: Optional[float] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class WeightOut(BaseModel):
|
||||
id: int
|
||||
date: datetime
|
||||
weight_kg: float
|
||||
body_fat_pct: Optional[float]
|
||||
note: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/weight", response_model=List[WeightOut])
|
||||
async def list_weight(
|
||||
limit: int = 365,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(WeightLog)
|
||||
.where(WeightLog.user_id == current_user.id)
|
||||
.order_by(desc(WeightLog.date))
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/weight", response_model=WeightOut)
|
||||
async def log_weight(
|
||||
body: WeightEntry,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not (20 <= body.weight_kg <= 500):
|
||||
raise HTTPException(400, "Weight must be 20–500 kg")
|
||||
entry = WeightLog(
|
||||
user_id=current_user.id,
|
||||
date=body.date,
|
||||
weight_kg=body.weight_kg,
|
||||
body_fat_pct=body.body_fat_pct,
|
||||
note=body.note,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/weight/{entry_id}", status_code=204)
|
||||
async def delete_weight(
|
||||
entry_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(WeightLog).where(
|
||||
WeightLog.id == entry_id,
|
||||
WeightLog.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(404, "Not found")
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
@@ -23,7 +23,7 @@ class RouteCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sport_type: Optional[str] = None
|
||||
activity_id: int # use this activity as the reference route
|
||||
activity_id: int
|
||||
|
||||
|
||||
class RouteOut(BaseModel):
|
||||
@@ -34,6 +34,7 @@ class RouteOut(BaseModel):
|
||||
reference_polyline: Optional[str]
|
||||
bounding_box: Optional[dict]
|
||||
distance_m: Optional[float]
|
||||
auto_detected: Optional[bool]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -64,13 +65,44 @@ async def list_routes(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/recent-activities")
|
||||
async def recent_activities_for_route(
|
||||
days: int = Query(14, ge=1, le=90),
|
||||
sport_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return recent activities for the route creation dropdown."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
q = select(Activity).where(
|
||||
Activity.user_id == current_user.id,
|
||||
Activity.start_time >= cutoff,
|
||||
Activity.sport_type != "swimming",
|
||||
)
|
||||
if sport_type:
|
||||
q = q.where(Activity.sport_type == sport_type)
|
||||
q = q.order_by(desc(Activity.start_time)).limit(50)
|
||||
result = await db.execute(q)
|
||||
activities = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"sport_type": a.sport_type,
|
||||
"start_time": a.start_time,
|
||||
"distance_m": a.distance_m,
|
||||
"duration_s": a.duration_s,
|
||||
}
|
||||
for a in activities
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=RouteOut)
|
||||
async def create_route(
|
||||
body: RouteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Load the reference activity
|
||||
act_result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == body.activity_id,
|
||||
@@ -89,11 +121,10 @@ async def create_route(
|
||||
reference_polyline=activity.polyline,
|
||||
bounding_box=activity.bounding_box,
|
||||
distance_m=activity.distance_m,
|
||||
auto_detected=False,
|
||||
)
|
||||
db.add(route)
|
||||
await db.flush()
|
||||
|
||||
# Link this activity to the route
|
||||
activity.named_route_id = route.id
|
||||
await db.commit()
|
||||
await db.refresh(route)
|
||||
@@ -124,7 +155,6 @@ async def route_activities(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All activities on this named route, ordered fastest first."""
|
||||
result = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.named_route_id == route_id,
|
||||
@@ -153,7 +183,6 @@ async def assign_activity_to_route(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually assign an activity to a named route."""
|
||||
activity_id = body.get("activity_id")
|
||||
act_result = await db.execute(
|
||||
select(Activity).where(
|
||||
@@ -164,7 +193,6 @@ async def assign_activity_to_route(
|
||||
activity = act_result.scalar_one_or_none()
|
||||
if not activity:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
|
||||
activity.named_route_id = route_id
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -36,4 +36,4 @@ class Settings(BaseSettings):
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
|
||||
@@ -44,4 +44,4 @@ async def get_db():
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
await session.close()
|
||||
|
||||
+3
-2
@@ -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
|
||||
from app.api import auth, activities, routes, health, records, upload, profile
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -97,8 +97,9 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||
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.get("/health")
|
||||
async def healthcheck():
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -22,9 +22,39 @@ class User(Base):
|
||||
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
# Health profile
|
||||
max_heart_rate = Column(Integer, nullable=True)
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
height_cm = Column(Float, nullable=True)
|
||||
|
||||
# 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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class WeightLog(Base):
|
||||
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||
__tablename__ = "weight_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False)
|
||||
weight_kg = Column(Float, nullable=False)
|
||||
body_fat_pct = Column(Float, nullable=True)
|
||||
note = Column(String(256), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_weight_user_date", "user_id", "date"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="weight_logs")
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
@@ -68,11 +98,6 @@ class Activity(Base):
|
||||
|
||||
|
||||
class ActivityDataPoint(Base):
|
||||
"""
|
||||
TimescaleDB hypertable - one row per second of activity data.
|
||||
Composite primary key (activity_id, timestamp) satisfies TimescaleDB's
|
||||
requirement that the partition column be part of the primary key.
|
||||
"""
|
||||
__tablename__ = "activity_data_points"
|
||||
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
||||
@@ -118,6 +143,7 @@ class NamedRoute(Base):
|
||||
reference_polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
auto_detected = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="named_routes")
|
||||
@@ -198,4 +224,4 @@ class HealthMetric(Base):
|
||||
Index("ix_health_user_date", "user_id", "date"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
|
||||
+108
-142
@@ -1,21 +1,24 @@
|
||||
"""
|
||||
Parses Garmin .fit files and GPX files into normalized activity data.
|
||||
Handles full Strava and Garmin data export archives.
|
||||
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.
|
||||
"""
|
||||
import os
|
||||
import zipfile
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import fitparse
|
||||
import gpxpy
|
||||
import polyline as polyline_lib
|
||||
|
||||
|
||||
FIT_EPOCH_S = 631065600
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||
"""Returns distance in metres between two GPS points."""
|
||||
"""Distance in metres between two GPS points."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
@@ -24,106 +27,100 @@ def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def semicircles_to_degrees(sc: int) -> float:
|
||||
return sc * (180 / 2**31)
|
||||
def _safe_float(val) -> Optional[float]:
|
||||
try:
|
||||
return float(val) if val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _bounding_box(coords: list) -> Optional[dict]:
|
||||
if not coords:
|
||||
return None
|
||||
lats = [c[0] for c in coords]
|
||||
lons = [c[1] for c in coords]
|
||||
return {"min_lat": min(lats), "max_lat": max(lats),
|
||||
"min_lon": min(lons), "max_lon": max(lons)}
|
||||
|
||||
|
||||
def parse_fit_file(filepath: str) -> dict:
|
||||
"""Parse a Garmin .fit file and return normalized activity dict."""
|
||||
fit = fitparse.FitFile(filepath)
|
||||
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
|
||||
from garmin_fit_sdk import Decoder, Stream
|
||||
|
||||
data_points = []
|
||||
laps = []
|
||||
session = {}
|
||||
records = []
|
||||
laps = []
|
||||
|
||||
for record in fit.get_messages():
|
||||
name = record.name
|
||||
def listener(mesg_num: int, msg: dict):
|
||||
nonlocal session
|
||||
if mesg_num == 18: # session
|
||||
session = msg
|
||||
elif mesg_num == 20: # record
|
||||
records.append(msg)
|
||||
elif mesg_num == 19: # lap
|
||||
laps.append(msg)
|
||||
|
||||
if name == "session":
|
||||
for f in record:
|
||||
session[f.name] = f.value
|
||||
stream = Stream.from_file(filepath)
|
||||
decoder = Decoder(stream)
|
||||
decoder.read(
|
||||
apply_scale_and_offset=True,
|
||||
convert_datetimes_to_dates=True,
|
||||
convert_types_to_strings=True,
|
||||
enable_crc_check=False,
|
||||
expand_sub_fields=True,
|
||||
expand_components=True,
|
||||
merge_heart_rates=True,
|
||||
mesg_listener=listener,
|
||||
)
|
||||
|
||||
elif name == "lap":
|
||||
lap = {}
|
||||
for f in record:
|
||||
lap[f.name] = f.value
|
||||
laps.append(lap)
|
||||
|
||||
elif name == "record":
|
||||
point = {}
|
||||
for f in record:
|
||||
point[f.name] = f.value
|
||||
if point:
|
||||
# Convert semicircles to degrees
|
||||
if "position_lat" in point and point["position_lat"] is not None:
|
||||
point["position_lat"] = semicircles_to_degrees(point["position_lat"])
|
||||
if "position_long" in point and point["position_long"] is not None:
|
||||
point["position_long"] = semicircles_to_degrees(point["position_long"])
|
||||
data_points.append(point)
|
||||
|
||||
# Build normalized output
|
||||
# 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)
|
||||
|
||||
start_time = session.get("start_time")
|
||||
if start_time and start_time.tzinfo is None:
|
||||
if isinstance(start_time, datetime) and start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Build GPS track for polyline
|
||||
# Build GPS track
|
||||
coords = [
|
||||
(p["position_lat"], p["position_long"])
|
||||
for p in data_points
|
||||
if p.get("position_lat") is not None and p.get("position_long") is not None
|
||||
(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
|
||||
]
|
||||
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Calculate cumulative distance if not in FIT
|
||||
cumulative_dist = 0.0
|
||||
prev_lat, prev_lon = None, None
|
||||
# Normalize data points
|
||||
normalized_points = []
|
||||
for p in data_points:
|
||||
ts = p.get("timestamp")
|
||||
if ts and ts.tzinfo is None:
|
||||
for r in records:
|
||||
ts = r.get("timestamp")
|
||||
if isinstance(ts, datetime) and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
lat = p.get("position_lat")
|
||||
lon = p.get("position_long")
|
||||
|
||||
dist = p.get("distance")
|
||||
if dist is None and lat and lon and prev_lat and prev_lon:
|
||||
cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon)
|
||||
dist = cumulative_dist
|
||||
elif dist is not None:
|
||||
cumulative_dist = float(dist)
|
||||
|
||||
if lat and lon:
|
||||
prev_lat, prev_lon = lat, lon
|
||||
|
||||
normalized_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"altitude_m": p.get("altitude"),
|
||||
"heart_rate": p.get("heart_rate"),
|
||||
"cadence": p.get("cadence"),
|
||||
"speed_ms": p.get("speed"),
|
||||
"power": p.get("power"),
|
||||
"temperature_c": p.get("temperature"),
|
||||
"distance_m": dist,
|
||||
"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"),
|
||||
})
|
||||
|
||||
# Parse laps
|
||||
# Normalize laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = lap.get("start_time")
|
||||
if ls and ls.tzinfo is None:
|
||||
if isinstance(ls, datetime) and ls.tzinfo is None:
|
||||
ls = ls.replace(tzinfo=timezone.utc)
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
@@ -132,13 +129,17 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
"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")),
|
||||
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
||||
"avg_power": _safe_float(lap.get("avg_power")),
|
||||
})
|
||||
|
||||
# Build activity name
|
||||
name = session.get("sport", "Activity").title()
|
||||
if start_time:
|
||||
name += " " + start_time.strftime("%Y-%m-%d")
|
||||
|
||||
return {
|
||||
"name": session.get("sport", "Activity").title() + " " + (
|
||||
start_time.strftime("%Y-%m-%d") if start_time else ""),
|
||||
"name": name,
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": _safe_float(session.get("total_distance")),
|
||||
@@ -150,12 +151,12 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
"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")),
|
||||
"max_speed_ms": _safe_float(session.get("max_speed")),
|
||||
"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("estimated_sweat_loss")), # varies by device
|
||||
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
@@ -165,13 +166,12 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
"""Parse a GPX file into normalized activity dict."""
|
||||
"""Parse a GPX file."""
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
data_points = []
|
||||
track = gpx.tracks[0] if gpx.tracks else None
|
||||
|
||||
if not track:
|
||||
raise ValueError("No tracks found in GPX file")
|
||||
|
||||
@@ -204,7 +204,6 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
"distance_m": None,
|
||||
})
|
||||
|
||||
# Calculate distance and elevation
|
||||
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
|
||||
@@ -220,6 +219,7 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
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)):
|
||||
@@ -235,7 +235,7 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
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" # GPX doesn't always include sport; default to running
|
||||
sport = "running"
|
||||
if track.type:
|
||||
sport = track.type.lower()
|
||||
|
||||
@@ -266,76 +266,42 @@ def parse_gpx_file(filepath: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def parse_strava_export(export_dir: str) -> list[dict]:
|
||||
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||
"""
|
||||
Parse a full Strava data export directory.
|
||||
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
|
||||
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.
|
||||
"""
|
||||
activities = []
|
||||
activities_dir = Path(export_dir) / "activities"
|
||||
|
||||
if not activities_dir.exists():
|
||||
return activities
|
||||
|
||||
for fname in sorted(activities_dir.iterdir()):
|
||||
if fname.suffix in (".fit", ".gpx"):
|
||||
try:
|
||||
if fname.suffix == ".fit":
|
||||
act = parse_fit_file(str(fname))
|
||||
else:
|
||||
act = parse_gpx_file(str(fname))
|
||||
act["source_type"] = "strava_" + fname.suffix[1:]
|
||||
activities.append(act)
|
||||
except Exception as e:
|
||||
print(f"Error parsing {fname}: {e}")
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict:
|
||||
"""Calculate percentage of time spent in each HR zone."""
|
||||
if not max_hr:
|
||||
if not user_max_hr or user_max_hr < 100:
|
||||
return {}
|
||||
|
||||
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
|
||||
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||
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:
|
||||
if not hr or hr < 20:
|
||||
continue
|
||||
pct = hr / max_hr
|
||||
pct = hr / user_max_hr
|
||||
total += 1
|
||||
if pct < zone_bounds[1]:
|
||||
zones["z1"] += 1
|
||||
elif pct < zone_bounds[2]:
|
||||
zones["z2"] += 1
|
||||
elif pct < zone_bounds[3]:
|
||||
zones["z3"] += 1
|
||||
elif pct < zone_bounds[4]:
|
||||
zones["z4"] += 1
|
||||
for i, key in enumerate(zone_keys):
|
||||
if zone_bounds[i] <= pct < zone_bounds[i+1]:
|
||||
zones[key] += 1
|
||||
break
|
||||
else:
|
||||
zones["z5"] += 1
|
||||
zones["z5"] += 1 # anything above 90% goes to z5
|
||||
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
|
||||
|
||||
def _safe_float(val) -> Optional[float]:
|
||||
try:
|
||||
return float(val) if val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _bounding_box(coords: list[tuple]) -> Optional[dict]:
|
||||
if not coords:
|
||||
return None
|
||||
lats = [c[0] for c in coords]
|
||||
lons = [c[1] for c in coords]
|
||||
return {
|
||||
"min_lat": min(lats), "max_lat": max(lats),
|
||||
"min_lon": min(lons), "max_lon": max(lons),
|
||||
}
|
||||
|
||||
@@ -306,4 +306,4 @@ def parse_wellness_fit(file_path: str) -> dict:
|
||||
"sleep_awake_s": sleep_awake_s,
|
||||
}
|
||||
|
||||
return {"days": result, "error": None}
|
||||
return {"days": result, "error": None}
|
||||
|
||||
@@ -4,4 +4,4 @@ can be started with: celery -A app.workers.celery_app worker
|
||||
"""
|
||||
from app.workers.tasks import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
__all__ = ["celery_app"]
|
||||
|
||||
+147
-134
@@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
if existing:
|
||||
return {"activity_id": existing.id, "status": "duplicate"}
|
||||
|
||||
# Get user's configured max HR for accurate zone calculation
|
||||
# Falls back to: user-set value → 220-age → activity max → 190
|
||||
from app.models.user import User as UserModel
|
||||
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
|
||||
if not user_max_hr and user_obj.birth_year:
|
||||
from datetime import date as _date
|
||||
age = _date.today().year - user_obj.birth_year
|
||||
user_max_hr = 220 - age
|
||||
if not user_max_hr:
|
||||
# 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", []),
|
||||
parsed.get("max_heart_rate") or 190
|
||||
user_max_hr
|
||||
)
|
||||
|
||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||
@@ -169,6 +184,9 @@ 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"}
|
||||
|
||||
|
||||
@@ -176,161 +194,156 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
def parse_wellness_fit(file_path: str, user_id: int):
|
||||
"""
|
||||
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
||||
These files contain resting HR, HRV, sleep, stress, SpO2 etc.
|
||||
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
|
||||
"""
|
||||
import fitparse
|
||||
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import HealthMetric
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime, timezone, date
|
||||
|
||||
try:
|
||||
fit = fitparse.FitFile(file_path)
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
result = _parse(file_path)
|
||||
if result.get("error"):
|
||||
return {"status": "error", "error": result["error"], "file": file_path}
|
||||
|
||||
# Collect all monitoring/daily summary records keyed by date
|
||||
daily = {} # date -> dict of fields
|
||||
|
||||
def get_or_create_day(d: date) -> dict:
|
||||
if d not in daily:
|
||||
daily[d] = {}
|
||||
return daily[d]
|
||||
|
||||
for record in fit.get_messages():
|
||||
name = record.name
|
||||
fields = {f.name: f.value for f in record if f.value is not None}
|
||||
|
||||
if name == "monitoring_info":
|
||||
ts = fields.get("timestamp") or fields.get("local_timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
day.setdefault("resting_hr", fields.get("resting_heart_rate"))
|
||||
|
||||
elif name == "monitoring":
|
||||
ts = fields.get("timestamp") or fields.get("local_timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if not d:
|
||||
continue
|
||||
day = get_or_create_day(d)
|
||||
# Accumulate steps (they're stored as increments)
|
||||
if "steps" in fields:
|
||||
day["steps"] = day.get("steps", 0) + int(fields["steps"])
|
||||
if "heart_rate" in fields:
|
||||
hrs = day.setdefault("heart_rates", [])
|
||||
hrs.append(int(fields["heart_rate"]))
|
||||
if "stress_level_value" in fields:
|
||||
stresses = day.setdefault("stress_values", [])
|
||||
stresses.append(int(fields["stress_level_value"]))
|
||||
|
||||
elif name == "hrv_status_summary":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
day.setdefault("hrv_nightly_avg", fields.get("weekly_average"))
|
||||
day.setdefault("hrv_5min_high", fields.get("last_night_5_min_high"))
|
||||
day.setdefault("hrv_status", str(fields.get("hrv_status", "")))
|
||||
|
||||
elif name == "sleep_level":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
levels = day.setdefault("sleep_levels", [])
|
||||
levels.append(fields.get("sleep_level"))
|
||||
|
||||
elif name == "stress":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
if "stress_level_value" in fields:
|
||||
stresses = day.setdefault("stress_values", [])
|
||||
stresses.append(int(fields["stress_level_value"]))
|
||||
|
||||
elif name == "spo2_data":
|
||||
ts = fields.get("timestamp")
|
||||
if ts:
|
||||
d = ts.date() if hasattr(ts, "date") else None
|
||||
if d:
|
||||
day = get_or_create_day(d)
|
||||
readings = day.setdefault("spo2_readings", [])
|
||||
if "spo2_percent" in fields:
|
||||
readings.append(fields["spo2_percent"])
|
||||
|
||||
if not daily:
|
||||
days = result.get("days", {})
|
||||
if not days:
|
||||
return {"status": "no_data", "file": file_path}
|
||||
|
||||
# Upsert into health_metrics using ON CONFLICT to handle concurrent workers
|
||||
with SyncSessionLocal() as db:
|
||||
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", [])
|
||||
|
||||
resting_hr = data.get("resting_hr")
|
||||
avg_hr = (sum(hrs) / len(hrs)) if hrs else None
|
||||
avg_stress = (sum(stresses) / len(stresses)) if stresses else None
|
||||
spo2_avg = (sum(spo2s) / len(spo2s)) if spo2s else None
|
||||
|
||||
# Garmin sleep levels: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem
|
||||
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) if sleep_levels else None
|
||||
sleep_light_s = sum(30 for l in sleep_levels if l == 2) if sleep_levels else None
|
||||
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) if sleep_levels else None
|
||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) if sleep_levels else None
|
||||
sleep_duration_s = (
|
||||
(sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0)
|
||||
) or None
|
||||
|
||||
for day_date, data in days.items():
|
||||
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
|
||||
|
||||
# ON CONFLICT upsert - race-condition safe, COALESCE preserves existing data
|
||||
db.execute(text("""
|
||||
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress,
|
||||
spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps,
|
||||
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)
|
||||
VALUES (:user_id, :date, :resting_hr, :avg_hr, :avg_stress,
|
||||
:spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps,
|
||||
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)
|
||||
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),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
|
||||
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
|
||||
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||
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),
|
||||
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_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)
|
||||
"""), {
|
||||
"user_id": user_id, "date": date_dt,
|
||||
"resting_hr": resting_hr, "avg_hr": avg_hr,
|
||||
"avg_stress": avg_stress, "spo2_avg": spo2_avg,
|
||||
"resting_hr": data.get("resting_hr"),
|
||||
"avg_hr": data.get("avg_hr_day"),
|
||||
"max_hr": data.get("max_hr_day"),
|
||||
"avg_stress": data.get("avg_stress"),
|
||||
"spo2_avg": data.get("spo2_avg"),
|
||||
"hrv_avg": data.get("hrv_nightly_avg"),
|
||||
"hrv_high": data.get("hrv_5min_high"),
|
||||
"hrv_status": data.get("hrv_status"),
|
||||
"steps": data.get("steps"),
|
||||
"sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s,
|
||||
"sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s,
|
||||
"sleep_awake": sleep_awake_s,
|
||||
"floors": data.get("floors_climbed"),
|
||||
"active_cal": data.get("active_calories"),
|
||||
"total_cal": data.get("total_calories"),
|
||||
"sleep_dur": data.get("sleep_duration_s"),
|
||||
"sleep_deep": data.get("sleep_deep_s"),
|
||||
"sleep_light": data.get("sleep_light_s"),
|
||||
"sleep_rem": data.get("sleep_rem_s"),
|
||||
"sleep_awake": data.get("sleep_awake_s"),
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "days_processed": len(daily), "file": file_path}
|
||||
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.
|
||||
"""
|
||||
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,
|
||||
NamedRoute.sport_type == new_act.sport_type,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for route in routes:
|
||||
if route.reference_polyline and routes_are_similar(
|
||||
new_act.polyline, route.reference_polyline,
|
||||
new_act.bounding_box, route.bounding_box,
|
||||
):
|
||||
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,
|
||||
Activity.sport_type == new_act.sport_type,
|
||||
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,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for candidate in candidates:
|
||||
if routes_are_similar(
|
||||
new_act.polyline, candidate.polyline,
|
||||
new_act.bounding_box, candidate.bounding_box,
|
||||
):
|
||||
# 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
|
||||
|
||||
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
|
||||
new_route = NamedRoute(
|
||||
user_id=user_id,
|
||||
name=route_name,
|
||||
sport_type=older.sport_type,
|
||||
reference_polyline=older.polyline,
|
||||
bounding_box=older.bounding_box,
|
||||
distance_m=older.distance_m,
|
||||
auto_detected=True,
|
||||
)
|
||||
db.add(new_route)
|
||||
db.flush()
|
||||
older.named_route_id = new_route.id
|
||||
newer.named_route_id = new_route.id
|
||||
db.commit()
|
||||
return {"status": "auto_created", "route_id": new_route.id}
|
||||
|
||||
return {"status": "no_match"}
|
||||
|
||||
|
||||
@celery_app.task(name="compute_personal_records")
|
||||
@@ -435,4 +448,4 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
"spo2": data.get("avgSpo2"),
|
||||
})
|
||||
|
||||
db.commit()
|
||||
db.commit()
|
||||
|
||||
@@ -12,6 +12,7 @@ python-multipart==0.0.9
|
||||
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
|
||||
@@ -22,4 +23,4 @@ Pillow==10.3.0
|
||||
aiofiles==23.2.1
|
||||
python-dateutil==2.9.0
|
||||
pytz==2024.1
|
||||
psycopg2-binary==2.9.9
|
||||
psycopg2-binary==2.9.9
|
||||
|
||||
Reference in New Issue
Block a user