Fix DB init - composite PK for hypertable + separate transactions
This commit is contained in:
+31
-15
@@ -2,33 +2,44 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from app.core.database import engine, AsyncSessionLocal, Base
|
from app.core.database import engine, AsyncSessionLocal, Base
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api import auth, activities, routes, health, records, upload
|
from app.api import auth, activities, routes, health, records, upload
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
async def init_db():
|
||||||
async def lifespan(app: FastAPI):
|
"""Create tables then seed admin, with retries for slow DB startup."""
|
||||||
# Create tables
|
for attempt in range(10):
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
# Try to enable TimescaleDB hypertable for data points
|
|
||||||
try:
|
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(
|
await conn.execute(text(
|
||||||
"SELECT create_hypertable('activity_data_points', 'timestamp', "
|
"SELECT create_hypertable('activity_data_points', 'timestamp', "
|
||||||
"if_not_exists => TRUE, migrate_data => TRUE)"
|
"if_not_exists => TRUE, migrate_data => TRUE)"
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Already exists or TimescaleDB not available
|
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:
|
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(
|
result = await db.execute(
|
||||||
select(User).where(User.username == settings.admin_username)
|
select(User).where(User.username == settings.admin_username)
|
||||||
)
|
)
|
||||||
@@ -40,7 +51,12 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
print(f"Admin user '{settings.admin_username}' created")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -68,4 +84,4 @@ app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def healthcheck():
|
async def healthcheck():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
+18
-50
@@ -17,7 +17,7 @@ class User(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
email = Column(String(256), unique=True, nullable=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)
|
is_admin = Column(Boolean, default=False)
|
||||||
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
@@ -32,16 +32,12 @@ class Activity(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
|
||||||
# Core fields
|
|
||||||
name = Column(String(256), nullable=False)
|
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)
|
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
distance_m = Column(Float, nullable=True)
|
||||||
# Metrics summary (cached aggregates)
|
duration_s = Column(Float, nullable=True)
|
||||||
distance_m = Column(Float, nullable=True) # metres
|
|
||||||
duration_s = Column(Float, nullable=True) # seconds
|
|
||||||
elevation_gain_m = Column(Float, nullable=True)
|
elevation_gain_m = Column(Float, nullable=True)
|
||||||
elevation_loss_m = Column(Float, nullable=True)
|
elevation_loss_m = Column(Float, nullable=True)
|
||||||
avg_heart_rate = Column(Float, nullable=True)
|
avg_heart_rate = Column(Float, nullable=True)
|
||||||
@@ -55,23 +51,14 @@ 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)
|
||||||
|
|
||||||
# Route reference
|
|
||||||
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
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)
|
polyline = Column(Text, nullable=True)
|
||||||
bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon}
|
bounding_box = Column(JSON, nullable=True)
|
||||||
|
|
||||||
# Source file info
|
|
||||||
source_file = Column(String(512), 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)
|
garmin_activity_id = Column(String(64), nullable=True, unique=True)
|
||||||
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
||||||
|
hr_zones = Column(JSON, nullable=True)
|
||||||
# HR zones (% of time in each zone)
|
|
||||||
hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...}
|
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
user = relationship("User", back_populates="activities")
|
user = relationship("User", back_populates="activities")
|
||||||
@@ -83,14 +70,13 @@ class Activity(Base):
|
|||||||
class ActivityDataPoint(Base):
|
class ActivityDataPoint(Base):
|
||||||
"""
|
"""
|
||||||
TimescaleDB hypertable - one row per second of activity data.
|
TimescaleDB hypertable - one row per second of activity data.
|
||||||
After creation, converted to hypertable in migration:
|
Composite primary key (activity_id, timestamp) satisfies TimescaleDB's
|
||||||
SELECT create_hypertable('activity_data_points', 'timestamp');
|
requirement that the partition column be part of the primary key.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "activity_data_points"
|
__tablename__ = "activity_data_points"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
||||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
|
||||||
timestamp = Column(DateTime(timezone=True), nullable=False)
|
|
||||||
latitude = Column(Float, nullable=True)
|
latitude = Column(Float, nullable=True)
|
||||||
longitude = Column(Float, nullable=True)
|
longitude = Column(Float, nullable=True)
|
||||||
altitude_m = Column(Float, nullable=True)
|
altitude_m = Column(Float, nullable=True)
|
||||||
@@ -99,11 +85,7 @@ class ActivityDataPoint(Base):
|
|||||||
speed_ms = Column(Float, nullable=True)
|
speed_ms = Column(Float, nullable=True)
|
||||||
power = Column(Float, nullable=True)
|
power = Column(Float, nullable=True)
|
||||||
temperature_c = Column(Float, nullable=True)
|
temperature_c = Column(Float, nullable=True)
|
||||||
distance_m = Column(Float, nullable=True) # cumulative distance
|
distance_m = Column(Float, nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_adp_activity_time", "activity_id", "timestamp"),
|
|
||||||
)
|
|
||||||
|
|
||||||
activity = relationship("Activity", back_populates="data_points")
|
activity = relationship("Activity", back_populates="data_points")
|
||||||
|
|
||||||
@@ -133,7 +115,7 @@ class NamedRoute(Base):
|
|||||||
name = Column(String(256), nullable=False)
|
name = Column(String(256), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
sport_type = Column(String(64), 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)
|
bounding_box = Column(JSON, nullable=True)
|
||||||
distance_m = Column(Float, nullable=True)
|
distance_m = Column(Float, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
@@ -144,13 +126,12 @@ class NamedRoute(Base):
|
|||||||
|
|
||||||
|
|
||||||
class RouteSegment(Base):
|
class RouteSegment(Base):
|
||||||
"""Named sections within a route for targeted comparisons (e.g. 'The big hill')"""
|
|
||||||
__tablename__ = "route_segments"
|
__tablename__ = "route_segments"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
||||||
name = Column(String(256), nullable=False)
|
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)
|
end_distance_m = Column(Float, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
@@ -164,8 +145,8 @@ class PersonalRecord(Base):
|
|||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
||||||
sport_type = Column(String(64), nullable=False)
|
sport_type = Column(String(64), nullable=False)
|
||||||
distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195
|
distance_m = Column(Float, nullable=False)
|
||||||
distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k"
|
distance_label = Column(String(32), nullable=False)
|
||||||
duration_s = Column(Float, nullable=False)
|
duration_s = Column(Float, nullable=False)
|
||||||
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
is_current_record = Column(Boolean, default=True)
|
is_current_record = Column(Boolean, default=True)
|
||||||
@@ -177,25 +158,18 @@ class PersonalRecord(Base):
|
|||||||
|
|
||||||
|
|
||||||
class HealthMetric(Base):
|
class HealthMetric(Base):
|
||||||
"""Daily health summary metrics from Garmin Connect / FIT wellness data"""
|
|
||||||
__tablename__ = "health_metrics"
|
__tablename__ = "health_metrics"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
date = Column(DateTime(timezone=True), nullable=False)
|
date = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
# Heart rate
|
|
||||||
resting_hr = Column(Float, nullable=True)
|
resting_hr = Column(Float, nullable=True)
|
||||||
max_hr_day = Column(Float, nullable=True)
|
max_hr_day = Column(Float, nullable=True)
|
||||||
avg_hr_day = Column(Float, nullable=True)
|
avg_hr_day = Column(Float, nullable=True)
|
||||||
|
hrv_status = Column(String(32), nullable=True)
|
||||||
# HRV
|
|
||||||
hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc.
|
|
||||||
hrv_nightly_avg = Column(Float, nullable=True)
|
hrv_nightly_avg = Column(Float, nullable=True)
|
||||||
hrv_5min_high = Column(Float, nullable=True)
|
hrv_5min_high = Column(Float, nullable=True)
|
||||||
hrv_5min_low = Column(Float, nullable=True)
|
hrv_5min_low = Column(Float, nullable=True)
|
||||||
|
|
||||||
# Sleep
|
|
||||||
sleep_duration_s = Column(Float, nullable=True)
|
sleep_duration_s = Column(Float, nullable=True)
|
||||||
sleep_deep_s = Column(Float, nullable=True)
|
sleep_deep_s = Column(Float, nullable=True)
|
||||||
sleep_light_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_score = Column(Float, nullable=True)
|
||||||
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
||||||
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Body composition
|
|
||||||
weight_kg = Column(Float, nullable=True)
|
weight_kg = Column(Float, nullable=True)
|
||||||
bmi = Column(Float, nullable=True)
|
bmi = Column(Float, nullable=True)
|
||||||
body_fat_pct = Column(Float, nullable=True)
|
body_fat_pct = Column(Float, nullable=True)
|
||||||
muscle_mass_kg = Column(Float, nullable=True)
|
muscle_mass_kg = Column(Float, nullable=True)
|
||||||
|
|
||||||
# Fitness
|
|
||||||
vo2max = Column(Float, nullable=True)
|
vo2max = Column(Float, nullable=True)
|
||||||
fitness_age = Column(Integer, nullable=True)
|
fitness_age = Column(Integer, nullable=True)
|
||||||
training_load = Column(Float, nullable=True)
|
training_load = Column(Float, nullable=True)
|
||||||
recovery_time_h = Column(Float, nullable=True)
|
recovery_time_h = Column(Float, nullable=True)
|
||||||
|
|
||||||
# Stress & activity
|
|
||||||
avg_stress = Column(Float, nullable=True)
|
avg_stress = Column(Float, nullable=True)
|
||||||
steps = Column(Integer, nullable=True)
|
steps = Column(Integer, nullable=True)
|
||||||
floors_climbed = 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"),
|
Index("ix_health_user_date", "user_id", "date"),
|
||||||
)
|
)
|
||||||
|
|
||||||
user = relationship("User", back_populates="health_metrics")
|
user = relationship("User", back_populates="health_metrics")
|
||||||
Reference in New Issue
Block a user