Files
MileVault/backend/app/main.py
T
owain 02eccad578
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
Add segments, YTD stats, route matching fixes, body battery layout, pace fix
- Segments page: new /segments route with auto-generate (1km splits, turn
  detection, hill detection), manual segment creation, per-segment performance
  times across matched activities; fixed auth on existing segment endpoints
- YTD distance: new /activities/stats/ytd endpoint; Dashboard replaces
  'Total distance' with 'Running this year' + 'Cycling this year'; Activities
  page shows YTD stats row
- Weekly chart click: clicking a Dashboard bar navigates to Activities filtered
  to that week; Activities reads from/to query params with dismissable chip
- Route matching: add ±2.5% distance gate + 3% relative DTW threshold
  (was flat 80m); tighten candidate pre-filter from 80/120% to 95/105%
- Body battery layout: HR chart and body battery now side-by-side at same
  height on large screens instead of stacked full-width
- Pace display fix: MetricTimeline clamps GPS speed outliers before computing
  Y-axis domain; tick formatter guards against v<=0 or v>25 m/s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:01:25 +01:00

173 lines
6.5 KiB
Python

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, profile, garmin_sync
async def init_db():
"""Create tables then seed admin, with retries for slow DB startup.
Multiple uvicorn workers may race here on first start. We tolerate
duplicate table errors since they just mean another worker got there first.
"""
for attempt in range(15):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
break
except Exception as e:
msg = str(e).lower()
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
print("Tables already created by another worker - skipping")
break
if attempt == 14:
raise
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
await asyncio.sleep(2)
# Try TimescaleDB hypertable (non-fatal)
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 as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Add columns that were introduced after initial table creation (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE garmin_connect_configs "
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
))
except Exception as e:
print(f"Column migration skipped: {e}")
# health_metrics columns added after initial creation
try:
async with engine.begin() as conn:
for stmt in [
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
]:
await conn.execute(text(stmt))
except Exception as e:
print(f"health_metrics column migration skipped: {e}")
# route_segments auto_generated column added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
))
except Exception as e:
print(f"route_segments column migration skipped: {e}")
# Replace the all-columns unique constraint on personal_records with a partial
# index (only current records must be unique per user/sport/distance).
# The old constraint also covered is_current_record=False rows, causing
# UniqueViolation crashes when multiple workers deactivate the same PR.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE personal_records "
"DROP CONSTRAINT IF EXISTS uq_pr_current"
))
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
"ON personal_records (user_id, sport_type, distance_m) "
"WHERE is_current_record = true"
))
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")
return
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
)
db.add(admin)
await db.commit()
print(f"Admin user '{settings.admin_username}' created")
except Exception as e:
msg = str(e).lower()
if "duplicate" in msg or "unique" in msg:
print("Admin user already exists - skipping seed")
else:
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="MileVault",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.environment == "development" else [],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
app.include_router(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
@app.get("/health")
async def healthcheck():
return {"status": "ok"}