from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, HTTPException, Request from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from sqlalchemy import text from app.database import get_db from app.config import settings from app.security import require_session import logging logging.basicConfig(level=settings.LOG_LEVEL) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): # Auto-migrate on boot. Idempotent: alembic upgrade head is a no-op # when the schema is already at head. Catches the recurring failure # where the DB is at revision N but the image expects N+1 (e.g. a # new column like denial_expires_at), which would otherwise 500 the # first request that touches the new column. Skip when SKIP_AUTO_MIGRATE # is set (tests / read-only replicas). import os if not os.getenv("SKIP_AUTO_MIGRATE"): try: from alembic.config import Config from alembic import command cfg = Config("alembic.ini") command.upgrade(cfg, "head") logger.info("auto-migrate: alembic upgrade head OK") except Exception as e: # Don't crash boot — log loud so it's visible in `docker logs`. logger.error(f"auto-migrate FAILED: {e!r}") yield app = FastAPI( title="MealPlanner", description="Self-hosted meal planning system", version="0.1.0", lifespan=lifespan, ) # Serve generated recipe images from local filesystem. app.mount("/static", StaticFiles(directory="static"), name="static") def _requires_session(path: str, method: str) -> bool: if method == "OPTIONS" or not path.startswith("/api/"): return False if path.startswith("/api/auth/") or path.startswith("/api/admin/"): return False # Email approval links carry their own signed, single-use token. if path.startswith("/api/meals/vote/"): return False return True @app.middleware("http") async def require_family_session(request: Request, call_next): if _requires_session(request.url.path, request.method): try: require_session(request) except HTTPException as exc: return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, headers=getattr(exc, "headers", None), ) return await call_next(request) @app.get("/health") def health_check(db: Session = Depends(get_db)): return {"status": "ok"} @app.get("/health/db") def health_check_db(db: Session = Depends(get_db)): try: db.execute(text("SELECT 1")) return {"status": "ok", "database": "connected"} except Exception as e: return {"status": "error", "database": "disconnected", "error": str(e)} from app.api import profile, meals, shopping_list, pantry, admin, auth from app.api import ingredients as ingredients_api from app.api import recipe_search as recipe_search_api from app.api import recipes as recipes_api from app.api import never_suggest as never_suggest_api from app.api import meal_plans as meal_plans_api from app.api import feedback as feedback_api from app.api import orchestrate as orchestrate_api from app.api import llm_plan as llm_plan_api app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(meals.router, prefix="/api/meals", tags=["meals"]) app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"]) app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(orchestrate_api.router, prefix="/api/orchestrate", tags=["orchestrate"]) # Retired external search/import routes stay mounted before /{recipe_id} # so legacy clients receive 410 rather than UUID validation errors. app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"]) app.include_router(ingredients_api.public_router) app.include_router(ingredients_api.admin_router) app.include_router(ingredients_api._match_admin_router) app.include_router(recipes_api.public_router) app.include_router(recipes_api.admin_router) app.include_router(never_suggest_api.public_router) app.include_router(never_suggest_api.admin_router) app.include_router(meal_plans_api.admin_router) app.include_router(meal_plans_api.public_router) app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"]) # Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud. app.include_router(llm_plan_api.router, prefix="/api/llm", tags=["llm"])