Fix DB init - composite PK for hypertable + separate transactions
This commit is contained in:
+24
-8
@@ -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 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)
|
||||
|
||||
# Try to enable TimescaleDB hypertable for data points
|
||||
# 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}")
|
||||
|
||||
# Seed admin user
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Step 3: seed admin user
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
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
|
||||
|
||||
|
||||
|
||||
+17
-49
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user