diff --git a/backend/app/main.py b/backend/app/main.py index a1aa293..9882258 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,33 +2,44 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from sqlalchemy import text +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 -@asynccontextmanager -async def lifespan(app: FastAPI): - # Create tables - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Try to enable TimescaleDB hypertable for data points +async def init_db(): + """Create tables then seed admin, with retries for slow DB startup.""" + for attempt in range(10): try: + # Step 1: create all tables (separate connection so it commits cleanly) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + break + except Exception as e: + if attempt == 9: + raise + print(f"DB not ready yet (attempt {attempt + 1}/10): {e}") + await asyncio.sleep(3) + + # Step 2: try to enable TimescaleDB hypertable (separate connection, + # failure here is non-fatal - falls back to plain Postgres) + try: + async with engine.begin() as conn: await conn.execute(text( "SELECT create_hypertable('activity_data_points', 'timestamp', " "if_not_exists => TRUE, migrate_data => TRUE)" )) - except Exception: - pass # Already exists or TimescaleDB not available + except Exception as e: + print(f"TimescaleDB hypertable skipped: {e}") + + # Step 3: seed admin user + from sqlalchemy import select + from app.models.user import User + from app.core.security import hash_password - # Seed admin user async with AsyncSessionLocal() as db: - from sqlalchemy import select - from app.models.user import User - from app.core.security import hash_password - result = await db.execute( select(User).where(User.username == settings.admin_username) ) @@ -40,7 +51,12 @@ async def lifespan(app: FastAPI): ) db.add(admin) await db.commit() + print(f"Admin user '{settings.admin_username}' created") + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() yield @@ -68,4 +84,4 @@ app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) @app.get("/health") async def healthcheck(): - return {"status": "ok"} + return {"status": "ok"} \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5c4f0c8..41d8c8e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -17,7 +17,7 @@ class User(Base): id = Column(Integer, primary_key=True) username = Column(String(64), unique=True, nullable=False, index=True) email = Column(String(256), unique=True, nullable=True) - hashed_password = Column(String(256), nullable=True) # null = OIDC-only user + hashed_password = Column(String(256), nullable=True) is_admin = Column(Boolean, default=False) pocketid_sub = Column(String(256), unique=True, nullable=True) created_at = Column(DateTime(timezone=True), default=now_utc) @@ -32,16 +32,12 @@ class Activity(Base): id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) - - # Core fields name = Column(String(256), nullable=False) - sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc. + sport_type = Column(String(64), nullable=False) start_time = Column(DateTime(timezone=True), nullable=False, index=True) end_time = Column(DateTime(timezone=True), nullable=True) - - # Metrics summary (cached aggregates) - distance_m = Column(Float, nullable=True) # metres - duration_s = Column(Float, nullable=True) # seconds + distance_m = Column(Float, nullable=True) + duration_s = Column(Float, nullable=True) elevation_gain_m = Column(Float, nullable=True) elevation_loss_m = Column(Float, nullable=True) avg_heart_rate = Column(Float, nullable=True) @@ -55,23 +51,14 @@ class Activity(Base): calories = Column(Float, nullable=True) training_stress_score = Column(Float, nullable=True) vo2max_estimate = Column(Float, nullable=True) - - # Route reference named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True) - - # Raw GPS track (encoded polyline for quick map render) polyline = Column(Text, nullable=True) - bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon} - - # Source file info + bounding_box = Column(JSON, nullable=True) source_file = Column(String(512), nullable=True) - source_type = Column(String(32), nullable=True) # fit, gpx, strava_json + source_type = Column(String(32), nullable=True) garmin_activity_id = Column(String(64), nullable=True, unique=True) strava_activity_id = Column(String(64), nullable=True, unique=True) - - # HR zones (% of time in each zone) - hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...} - + hr_zones = Column(JSON, nullable=True) created_at = Column(DateTime(timezone=True), default=now_utc) user = relationship("User", back_populates="activities") @@ -83,14 +70,13 @@ class Activity(Base): class ActivityDataPoint(Base): """ TimescaleDB hypertable - one row per second of activity data. - After creation, converted to hypertable in migration: - SELECT create_hypertable('activity_data_points', 'timestamp'); + Composite primary key (activity_id, timestamp) satisfies TimescaleDB's + requirement that the partition column be part of the primary key. """ __tablename__ = "activity_data_points" - id = Column(Integer, primary_key=True) - activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True) - timestamp = Column(DateTime(timezone=True), nullable=False) + activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True) + timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True) latitude = Column(Float, nullable=True) longitude = Column(Float, nullable=True) altitude_m = Column(Float, nullable=True) @@ -99,11 +85,7 @@ class ActivityDataPoint(Base): speed_ms = Column(Float, nullable=True) power = Column(Float, nullable=True) temperature_c = Column(Float, nullable=True) - distance_m = Column(Float, nullable=True) # cumulative distance - - __table_args__ = ( - Index("ix_adp_activity_time", "activity_id", "timestamp"), - ) + distance_m = Column(Float, nullable=True) activity = relationship("Activity", back_populates="data_points") @@ -133,7 +115,7 @@ class NamedRoute(Base): name = Column(String(256), nullable=False) description = Column(Text, nullable=True) sport_type = Column(String(64), nullable=True) - reference_polyline = Column(Text, nullable=True) # canonical route polyline + reference_polyline = Column(Text, nullable=True) bounding_box = Column(JSON, nullable=True) distance_m = Column(Float, nullable=True) created_at = Column(DateTime(timezone=True), default=now_utc) @@ -144,13 +126,12 @@ class NamedRoute(Base): class RouteSegment(Base): - """Named sections within a route for targeted comparisons (e.g. 'The big hill')""" __tablename__ = "route_segments" id = Column(Integer, primary_key=True) route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True) name = Column(String(256), nullable=False) - start_distance_m = Column(Float, nullable=False) # distance into route where segment starts + start_distance_m = Column(Float, nullable=False) end_distance_m = Column(Float, nullable=False) description = Column(Text, nullable=True) @@ -164,8 +145,8 @@ class PersonalRecord(Base): user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False) sport_type = Column(String(64), nullable=False) - distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195 - distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k" + distance_m = Column(Float, nullable=False) + distance_label = Column(String(32), nullable=False) duration_s = Column(Float, nullable=False) achieved_at = Column(DateTime(timezone=True), nullable=False) is_current_record = Column(Boolean, default=True) @@ -177,25 +158,18 @@ class PersonalRecord(Base): class HealthMetric(Base): - """Daily health summary metrics from Garmin Connect / FIT wellness data""" __tablename__ = "health_metrics" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) date = Column(DateTime(timezone=True), nullable=False) - - # Heart rate resting_hr = Column(Float, nullable=True) max_hr_day = Column(Float, nullable=True) avg_hr_day = Column(Float, nullable=True) - - # HRV - hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc. + hrv_status = Column(String(32), nullable=True) hrv_nightly_avg = Column(Float, nullable=True) hrv_5min_high = Column(Float, nullable=True) hrv_5min_low = Column(Float, nullable=True) - - # Sleep sleep_duration_s = Column(Float, nullable=True) sleep_deep_s = Column(Float, nullable=True) sleep_light_s = Column(Float, nullable=True) @@ -204,20 +178,14 @@ class HealthMetric(Base): sleep_score = Column(Float, nullable=True) sleep_start = Column(DateTime(timezone=True), nullable=True) sleep_end = Column(DateTime(timezone=True), nullable=True) - - # Body composition weight_kg = Column(Float, nullable=True) bmi = Column(Float, nullable=True) body_fat_pct = Column(Float, nullable=True) muscle_mass_kg = Column(Float, nullable=True) - - # Fitness vo2max = Column(Float, nullable=True) fitness_age = Column(Integer, nullable=True) training_load = Column(Float, nullable=True) recovery_time_h = Column(Float, nullable=True) - - # Stress & activity avg_stress = Column(Float, nullable=True) steps = Column(Integer, nullable=True) floors_climbed = Column(Integer, nullable=True) @@ -230,4 +198,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") \ No newline at end of file