Spaces:
Sleeping
Sleeping
| 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 | |
| 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] | |
| def healthcheck() -> HealthResponse: | |
| return HealthResponse(status="ok") | |
| 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"])) | |
| 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 []) | |
| 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"])) | |
| 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) | |
| 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)) | |