mirror of
https://github.com/sstent/foodplanner.git
synced 2025-12-06 08:01:47 +00:00
458 lines
15 KiB
Python
458 lines
15 KiB
Python
"""
|
|
Database models and session management for the meal planner app
|
|
"""
|
|
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text, Date, Boolean
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import sessionmaker, Session, relationship, declarative_base
|
|
from sqlalchemy.orm import joinedload
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
from typing import List, Optional
|
|
from datetime import date, datetime
|
|
import os
|
|
|
|
# Database setup - Use SQLite for easier setup
|
|
# Use environment variables if set, otherwise use defaults
|
|
# Use current directory for database
|
|
DATABASE_PATH = os.getenv('DATABASE_PATH', '.')
|
|
DATABASE_URL = os.getenv('DATABASE_URL', f'sqlite:///{DATABASE_PATH}/meal_planner.db')
|
|
|
|
# For production, use PostgreSQL: DATABASE_URL = "postgresql://username:password@localhost/meal_planner"
|
|
|
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {})
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
Base = declarative_base()
|
|
|
|
# Database Models
|
|
class Food(Base):
|
|
__tablename__ = "foods"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, unique=True, index=True)
|
|
serving_size = Column(String)
|
|
serving_unit = Column(String)
|
|
calories = Column(Float)
|
|
protein = Column(Float)
|
|
carbs = Column(Float)
|
|
fat = Column(Float)
|
|
fiber = Column(Float, default=0)
|
|
sugar = Column(Float, default=0)
|
|
sodium = Column(Float, default=0)
|
|
calcium = Column(Float, default=0)
|
|
source = Column(String, default="manual") # manual, csv, openfoodfacts
|
|
brand = Column(String, default="") # Brand name for the food
|
|
|
|
class Meal(Base):
|
|
__tablename__ = "meals"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, index=True)
|
|
meal_type = Column(String) # breakfast, lunch, dinner, snack, custom
|
|
meal_time = Column(String, default="Breakfast") # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
|
|
|
|
# Relationship to meal foods
|
|
meal_foods = relationship("MealFood", back_populates="meal")
|
|
|
|
class MealFood(Base):
|
|
__tablename__ = "meal_foods"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
meal_id = Column(Integer, ForeignKey("meals.id"))
|
|
food_id = Column(Integer, ForeignKey("foods.id"))
|
|
quantity = Column(Float)
|
|
|
|
meal = relationship("Meal", back_populates="meal_foods")
|
|
food = relationship("Food")
|
|
|
|
class Plan(Base):
|
|
__tablename__ = "plans"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
person = Column(String, index=True) # Sarah or Stuart
|
|
date = Column(Date, index=True) # Store actual calendar dates
|
|
meal_id = Column(Integer, ForeignKey("meals.id"))
|
|
meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
|
|
|
|
meal = relationship("Meal")
|
|
|
|
class Template(Base):
|
|
__tablename__ = "templates"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, unique=True, index=True)
|
|
|
|
# Relationship to template meals
|
|
template_meals = relationship("TemplateMeal", back_populates="template")
|
|
|
|
class TemplateMeal(Base):
|
|
__tablename__ = "template_meals"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
template_id = Column(Integer, ForeignKey("templates.id"))
|
|
meal_id = Column(Integer, ForeignKey("meals.id"))
|
|
meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
|
|
|
|
template = relationship("Template", back_populates="template_meals")
|
|
meal = relationship("Meal")
|
|
|
|
class WeeklyMenu(Base):
|
|
__tablename__ = "weekly_menus"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, unique=True, index=True)
|
|
|
|
# Relationship to weekly menu days
|
|
weekly_menu_days = relationship("WeeklyMenuDay", back_populates="weekly_menu")
|
|
|
|
class WeeklyMenuDay(Base):
|
|
__tablename__ = "weekly_menu_days"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
weekly_menu_id = Column(Integer, ForeignKey("weekly_menus.id"))
|
|
day_of_week = Column(Integer) # 0=Monday, 1=Tuesday, ..., 6=Sunday
|
|
template_id = Column(Integer, ForeignKey("templates.id"))
|
|
|
|
weekly_menu = relationship("WeeklyMenu", back_populates="weekly_menu_days")
|
|
template = relationship("Template")
|
|
|
|
class TrackedDay(Base):
|
|
"""Represents a day being tracked (separate from planned days)"""
|
|
__tablename__ = "tracked_days"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
person = Column(String, index=True) # Sarah or Stuart
|
|
date = Column(Date, index=True) # Date being tracked
|
|
is_modified = Column(Boolean, default=False) # Whether this day has been modified from original plan
|
|
|
|
# Relationship to tracked meals
|
|
tracked_meals = relationship("TrackedMeal", back_populates="tracked_day")
|
|
|
|
class TrackedMeal(Base):
|
|
"""Represents a meal tracked for a specific day"""
|
|
__tablename__ = "tracked_meals"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
tracked_day_id = Column(Integer, ForeignKey("tracked_days.id"))
|
|
meal_id = Column(Integer, ForeignKey("meals.id"))
|
|
meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
|
|
|
|
tracked_day = relationship("TrackedDay", back_populates="tracked_meals")
|
|
meal = relationship("Meal")
|
|
tracked_foods = relationship("TrackedMealFood", back_populates="tracked_meal", cascade="all, delete-orphan")
|
|
|
|
|
|
class TrackedMealFood(Base):
|
|
"""Custom food entries for a tracked meal (overrides or additions)"""
|
|
__tablename__ = "tracked_meal_foods"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
tracked_meal_id = Column(Integer, ForeignKey("tracked_meals.id"))
|
|
food_id = Column(Integer, ForeignKey("foods.id"))
|
|
quantity = Column(Float, default=1.0) # Custom quantity for this tracked instance
|
|
is_override = Column(Boolean, default=False) # True if overriding original meal food, False if addition
|
|
|
|
tracked_meal = relationship("TrackedMeal", back_populates="tracked_foods")
|
|
food = relationship("Food")
|
|
|
|
# Pydantic models
|
|
class FoodCreate(BaseModel):
|
|
name: str
|
|
serving_size: str
|
|
serving_unit: str
|
|
calories: float
|
|
protein: float
|
|
carbs: float
|
|
fat: float
|
|
fiber: float = 0
|
|
sugar: float = 0
|
|
sodium: float = 0
|
|
calcium: float = 0
|
|
source: str = "manual"
|
|
brand: Optional[str] = ""
|
|
|
|
class FoodResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
serving_size: str
|
|
serving_unit: str
|
|
calories: float
|
|
protein: float
|
|
carbs: float
|
|
fat: float
|
|
fiber: float
|
|
sugar: float
|
|
sodium: float
|
|
calcium: float
|
|
source: str
|
|
brand: str
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class MealCreate(BaseModel):
|
|
name: str
|
|
meal_type: str
|
|
meal_time: str
|
|
foods: List[dict] # [{"food_id": 1, "quantity": 1.5}]
|
|
|
|
class TrackedDayCreate(BaseModel):
|
|
person: str
|
|
date: str # ISO date string
|
|
|
|
class TrackedMealCreate(BaseModel):
|
|
meal_id: int
|
|
meal_time: str
|
|
|
|
class FoodExport(FoodResponse):
|
|
pass
|
|
|
|
class MealFoodExport(BaseModel):
|
|
food_id: int
|
|
quantity: float
|
|
|
|
class MealExport(BaseModel):
|
|
id: int
|
|
name: str
|
|
meal_type: str
|
|
meal_time: str
|
|
meal_foods: List[MealFoodExport]
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class PlanExport(BaseModel):
|
|
id: int
|
|
person: str
|
|
date: date
|
|
meal_id: int
|
|
meal_time: str
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class TemplateMealExport(BaseModel):
|
|
meal_id: int
|
|
meal_time: str
|
|
|
|
class TemplateExport(BaseModel):
|
|
id: int
|
|
name: str
|
|
template_meals: List[TemplateMealExport]
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class TemplateMealDetail(BaseModel):
|
|
meal_id: int
|
|
meal_time: str
|
|
meal_name: str
|
|
|
|
class TemplateDetail(BaseModel):
|
|
id: int
|
|
name: str
|
|
template_meals: List[TemplateMealDetail]
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class WeeklyMenuDayExport(BaseModel):
|
|
day_of_week: int
|
|
template_id: int
|
|
|
|
class WeeklyMenuDayDetail(BaseModel):
|
|
day_of_week: int
|
|
template_id: int
|
|
template_name: str
|
|
|
|
class WeeklyMenuExport(BaseModel):
|
|
id: int
|
|
name: str
|
|
weekly_menu_days: List[WeeklyMenuDayExport]
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class WeeklyMenuDetail(BaseModel):
|
|
id: int
|
|
name: str
|
|
weekly_menu_days: List[WeeklyMenuDayDetail]
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class TrackedMealFoodExport(BaseModel):
|
|
food_id: int
|
|
quantity: float
|
|
is_override: bool
|
|
|
|
|
|
class TrackedMealExport(BaseModel):
|
|
meal_id: int
|
|
meal_time: str
|
|
tracked_foods: List[TrackedMealFoodExport] = []
|
|
|
|
class TrackedDayExport(BaseModel):
|
|
id: int
|
|
person: str
|
|
date: date
|
|
is_modified: bool
|
|
tracked_meals: List[TrackedMealExport]
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class AllData(BaseModel):
|
|
foods: List[FoodExport]
|
|
meals: List[MealExport]
|
|
plans: List[PlanExport]
|
|
templates: List[TemplateExport]
|
|
weekly_menus: List[WeeklyMenuExport]
|
|
tracked_days: List[TrackedDayExport]
|
|
|
|
# Database dependency
|
|
def get_db():
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
# Utility functions
|
|
def calculate_meal_nutrition(meal, db: Session):
|
|
"""
|
|
Calculate total nutrition for a meal.
|
|
Quantities in MealFood are now directly in grams.
|
|
"""
|
|
totals = {
|
|
'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0,
|
|
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
|
}
|
|
|
|
for meal_food in meal.meal_foods:
|
|
food = meal_food.food
|
|
grams = meal_food.quantity # quantity is now grams
|
|
|
|
# Convert grams to a multiplier of serving size for nutrition calculation
|
|
try:
|
|
serving_size_value = float(food.serving_size)
|
|
except ValueError:
|
|
serving_size_value = 1 # Fallback if serving_size is not a number
|
|
|
|
if serving_size_value == 0:
|
|
multiplier = 0 # Avoid division by zero
|
|
else:
|
|
multiplier = grams / serving_size_value
|
|
|
|
totals['calories'] += food.calories * multiplier
|
|
totals['protein'] += food.protein * multiplier
|
|
totals['carbs'] += food.carbs * multiplier
|
|
totals['fat'] += food.fat * multiplier
|
|
totals['fiber'] += (food.fiber or 0) * multiplier
|
|
totals['sugar'] += (food.sugar or 0) * multiplier
|
|
totals['sodium'] += (food.sodium or 0) * multiplier
|
|
totals['calcium'] += (food.calcium or 0) * multiplier
|
|
|
|
# Calculate percentages
|
|
total_cals = totals['calories']
|
|
if total_cals > 0:
|
|
totals['protein_pct'] = round((totals['protein'] * 4 / total_cals) * 100, 1)
|
|
totals['carbs_pct'] = round((totals['carbs'] * 4 / total_cals) * 100, 1)
|
|
totals['fat_pct'] = round((totals['fat'] * 9 / total_cals) * 100, 1)
|
|
totals['net_carbs'] = totals['carbs'] - totals['fiber']
|
|
else:
|
|
totals['protein_pct'] = 0
|
|
totals['carbs_pct'] = 0
|
|
totals['fat_pct'] = 0
|
|
totals['net_carbs'] = 0
|
|
|
|
return totals
|
|
|
|
def calculate_day_nutrition(plans, db: Session):
|
|
"""Calculate total nutrition for a day's worth of meals"""
|
|
day_totals = {
|
|
'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0,
|
|
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
|
}
|
|
|
|
for plan in plans:
|
|
meal_nutrition = calculate_meal_nutrition(plan.meal, db)
|
|
for key in day_totals:
|
|
if key in meal_nutrition:
|
|
day_totals[key] += meal_nutrition[key]
|
|
|
|
# Calculate percentages
|
|
total_cals = day_totals['calories']
|
|
if total_cals > 0:
|
|
day_totals['protein_pct'] = round((day_totals['protein'] * 4 / total_cals) * 100, 1)
|
|
day_totals['carbs_pct'] = round((day_totals['carbs'] * 4 / total_cals) * 100, 1)
|
|
day_totals['fat_pct'] = round((day_totals['fat'] * 9 / total_cals) * 100, 1)
|
|
day_totals['net_carbs'] = day_totals['carbs'] - day_totals['fiber']
|
|
else:
|
|
day_totals['protein_pct'] = 0
|
|
day_totals['carbs_pct'] = 0
|
|
day_totals['fat_pct'] = 0
|
|
day_totals['net_carbs'] = 0
|
|
|
|
return day_totals
|
|
|
|
def calculate_tracked_meal_nutrition(tracked_meal, db: Session):
|
|
"""Calculate nutrition for a tracked meal, including custom foods"""
|
|
totals = {
|
|
'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0,
|
|
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
|
}
|
|
|
|
# Base meal nutrition
|
|
base_nutrition = calculate_meal_nutrition(tracked_meal.meal, db)
|
|
for key in totals:
|
|
if key in base_nutrition:
|
|
totals[key] += base_nutrition[key]
|
|
|
|
# Add custom tracked foods
|
|
for tracked_food in tracked_meal.tracked_foods:
|
|
food = tracked_food.food
|
|
food_quantity = tracked_food.quantity
|
|
totals['calories'] += food.calories * food_quantity
|
|
totals['protein'] += food.protein * food_quantity
|
|
totals['carbs'] += food.carbs * food_quantity
|
|
totals['fat'] += food.fat * food_quantity
|
|
totals['fiber'] += (food.fiber or 0) * food_quantity
|
|
totals['sugar'] += (food.sugar or 0) * food_quantity
|
|
totals['sodium'] += (food.sodium or 0) * food_quantity
|
|
totals['calcium'] += (food.calcium or 0) * food_quantity
|
|
|
|
# Calculate percentages
|
|
total_cals = totals['calories']
|
|
if total_cals > 0:
|
|
totals['protein_pct'] = round((totals['protein'] * 4 / total_cals) * 100, 1)
|
|
totals['carbs_pct'] = round((totals['carbs'] * 4 / total_cals) * 100, 1)
|
|
totals['fat_pct'] = round((totals['fat'] * 9 / total_cals) * 100, 1)
|
|
totals['net_carbs'] = totals['carbs'] - totals['fiber']
|
|
else:
|
|
totals['protein_pct'] = 0
|
|
totals['carbs_pct'] = 0
|
|
totals['fat_pct'] = 0
|
|
totals['net_carbs'] = 0
|
|
|
|
return totals
|
|
|
|
|
|
def calculate_day_nutrition_tracked(tracked_meals, db: Session):
|
|
"""Calculate total nutrition for tracked meals"""
|
|
day_totals = {
|
|
'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0,
|
|
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
|
}
|
|
|
|
for tracked_meal in tracked_meals:
|
|
meal_nutrition = calculate_tracked_meal_nutrition(tracked_meal, db)
|
|
for key in day_totals:
|
|
if key in meal_nutrition:
|
|
day_totals[key] += meal_nutrition[key]
|
|
|
|
# Calculate percentages
|
|
total_cals = day_totals['calories']
|
|
if total_cals > 0:
|
|
day_totals['protein_pct'] = round((day_totals['protein'] * 4 / total_cals) * 100, 1)
|
|
day_totals['carbs_pct'] = round((day_totals['carbs'] * 4 / total_cals) * 100, 1)
|
|
day_totals['fat_pct'] = round((day_totals['fat'] * 9 / total_cals) * 100, 1)
|
|
day_totals['net_carbs'] = day_totals['carbs'] - day_totals['fiber']
|
|
else:
|
|
day_totals['protein_pct'] = 0
|
|
day_totals['carbs_pct'] = 0
|
|
day_totals['fat_pct'] = 0
|
|
day_totals['net_carbs'] = 0
|
|
|
|
return day_totals |