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}") # 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"}