Fix five code-review findings: token auth, sync rate-limiting, model drift, FK cascade
- 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:
@@ -68,6 +68,22 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"PR constraint migration skipped: {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)
|
# Seed admin user (only if password is configured)
|
||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Float, DateTime, Boolean,
|
Column, Integer, String, Float, DateTime, Boolean,
|
||||||
ForeignKey, Text, JSON, Index, UniqueConstraint
|
ForeignKey, Text, JSON, Index, UniqueConstraint, text
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -102,7 +102,7 @@ class Activity(Base):
|
|||||||
calories = Column(Float, nullable=True)
|
calories = Column(Float, nullable=True)
|
||||||
training_stress_score = Column(Float, nullable=True)
|
training_stress_score = Column(Float, nullable=True)
|
||||||
vo2max_estimate = 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)
|
polyline = Column(Text, nullable=True)
|
||||||
bounding_box = Column(JSON, nullable=True)
|
bounding_box = Column(JSON, nullable=True)
|
||||||
source_file = Column(String(512), nullable=True)
|
source_file = Column(String(512), nullable=True)
|
||||||
@@ -199,8 +199,12 @@ class PersonalRecord(Base):
|
|||||||
is_current_record = Column(Boolean, default=True)
|
is_current_record = Column(Boolean, default=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
|
||||||
name="uq_pr_current"),
|
# (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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,15 +47,18 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str
|
|||||||
import garminconnect
|
import garminconnect
|
||||||
|
|
||||||
# Try stored OAuth token first.
|
# Try stored OAuth token first.
|
||||||
# Must call login(tokenstore=...) rather than garth.loads() directly so that
|
# Use garth.loads() directly (always treats the argument as an inline string).
|
||||||
# garmin.display_name is populated — it's required by get_user_summary() and
|
# garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as
|
||||||
# several other endpoints. Without it every stats call silently returns None.
|
# 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:
|
if token_store:
|
||||||
try:
|
try:
|
||||||
garmin = garminconnect.Garmin(
|
garmin = garminconnect.Garmin(
|
||||||
email=email, password=decrypt_password(password_enc)
|
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
|
return garmin, None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.info("Garmin token invalid (%s), re-authenticating", 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:
|
lookback_days controls the start date on every sync:
|
||||||
-1 → full history back to 2010 on first sync, then incremental (since-1d)
|
-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.
|
Returns the number of new activities queued.
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
@@ -87,6 +90,9 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
|||||||
if lookback_days == -1:
|
if lookback_days == -1:
|
||||||
# All-time: full pull on first sync, incremental thereafter
|
# All-time: full pull on first sync, incremental thereafter
|
||||||
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 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 activities
|
||||||
|
start_date = (since - timedelta(days=1)).date()
|
||||||
else:
|
else:
|
||||||
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
||||||
end_date = date.today()
|
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:
|
lookback_days controls the window on every sync:
|
||||||
-1 → full history back to 2010 on first sync, then incremental (since-1d)
|
-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.
|
Returns the number of days upserted.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
if lookback_days == -1:
|
if lookback_days == -1:
|
||||||
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 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:
|
else:
|
||||||
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
start_date = date.today() - timedelta(days=max(lookback_days, 1))
|
||||||
days = (date.today() - start_date).days + 1
|
days = (date.today() - start_date).days + 1
|
||||||
processed = 0
|
processed = 0
|
||||||
|
|
||||||
|
import time as _time
|
||||||
for i in range(max(days, 1)):
|
for i in range(max(days, 1)):
|
||||||
day = start_date + timedelta(days=i)
|
day = start_date + timedelta(days=i)
|
||||||
day_str = day.isoformat()
|
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)
|
stats = _safe(garmin.get_stats, day_str)
|
||||||
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
||||||
hrv_data = _safe(garmin.get_hrv_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)
|
row = _parse_day(stats, sleep_data, hrv_data)
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
Reference in New Issue
Block a user