Files
Meal-Planner/backend/app/main.py
T
admin 2695ba0232
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s
feat(meals): enforce three lower-calorie dinners with media
2026-09-01 10:11:36 -07:00

120 lines
4.7 KiB
Python

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"])