Fix five code-review findings: token auth, sync rate-limiting, model drift, FK cascade
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s

- garmin_connect_sync: revert to garth.loads() for token auth — login(tokenstore=)
  dispatches on len>512, treating compact tokens as filesystem paths and forcing a
  full re-login on every sync. Explicitly set display_name from the embedded profile.
- garmin_connect_sync: restore incremental sync for both activities and wellness —
  always re-fetching the full lookback window was generating ~270 Garmin API calls
  per wellness sync run, risking rate-limits. Now uses since-1d when since is set.
  Add 0.25s per-day sleep in sync_wellness as an additional rate-limit guard.
- models/user.py: replace the dropped uq_pr_current UniqueConstraint in
  PersonalRecord.__table_args__ with the partial Index the DB actually has,
  so the model and live schema no longer permanently diverge.
- models/user.py: add ondelete="SET NULL" to Activity.named_route_id FK so the
  DB cascade handles unlinks if routes are deleted outside the API endpoint.
- main.py: add startup migration to re-add activities_named_route_id_fkey with
  ON DELETE SET NULL on existing deployments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 10:30:20 +01:00
parent 211f77a574
commit a9b3da858d
3 changed files with 41 additions and 10 deletions
+16
View File
@@ -68,6 +68,22 @@ async def init_db():
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}")
# Seed admin user (only if password is configured)
if not settings.admin_password:
print("ADMIN_PASSWORD not set - skipping admin user seed")
+8 -4
View File
@@ -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
@@ -102,7 +102,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)
@@ -199,8 +199,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),
)
+17 -6
View File
@@ -47,15 +47,18 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str
import garminconnect
# 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.
# Use garth.loads() directly (always treats the argument as an inline string).
# garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as
# filesystem paths and raising FileNotFoundError on every token-based auth attempt.
# After loads(), set display_name from the embedded profile — required by
# get_stats(), get_sleep_data(), and other endpoints that build URLs from it.
if token_store:
try:
garmin = garminconnect.Garmin(
email=email, password=decrypt_password(password_enc)
)
garmin.login(tokenstore=token_store)
garmin.garth.loads(token_store)
garmin.display_name = (garmin.garth.profile or {}).get("displayName", "")
return garmin, None
except Exception as exc:
logger.info("Garmin token invalid (%s), re-authenticating", exc)
@@ -76,7 +79,7 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
lookback_days controls the start date on every sync:
-1 → full history back to 2010 on first sync, then incremental (since-1d)
N → always look back N days from today (dedup prevents re-downloading)
N → incremental (since-1d) when since is set; else last N days on first sync
Returns the number of new activities queued.
"""
import time
@@ -87,6 +90,9 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
if lookback_days == -1:
# All-time: full pull on first sync, incremental thereafter
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1)
elif since:
# Incremental: one day overlap to catch any late-arriving activities
start_date = (since - timedelta(days=1)).date()
else:
start_date = date.today() - timedelta(days=max(lookback_days, 1))
end_date = date.today()
@@ -180,18 +186,22 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
lookback_days controls the window on every sync:
-1 → full history back to 2010 on first sync, then incremental (since-1d)
N → always cover the last N days (upsert is safe to re-run)
N → incremental (since-1d) when since is set; else last N days on first sync
Returns the number of days upserted.
"""
from sqlalchemy import text
if lookback_days == -1:
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1)
elif since:
# Incremental: one day overlap to catch any late-arriving wellness data
start_date = (since - timedelta(days=1)).date()
else:
start_date = date.today() - timedelta(days=max(lookback_days, 1))
days = (date.today() - start_date).days + 1
processed = 0
import time as _time
for i in range(max(days, 1)):
day = start_date + timedelta(days=i)
day_str = day.isoformat()
@@ -199,6 +209,7 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
stats = _safe(garmin.get_stats, day_str)
sleep_data = _safe(garmin.get_sleep_data, day_str)
hrv_data = _safe(garmin.get_hrv_data, day_str)
_time.sleep(0.25) # avoid hammering Garmin's wellness API
row = _parse_day(stats, sleep_data, hrv_data)
if not row: