NutritionBot / main.py
lovelymango's picture
Initial deploy: NutritionBot API
b8e6393
import datetime as dt
import os
from functools import lru_cache
from typing import Any, Literal, Optional
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from pydantic import BaseModel, Field
from supabase import Client, create_client
load_dotenv()
app = FastAPI(title="NutritionBot API")
MenuMealType = Literal["breakfast", "lunch", "dinner"]
MealLogMealType = Literal["breakfast", "lunch", "dinner", "snack"]
MealLogStatus = Literal["before_only", "completed"]
class HealthResponse(BaseModel):
status: str
class MenuItem(BaseModel):
name: str
calories: Optional[int] = None
protein_g: Optional[float] = None
carbs_g: Optional[float] = None
fat_g: Optional[float] = None
class FoodItem(BaseModel):
name: str
estimated_served_g: Optional[float] = None
estimated_consumed_g: Optional[float] = None
consumed_ratio: Optional[float] = None
calories: Optional[int] = None
protein_g: Optional[float] = None
carbs_g: Optional[float] = None
fat_g: Optional[float] = None
class SaveMenuRequest(BaseModel):
user_id: str
date: dt.date
meal_type: MenuMealType
menu_items: list[MenuItem]
source: str = "photo_ocr"
class MenuRecord(BaseModel):
id: str
created_at: dt.datetime
user_id: str
date: dt.date
meal_type: MenuMealType
menu_items: list[MenuItem]
source: Optional[str] = None
class SaveMenuResponse(BaseModel):
status: str
menu_id: str
class TodayMenuResponse(BaseModel):
menus: list[MenuRecord]
class SaveMealRequest(BaseModel):
user_id: str
date: dt.date = Field(default_factory=dt.date.today)
meal_type: MealLogMealType
menu_id: Optional[str] = None
status: MealLogStatus = "before_only"
food_items: list[FoodItem]
total_calories: int
total_protein_g: float
total_carbs_g: float
total_fat_g: float
confidence_score: float = Field(default=0.0, ge=0.0, le=1.0)
analysis_note: Optional[str] = None
class UpdateMealRequest(BaseModel):
user_id: str
date: Optional[dt.date] = None
meal_type: Optional[MealLogMealType] = None
menu_id: Optional[str] = None
status: Optional[MealLogStatus] = None
food_items: Optional[list[FoodItem]] = None
total_calories: Optional[int] = None
total_protein_g: Optional[float] = None
total_carbs_g: Optional[float] = None
total_fat_g: Optional[float] = None
confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
analysis_note: Optional[str] = None
class MealLogRecord(BaseModel):
id: str
created_at: dt.datetime
user_id: str
date: dt.date
meal_type: MealLogMealType
menu_id: Optional[str] = None
status: MealLogStatus
food_items: list[FoodItem]
total_calories: int
total_protein_g: float
total_carbs_g: float
total_fat_g: float
confidence_score: Optional[float] = 0.0
analysis_note: Optional[str] = None
class SaveMealResponse(BaseModel):
status: str
meal_id: str
class UpdateMealResponse(BaseModel):
status: str
meal_id: str
class MealHistoryResponse(BaseModel):
meals: list[MealLogRecord]
count: int
def get_required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
@lru_cache
def get_supabase_client() -> Client:
return create_client(
get_required_env("SUPABASE_URL"),
get_required_env("SUPABASE_KEY"),
)
def get_client() -> Client:
try:
return get_supabase_client()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
def verify_api_key(x_api_key: str = Header(..., alias="X-Api-Key")) -> None:
expected_api_key = os.environ.get("API_KEY")
if not expected_api_key or x_api_key != expected_api_key:
raise HTTPException(status_code=401, detail="Unauthorized")
def execute_supabase(query: Any) -> Any:
try:
response = query.execute()
if response is not None:
error = getattr(response, "error", None)
if error:
raise HTTPException(status_code=500, detail=str(error))
return response
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
def get_first_row_or_500(response: Any, entity_name: str) -> dict[str, Any]:
rows = getattr(response, "data", None) or []
if not rows:
raise HTTPException(
status_code=500,
detail=f"No {entity_name} returned from Supabase.",
)
return rows[0]
@app.get("/", response_model=HealthResponse)
def healthcheck() -> HealthResponse:
return HealthResponse(status="ok")
@app.post("/save-menu", response_model=SaveMenuResponse)
def save_menu(
payload: SaveMenuRequest,
_: None = Depends(verify_api_key),
) -> SaveMenuResponse:
supabase = get_client()
response = execute_supabase(
supabase.table("school_menus").upsert(
payload.model_dump(mode="json"),
on_conflict="user_id,date,meal_type",
)
)
rows = getattr(response, "data", None) or []
if rows:
return SaveMenuResponse(status="saved", menu_id=str(rows[0]["id"]))
lookup = execute_supabase(
supabase.table("school_menus")
.select("id")
.eq("user_id", payload.user_id)
.eq("date", payload.date.isoformat())
.eq("meal_type", payload.meal_type)
.maybe_single()
)
if lookup is None or lookup.data is None:
raise HTTPException(status_code=500, detail="Menu was saved but no row was returned.")
return SaveMenuResponse(status="saved", menu_id=str(lookup.data["id"]))
@app.get("/today-menu", response_model=TodayMenuResponse)
def get_today_menu(
user_id: str = Query(...),
request_date: Optional[dt.date] = Query(default=None, alias="date"),
meal_type: Optional[MenuMealType] = Query(default=None),
_: None = Depends(verify_api_key),
) -> TodayMenuResponse:
supabase = get_client()
target_date = request_date or dt.date.today()
query = (
supabase.table("school_menus")
.select("*")
.eq("user_id", user_id)
.eq("date", target_date.isoformat())
)
if meal_type:
query = query.eq("meal_type", meal_type)
response = execute_supabase(query.order("meal_type"))
return TodayMenuResponse(menus=getattr(response, "data", None) or [])
@app.post("/save-meal", response_model=SaveMealResponse)
def save_meal(
payload: SaveMealRequest,
_: None = Depends(verify_api_key),
) -> SaveMealResponse:
supabase = get_client()
response = execute_supabase(
supabase.table("meal_logs").insert(payload.model_dump(mode="json"))
)
meal_row = get_first_row_or_500(response, "meal log")
return SaveMealResponse(status="saved", meal_id=str(meal_row["id"]))
@app.patch("/update-meal/{meal_id}", response_model=UpdateMealResponse)
def update_meal(
meal_id: str,
payload: UpdateMealRequest,
_: None = Depends(verify_api_key),
) -> UpdateMealResponse:
supabase = get_client()
existing_response = execute_supabase(
supabase.table("meal_logs").select("*").eq("id", meal_id).maybe_single()
)
if existing_response is None or existing_response.data is None:
raise HTTPException(status_code=404, detail="Meal log not found")
existing_meal = existing_response.data
if existing_meal.get("user_id") != payload.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
update_data = payload.model_dump(
mode="json",
exclude={"user_id"},
exclude_unset=True,
)
if not update_data:
return UpdateMealResponse(status="updated", meal_id=meal_id)
update_response = execute_supabase(
supabase.table("meal_logs")
.update(update_data)
.eq("id", meal_id)
.eq("user_id", payload.user_id)
)
get_first_row_or_500(update_response, "updated meal log")
return UpdateMealResponse(status="updated", meal_id=meal_id)
@app.get("/meal-history", response_model=MealHistoryResponse)
def get_meal_history(
user_id: str = Query(...),
days: int = Query(default=7, ge=0),
limit: int = Query(default=20, ge=1, le=50),
_: None = Depends(verify_api_key),
) -> MealHistoryResponse:
supabase = get_client()
since_date = dt.date.today() - dt.timedelta(days=days)
response = execute_supabase(
supabase.table("meal_logs")
.select("*")
.eq("user_id", user_id)
.gte("date", since_date.isoformat())
.order("created_at", desc=True)
.limit(limit)
)
meals = getattr(response, "data", None) or []
return MealHistoryResponse(meals=meals, count=len(meals))