Fix Garmin stats sync, add route merge/map/links, fix PR constraint
Garmin health: fix display_name=None when using stored OAuth tokens.
authenticate_garmin() now calls login(tokenstore=...) instead of
garth.loads() directly, so display_name is populated and get_user_summary
works. Also add avg_hr_day / max_hr_day from stats response.
Routes: add merge endpoint (POST /{id}/merge/{source}), delete endpoint.
Routes page: polyline SVG mini-map on each route card, merge UI with
confirmation, activity rows are now Links to the activity detail page.
Personal records: replace all-columns unique constraint with a partial
index (unique on current records only) to stop UniqueViolation crashes
when parallel workers deactivate the same PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,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,
|
||||
|
||||
@@ -50,6 +50,24 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"Column migration 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}")
|
||||
|
||||
# Seed admin user (only if password is configured)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
|
||||
@@ -46,15 +46,17 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str
|
||||
"""
|
||||
import garminconnect
|
||||
|
||||
# Try stored OAuth token first (garth auto-refreshes access token on use)
|
||||
# Try stored OAuth token first.
|
||||
# Must call login(tokenstore=...) rather than garth.loads() directly so that
|
||||
# garmin.display_name is populated — it's required by get_user_summary() and
|
||||
# several other endpoints. Without it every stats call silently returns None.
|
||||
if token_store:
|
||||
try:
|
||||
garmin = garminconnect.Garmin(
|
||||
email=email, password=decrypt_password(password_enc)
|
||||
)
|
||||
garmin.garth.loads(token_store)
|
||||
garmin.get_full_name() # lightweight request; triggers refresh if needed
|
||||
return garmin, None # tokens still valid
|
||||
garmin.login(tokenstore=token_store)
|
||||
return garmin, None
|
||||
except Exception as exc:
|
||||
logger.info("Garmin token invalid (%s), re-authenticating", exc)
|
||||
|
||||
@@ -234,6 +236,8 @@ 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"))
|
||||
_set(row, "max_hr_day", stats.get("maxHeartRate"))
|
||||
_set(row, "steps", stats.get("totalSteps"))
|
||||
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||
_set(row, "avg_stress", stats.get("averageStressLevel"))
|
||||
|
||||
Reference in New Issue
Block a user