mirror of
https://github.com/sstent/foodplanner.git
synced 2025-12-05 23:51:46 +00:00
unit consistency changes
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""Document quantity standardization: all quantity fields now represent grams
|
||||
|
||||
Revision ID: a11a3921f528
|
||||
Revises: 2295851db11e
|
||||
Create Date: 2025-10-01 20:25:50.531913
|
||||
|
||||
This migration documents the standardization of quantity handling across the application.
|
||||
No schema changes are required, as the database schema already supports Float for quantities
|
||||
and serving sizes (from previous migration 2295851db11e).
|
||||
|
||||
Key Changes Documented:
|
||||
- All quantity fields (MealFood.quantity, TrackedMealFood.quantity) now explicitly represent
|
||||
grams of the food item.
|
||||
- Food.serving_size represents the base serving size in grams.
|
||||
- Nutritional values for Food are per serving_size grams.
|
||||
- Nutrition calculations use: multiplier = quantity (grams) / serving_size (grams)
|
||||
|
||||
Data Migration Assessment:
|
||||
- Queried existing MealFood entries: 154 total.
|
||||
- Quantities appear to be stored as grams (e.g., 120.0g for Egg with 50g serving, 350.0g for Black Tea).
|
||||
- No evidence of multipliers (quantities are reasonable gram values, not typically 1-5).
|
||||
- All Food.serving_size values are numeric (Floats), no strings detected.
|
||||
- No None values in core nutrients (calories, protein, carbs, fat).
|
||||
- Conclusion: No data conversion needed. Existing data aligns with the grams convention.
|
||||
- If future audits reveal multiplier-based data, add conversion logic here:
|
||||
# Example (not applied):
|
||||
# from app.database import Food, MealFood
|
||||
# conn = op.get_bind()
|
||||
# meal_foods = conn.execute(sa.text("SELECT mf.id, mf.quantity, mf.food_id FROM meal_foods mf")).fetchall()
|
||||
# for mf_id, qty, food_id in meal_foods:
|
||||
# if isinstance(qty, (int, float)) and qty <= 5.0: # Heuristic for potential multipliers
|
||||
# serving = conn.execute(sa.text("SELECT serving_size FROM foods WHERE id = :fid"), {"fid": food_id}).scalar()
|
||||
# if serving and serving > 0:
|
||||
# new_qty = qty * serving
|
||||
# conn.execute(sa.text("UPDATE meal_foods SET quantity = :nq WHERE id = :mid"), {"nq": new_qty, "mid": mf_id})
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a11a3921f528'
|
||||
down_revision: Union[str, None] = '2295851db11e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite does not support comments, so we check the dialect
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == 'postgresql':
|
||||
op.execute("COMMENT ON COLUMN meal_foods.quantity IS 'Quantity in grams of this food in the meal'")
|
||||
op.execute("COMMENT ON COLUMN tracked_meal_foods.quantity IS 'Quantity in grams of this food as tracked'")
|
||||
op.execute("COMMENT ON COLUMN foods.serving_size IS 'Base serving size in grams'")
|
||||
# For other dialects like SQLite, this migration does nothing.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# SQLite does not support comments, so we check the dialect
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == 'postgresql':
|
||||
op.execute("COMMENT ON COLUMN meal_foods.quantity IS NULL")
|
||||
op.execute("COMMENT ON COLUMN tracked_meal_foods.quantity IS NULL")
|
||||
op.execute("COMMENT ON COLUMN foods.serving_size IS NULL")
|
||||
# For other dialects like SQLite, this migration does nothing.
|
||||
pass
|
||||
@@ -47,7 +47,6 @@ async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends(
|
||||
|
||||
food_name = row[i].strip()
|
||||
grams = float(row[i+1].strip())
|
||||
quantity = grams
|
||||
|
||||
# Try multiple matching strategies for food names
|
||||
food = None
|
||||
@@ -72,7 +71,7 @@ async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends(
|
||||
food_names = [f[0] for f in all_foods]
|
||||
raise ValueError(f"Food '{food_name}' not found. Available foods include: {', '.join(food_names[:5])}...")
|
||||
logging.info(f"Found food '{food_name}' with id {food.id}")
|
||||
ingredients.append((food.id, quantity))
|
||||
ingredients.append((food.id, grams))
|
||||
|
||||
# Create/update meal
|
||||
existing = db.query(Meal).filter(Meal.name == meal_name).first()
|
||||
@@ -89,11 +88,11 @@ async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends(
|
||||
db.flush() # Get meal ID
|
||||
|
||||
# Add new ingredients
|
||||
for food_id, quantity in ingredients:
|
||||
for food_id, grams in ingredients:
|
||||
meal_food = MealFood(
|
||||
meal_id=existing.id,
|
||||
food_id=food_id,
|
||||
quantity=quantity
|
||||
quantity=grams
|
||||
)
|
||||
db.add(meal_food)
|
||||
|
||||
@@ -180,7 +179,7 @@ async def get_meal_foods(meal_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/meals/{meal_id}/add_food")
|
||||
async def add_food_to_meal(meal_id: int, food_id: int = Form(...),
|
||||
grams: float = Form(..., alias="quantity"), db: Session = Depends(get_db)):
|
||||
grams: float = Form(...), db: Session = Depends(get_db)):
|
||||
|
||||
try:
|
||||
meal_food = MealFood(meal_id=meal_id, food_id=food_id, quantity=grams)
|
||||
@@ -210,7 +209,7 @@ async def remove_food_from_meal(meal_food_id: int, db: Session = Depends(get_db)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@router.post("/meals/update_food_quantity")
|
||||
async def update_meal_food_quantity(meal_food_id: int = Form(...), grams: float = Form(..., alias="quantity"), db: Session = Depends(get_db)):
|
||||
async def update_meal_food_quantity(meal_food_id: int = Form(...), grams: float = Form(...), db: Session = Depends(get_db)):
|
||||
"""Update the quantity of a food in a meal"""
|
||||
try:
|
||||
meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first()
|
||||
|
||||
@@ -280,10 +280,10 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess
|
||||
"""Update quantity of a custom food in a tracked meal"""
|
||||
try:
|
||||
tracked_food_id = data.get("tracked_food_id")
|
||||
quantity = float(data.get("quantity", 1.0))
|
||||
grams = float(data.get("grams", 1.0))
|
||||
is_custom = data.get("is_custom", False)
|
||||
|
||||
logging.info(f"DEBUG: Updating tracked food {tracked_food_id} quantity to {quantity}")
|
||||
logging.info(f"DEBUG: Updating tracked food {tracked_food_id} grams to {grams}")
|
||||
|
||||
if is_custom:
|
||||
tracked_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == tracked_food_id).first()
|
||||
@@ -300,7 +300,7 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess
|
||||
tracked_food = TrackedMealFood(
|
||||
tracked_meal_id=tracked_meal.id,
|
||||
food_id=meal_food.food_id,
|
||||
quantity=quantity
|
||||
quantity=grams
|
||||
)
|
||||
db.add(tracked_food)
|
||||
|
||||
@@ -311,7 +311,7 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess
|
||||
return {"status": "error", "message": "Tracked food not found"}
|
||||
|
||||
# Update quantity
|
||||
tracked_food.quantity = quantity
|
||||
tracked_food.quantity = grams
|
||||
|
||||
# Mark the tracked day as modified
|
||||
tracked_day = tracked_food.tracked_meal.tracked_day
|
||||
@@ -319,7 +319,7 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess
|
||||
|
||||
db.commit()
|
||||
|
||||
logging.info(f"DEBUG: Successfully updated tracked food quantity")
|
||||
logging.info(f"DEBUG: Successfully updated tracked food grams")
|
||||
return {"status": "success"}
|
||||
|
||||
except Exception as e:
|
||||
@@ -440,7 +440,7 @@ async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends
|
||||
try:
|
||||
tracked_meal_id = data.get("tracked_meal_id")
|
||||
food_id = data.get("food_id")
|
||||
quantity = float(data.get("quantity", 1.0))
|
||||
grams = float(data.get("grams", 1.0))
|
||||
|
||||
tracked_meal = db.query(TrackedMeal).filter(TrackedMeal.id == tracked_meal_id).first()
|
||||
if not tracked_meal:
|
||||
@@ -454,7 +454,7 @@ async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends
|
||||
meal_food = MealFood(
|
||||
meal_id=tracked_meal.meal_id,
|
||||
food_id=food_id,
|
||||
quantity=quantity
|
||||
quantity=grams
|
||||
)
|
||||
db.add(meal_food)
|
||||
|
||||
@@ -489,27 +489,25 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend
|
||||
|
||||
for food_data in foods_data:
|
||||
food_id = food_data.get("food_id")
|
||||
grams = float(food_data.get("quantity", 1.0)) # Assuming quantity is now grams
|
||||
grams = float(food_data.get("grams", 1.0))
|
||||
is_custom = food_data.get("is_custom", False)
|
||||
item_id = food_data.get("id") # This could be MealFood.id or TrackedMealFood.id
|
||||
logging.info(f"DEBUG: Processing food_id: {food_id}, quantity: {grams}, is_custom: {is_custom}, item_id: {item_id}")
|
||||
|
||||
quantity = grams
|
||||
logging.info(f"DEBUG: Processing food_id: {food_id}, grams: {grams}, is_custom: {is_custom}, item_id: {item_id}")
|
||||
|
||||
if is_custom:
|
||||
tracked_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == item_id).first()
|
||||
if tracked_food:
|
||||
tracked_food.quantity = quantity
|
||||
logging.info(f"DEBUG: Updated existing custom tracked food {item_id} to quantity {quantity}")
|
||||
tracked_food.quantity = grams
|
||||
logging.info(f"DEBUG: Updated existing custom tracked food {item_id} to grams {grams}")
|
||||
else:
|
||||
# If it's a new custom food being added
|
||||
new_tracked_food = TrackedMealFood(
|
||||
tracked_meal_id=tracked_meal.id,
|
||||
food_id=food_id,
|
||||
quantity=quantity
|
||||
quantity=grams
|
||||
)
|
||||
db.add(new_tracked_food)
|
||||
logging.info(f"DEBUG: Added new custom tracked food for food_id {food_id} with quantity {quantity}")
|
||||
logging.info(f"DEBUG: Added new custom tracked food for food_id {food_id} with grams {grams}")
|
||||
else:
|
||||
# This is a food from the original meal definition
|
||||
# We need to check if it's already a TrackedMealFood (meaning it was overridden)
|
||||
@@ -521,8 +519,8 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend
|
||||
logging.info(f"DEBUG: Checking for existing TrackedMealFood for food_id {food_id}: {existing_tracked_food.id if existing_tracked_food else 'None'}")
|
||||
|
||||
if existing_tracked_food:
|
||||
existing_tracked_food.quantity = quantity
|
||||
logging.info(f"DEBUG: Updated existing TrackedMealFood {existing_tracked_food.id} (override) to quantity {quantity}")
|
||||
existing_tracked_food.quantity = float(grams)
|
||||
logging.info(f"DEBUG: Updated existing TrackedMealFood {existing_tracked_food.id} (override) to grams {grams}")
|
||||
else:
|
||||
# If it's not a TrackedMealFood, it must be a MealFood
|
||||
meal_food = db.query(MealFood).filter(
|
||||
@@ -531,32 +529,29 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend
|
||||
).first()
|
||||
logging.info(f"DEBUG: Checking for existing MealFood for food_id {food_id}: {meal_food.id if meal_food else 'None'}")
|
||||
if meal_food:
|
||||
# If quantity changed, convert to TrackedMealFood
|
||||
# NOTE: meal_food.quantity is already a multiplier,
|
||||
# but the incoming 'quantity' is a multiplier derived from grams.
|
||||
# So, we compare the incoming multiplier with the existing multiplier.
|
||||
if meal_food.quantity != quantity:
|
||||
# If grams changed, convert to TrackedMealFood
|
||||
if meal_food.quantity != grams:
|
||||
new_tracked_food = TrackedMealFood(
|
||||
tracked_meal_id=tracked_meal.id,
|
||||
food_id=food_id,
|
||||
quantity=quantity,
|
||||
quantity=float(grams),
|
||||
is_override=True
|
||||
)
|
||||
db.add(new_tracked_food)
|
||||
db.delete(meal_food) # Remove original MealFood
|
||||
logging.info(f"DEBUG: Converted MealFood {meal_food.id} to new TrackedMealFood for food_id {food_id} with quantity {quantity} and deleted original MealFood.")
|
||||
logging.info(f"DEBUG: Converted MealFood {meal_food.id} to new TrackedMealFood for food_id {food_id} with grams {grams} and deleted original MealFood.")
|
||||
else:
|
||||
logging.info(f"DEBUG: MealFood {meal_food.id} quantity unchanged, no override needed.")
|
||||
logging.info(f"DEBUG: MealFood {meal_food.id} grams unchanged, no override needed.")
|
||||
else:
|
||||
# This case should ideally not happen if data is consistent,
|
||||
# but as a fallback, add as a new TrackedMealFood
|
||||
new_tracked_food = TrackedMealFood(
|
||||
tracked_meal_id=tracked_meal.id,
|
||||
food_id=food_id,
|
||||
quantity=quantity
|
||||
quantity=float(grams)
|
||||
)
|
||||
db.add(new_tracked_food)
|
||||
logging.warning(f"DEBUG: Fallback: Added new TrackedMealFood for food_id {food_id} with quantity {quantity}. Original MealFood not found.")
|
||||
logging.warning(f"DEBUG: Fallback: Added new TrackedMealFood for food_id {food_id} with grams {grams}. Original MealFood not found.")
|
||||
|
||||
# Mark the tracked day as modified
|
||||
tracked_meal.tracked_day.is_modified = True
|
||||
@@ -656,7 +651,7 @@ async def save_as_new_meal(data: dict = Body(...), db: Session = Depends(get_db)
|
||||
meal_food = MealFood(
|
||||
meal_id=new_meal.id,
|
||||
food_id=food_data["food_id"],
|
||||
quantity=food_data["quantity"]
|
||||
quantity=food_data["grams"]
|
||||
)
|
||||
db.add(meal_food)
|
||||
|
||||
@@ -692,7 +687,7 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db)
|
||||
person = data.get("person")
|
||||
date_str = data.get("date")
|
||||
food_id = data.get("food_id")
|
||||
grams = float(data.get("quantity", 1.0)) # Assuming quantity is now grams
|
||||
grams = float(data.get("grams", 1.0))
|
||||
meal_time = data.get("meal_time")
|
||||
|
||||
logging.info(f"DEBUG: Adding single food to tracker - person={person}, date={date_str}, food_id={food_id}, grams={grams}, meal_time={meal_time}")
|
||||
@@ -713,15 +708,12 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db)
|
||||
db.commit()
|
||||
db.refresh(tracked_day)
|
||||
|
||||
# Convert grams to a quantity multiplier based on serving size
|
||||
food_item = db.query(Food).filter(Food.id == food_id).first()
|
||||
if not food_item:
|
||||
return {"status": "error", "message": "Food not found"}
|
||||
|
||||
if food_item.serving_size > 0:
|
||||
quantity = grams / food_item.serving_size
|
||||
else:
|
||||
quantity = 1.0 # Default to 1 serving if serving size is not set
|
||||
# Store grams directly
|
||||
quantity = grams
|
||||
|
||||
# Create a new Meal for this single food entry
|
||||
# This allows it to be treated like any other meal in the tracker view
|
||||
@@ -730,7 +722,7 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db)
|
||||
db.flush() # Flush to get the new meal ID
|
||||
|
||||
# Link the food to the new meal
|
||||
meal_food = MealFood(meal_id=new_meal.id, food_id=food_id, quantity=quantity)
|
||||
meal_food = MealFood(meal_id=new_meal.id, food_id=food_id, quantity=grams)
|
||||
db.add(meal_food)
|
||||
|
||||
# Create tracked meal entry
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
"""
|
||||
Database models and session management for the meal planner app
|
||||
"""
|
||||
"""
|
||||
QUANTITY CONVENTION:
|
||||
All quantity fields in this application represent GRAMS.
|
||||
|
||||
- Food.serving_size: base serving size in grams (e.g., 100.0)
|
||||
- Food nutrition values: per serving_size grams
|
||||
- MealFood.quantity: grams of this food in the meal (e.g., 150.0)
|
||||
- TrackedMealFood.quantity: grams of this food as tracked (e.g., 200.0)
|
||||
|
||||
To calculate nutrition: multiplier = quantity / serving_size
|
||||
"""
|
||||
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
|
||||
@@ -313,7 +324,7 @@ def get_db():
|
||||
def calculate_meal_nutrition(meal, db: Session):
|
||||
"""
|
||||
Calculate total nutrition for a meal.
|
||||
Quantities in MealFood are now directly in grams.
|
||||
MealFood.quantity is in GRAMS. Multiplier = quantity / food.serving_size (serving_size in grams).
|
||||
"""
|
||||
totals = {
|
||||
'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0,
|
||||
@@ -322,12 +333,16 @@ def calculate_meal_nutrition(meal, db: Session):
|
||||
|
||||
for meal_food in meal.meal_foods:
|
||||
food = meal_food.food
|
||||
multiplier = meal_food.quantity
|
||||
try:
|
||||
serving_size = float(food.serving_size)
|
||||
multiplier = meal_food.quantity / serving_size if serving_size > 0 else 0
|
||||
except (ValueError, TypeError):
|
||||
multiplier = 0
|
||||
|
||||
totals['calories'] += food.calories * multiplier
|
||||
totals['protein'] += food.protein * multiplier
|
||||
totals['carbs'] += food.carbs * multiplier
|
||||
totals['fat'] += food.fat * multiplier
|
||||
totals['calories'] += (food.calories or 0) * multiplier
|
||||
totals['protein'] += (food.protein or 0) * multiplier
|
||||
totals['carbs'] += (food.carbs or 0) * multiplier
|
||||
totals['fat'] += (food.fat or 0) * multiplier
|
||||
totals['fiber'] += (food.fiber or 0) * multiplier
|
||||
totals['sugar'] += (food.sugar or 0) * multiplier
|
||||
totals['sodium'] += (food.sodium or 0) * multiplier
|
||||
@@ -377,7 +392,11 @@ def calculate_day_nutrition(plans, db: Session):
|
||||
return day_totals
|
||||
|
||||
def calculate_tracked_meal_nutrition(tracked_meal, db: Session):
|
||||
"""Calculate nutrition for a tracked meal, including custom foods"""
|
||||
"""
|
||||
Calculate nutrition for a tracked meal, including custom foods.
|
||||
TrackedMealFood.quantity is in GRAMS. Multiplier = quantity / food.serving_size (serving_size in grams).
|
||||
Base meal uses calculate_meal_nutrition which handles grams correctly.
|
||||
"""
|
||||
totals = {
|
||||
'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0,
|
||||
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
||||
@@ -392,15 +411,15 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session):
|
||||
# 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
|
||||
multiplier = tracked_food.quantity / food.serving_size if food.serving_size and food.serving_size != 0 else 0
|
||||
totals['calories'] += (food.calories or 0) * multiplier
|
||||
totals['protein'] += (food.protein or 0) * multiplier
|
||||
totals['carbs'] += (food.carbs or 0) * multiplier
|
||||
totals['fat'] += (food.fat or 0) * 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']
|
||||
@@ -447,10 +466,11 @@ def calculate_day_nutrition_tracked(tracked_meals, db: Session):
|
||||
return day_totals
|
||||
|
||||
|
||||
def convert_grams_to_quantity(food_id: int, grams: float, db: Session) -> float:
|
||||
def calculate_multiplier_from_grams(food_id: int, grams: float, db: Session) -> float:
|
||||
"""
|
||||
Converts a given amount in grams to the corresponding quantity multiplier
|
||||
based on the food's serving size.
|
||||
Calculate the multiplier from grams based on the food's serving size.
|
||||
Multiplier = grams / serving_size (both in grams).
|
||||
Used for nutrition calculations when quantity is provided in grams.
|
||||
"""
|
||||
food = db.query(Food).filter(Food.id == food_id).first()
|
||||
if not food:
|
||||
|
||||
4
main.py
4
main.py
@@ -276,4 +276,8 @@ async def root(request: Request):
|
||||
@app.get("/test")
|
||||
async def test_route():
|
||||
logging.info("DEBUG: Test route called")
|
||||
# Add a test route to check template inheritance
|
||||
@app.get("/test_template", response_class=HTMLResponse)
|
||||
async def test_template(request: Request):
|
||||
return templates.TemplateResponse("test_template.html", {"request": request, "person": "Sarah"})
|
||||
return {"status": "success", "message": "Test route is working"}
|
||||
320
standardization.md
Normal file
320
standardization.md
Normal file
@@ -0,0 +1,320 @@
|
||||
Food Planner Quantity Standardization Plan
|
||||
Problem Statement
|
||||
The application has inconsistent handling of food quantities throughout the codebase:
|
||||
|
||||
Current Issue: MealFood.quantity is being used sometimes as a multiplier of serving_size and sometimes as grams directly
|
||||
Impact: Confusing calculations in nutrition functions and unclear user interface expectations
|
||||
Goal: Standardize so MealFood.quantity always represents grams of the food item
|
||||
|
||||
|
||||
Core Data Model Definition
|
||||
Standard to Adopt
|
||||
Food.serving_size = base serving size in grams (e.g., 100)
|
||||
Food.[nutrients] = nutritional values per serving_size grams
|
||||
MealFood.quantity = actual grams to use (e.g., 150g)
|
||||
TrackedMealFood.quantity = actual grams to use (e.g., 200g)
|
||||
|
||||
Calculation: multiplier = quantity / serving_size
|
||||
|
||||
Implementation Plan
|
||||
Phase 1: Audit & Document (Non-Breaking)
|
||||
Task 1.1: Add documentation header to app/database.py
|
||||
python"""
|
||||
QUANTITY CONVENTION:
|
||||
All quantity fields in this application represent GRAMS.
|
||||
|
||||
- Food.serving_size: base serving size in grams (e.g., 100.0)
|
||||
- Food nutrition values: per serving_size grams
|
||||
- MealFood.quantity: grams of this food in the meal (e.g., 150.0)
|
||||
- TrackedMealFood.quantity: grams of this food as tracked (e.g., 200.0)
|
||||
|
||||
To calculate nutrition: multiplier = quantity / serving_size
|
||||
"""
|
||||
Task 1.2: Audit all locations where quantity is read/written
|
||||
|
||||
app/database.py - calculation functions
|
||||
app/api/routes/meals.py - meal food operations
|
||||
app/api/routes/tracker.py - tracked meal operations
|
||||
app/api/routes/plans.py - detailed view
|
||||
Templates using quantity values
|
||||
|
||||
|
||||
Phase 2: Fix Core Calculation Functions
|
||||
Task 2.1: Fix calculate_meal_nutrition() in app/database.py
|
||||
Current behavior: Assumes quantity is already a multiplier
|
||||
New behavior: Calculate multiplier from grams
|
||||
pythondef calculate_meal_nutrition(meal, db: Session):
|
||||
"""
|
||||
Calculate total nutrition for a meal.
|
||||
MealFood.quantity is 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
|
||||
|
||||
# Convert grams to multiplier based on serving size
|
||||
try:
|
||||
serving_size = float(food.serving_size)
|
||||
multiplier = grams / serving_size if serving_size > 0 else 0
|
||||
except (ValueError, TypeError):
|
||||
multiplier = 0
|
||||
|
||||
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 (unchanged)
|
||||
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
|
||||
Task 2.2: Fix calculate_tracked_meal_nutrition() in app/database.py
|
||||
Apply the same pattern to handle TrackedMealFood.quantity as grams.
|
||||
Task 2.3: Remove or fix convert_grams_to_quantity() function
|
||||
This function appears to be unused but creates confusion. Either:
|
||||
|
||||
Remove it entirely, OR
|
||||
Rename to calculate_multiplier_from_grams() and update documentation
|
||||
|
||||
|
||||
Phase 3: Fix API Routes
|
||||
Task 3.1: Fix app/api/routes/meals.py
|
||||
Location: POST /meals/{meal_id}/add_food
|
||||
python@router.post("/meals/{meal_id}/add_food")
|
||||
async def add_food_to_meal(
|
||||
meal_id: int,
|
||||
food_id: int = Form(...),
|
||||
grams: float = Form(...), # Changed from 'quantity' to be explicit
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
# Store grams directly - no conversion needed
|
||||
meal_food = MealFood(
|
||||
meal_id=meal_id,
|
||||
food_id=food_id,
|
||||
quantity=grams # This is grams
|
||||
)
|
||||
db.add(meal_food)
|
||||
db.commit()
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"status": "error", "message": str(e)}
|
||||
Location: POST /meals/update_food_quantity
|
||||
python@router.post("/meals/update_food_quantity")
|
||||
async def update_meal_food_quantity(
|
||||
meal_food_id: int = Form(...),
|
||||
grams: float = Form(...), # Changed from 'quantity'
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first()
|
||||
if not meal_food:
|
||||
return {"status": "error", "message": "meal food not found"}
|
||||
|
||||
meal_food.quantity = grams # Store grams directly
|
||||
db.commit()
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"status": "error", "message": str(e)}
|
||||
Task 3.2: Fix app/api/routes/tracker.py
|
||||
Review all tracked meal operations to ensure they handle grams correctly.
|
||||
Task 3.3: Fix app/api/routes/plans.py detailed view
|
||||
The detailed view calculates nutrition per food item. Update to show grams clearly:
|
||||
pythonfor mf in tm.meal.meal_foods:
|
||||
try:
|
||||
serving_size_value = float(mf.food.serving_size)
|
||||
num_servings = mf.quantity / serving_size_value if serving_size_value != 0 else 0
|
||||
except (ValueError, TypeError):
|
||||
num_servings = 0
|
||||
|
||||
foods.append({
|
||||
'name': mf.food.name,
|
||||
'total_grams': mf.quantity, # Explicitly show it's grams
|
||||
'num_servings': round(num_servings, 2),
|
||||
'serving_size': mf.food.serving_size,
|
||||
'serving_unit': mf.food.serving_unit,
|
||||
# Don't recalculate nutrition here - it's done in calculate_meal_nutrition
|
||||
})
|
||||
|
||||
Phase 4: Fix CSV Import Functions
|
||||
Task 4.1: Fix app/api/routes/meals.py - POST /meals/upload
|
||||
Currently processes ingredient pairs as (food_name, grams). Ensure it stores grams directly:
|
||||
pythonfor i in range(1, len(row), 2):
|
||||
if i+1 >= len(row) or not row[i].strip():
|
||||
continue
|
||||
|
||||
food_name = row[i].strip()
|
||||
grams = float(row[i+1].strip()) # This is grams
|
||||
|
||||
# ... find food ...
|
||||
|
||||
ingredients.append((food.id, grams)) # Store grams directly
|
||||
|
||||
# Later when creating MealFood:
|
||||
for food_id, grams in ingredients:
|
||||
meal_food = MealFood(
|
||||
meal_id=existing.id,
|
||||
food_id=food_id,
|
||||
quantity=grams # Store grams directly
|
||||
)
|
||||
|
||||
Phase 5: Update Templates & UI
|
||||
Task 5.1: Update templates/detailed.html
|
||||
Ensure the food breakdown clearly shows grams:
|
||||
html<li>
|
||||
{{ food.total_grams }}g of {{ food.name }}
|
||||
({{ food.num_servings|round(2) }} servings of {{ food.serving_size }}{{ food.serving_unit }})
|
||||
</li>
|
||||
Task 5.2: Update meal editing forms
|
||||
Ensure all forms ask for "grams" not "quantity" to avoid confusion.
|
||||
|
||||
Phase 6: Add Tests
|
||||
Task 6.1: Create test file tests/test_quantity_consistency.py
|
||||
pythondef test_meal_nutrition_uses_grams_correctly(db_session):
|
||||
"""Verify that MealFood.quantity as grams calculates nutrition correctly"""
|
||||
# Create a food: 100 cal per 100g
|
||||
food = Food(
|
||||
name="Test Food",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0,
|
||||
protein=10.0,
|
||||
carbs=20.0,
|
||||
fat=5.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
# Create a meal with 200g of this food
|
||||
meal = Meal(name="Test Meal", meal_type="breakfast")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=200.0 # 200 grams
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
# Calculate nutrition
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
|
||||
# Should be 2x the base values (200g / 100g = 2x multiplier)
|
||||
assert nutrition['calories'] == 200.0
|
||||
assert nutrition['protein'] == 20.0
|
||||
assert nutrition['carbs'] == 40.0
|
||||
assert nutrition['fat'] == 10.0
|
||||
|
||||
def test_fractional_servings(db_session):
|
||||
"""Test that fractional grams work correctly"""
|
||||
food = Food(
|
||||
name="Test Food",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
meal = Meal(name="Test Meal")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
# Add 50g (half serving)
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=50.0
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
assert nutrition['calories'] == 50.0
|
||||
Task 6.2: Run existing tests to verify no regressions
|
||||
|
||||
Phase 7: Data Migration (if needed)
|
||||
Task 7.1: Determine if existing data needs migration
|
||||
Check if current database has MealFood entries where quantity is already being stored as multipliers instead of grams. If so, create a data migration script.
|
||||
Task 7.2: Create Alembic migration (documentation only)
|
||||
python"""clarify quantity fields represent grams
|
||||
|
||||
Revision ID: xxxxx
|
||||
Revises: 2295851db11e
|
||||
Create Date: 2025-10-01
|
||||
"""
|
||||
|
||||
def upgrade() -> None:
|
||||
# No schema changes needed
|
||||
# This migration documents that all quantity fields = grams
|
||||
# If data migration is needed, add conversion logic here
|
||||
pass
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
|
||||
Testing Checklist
|
||||
|
||||
Add 100g of a food with 100 cal/100g → should show 100 cal
|
||||
Add 200g of a food with 100 cal/100g → should show 200 cal
|
||||
Add 50g of a food with 100 cal/100g → should show 50 cal
|
||||
Import meals from CSV with gram values → should calculate correctly
|
||||
View detailed page for template → should show grams and correct totals
|
||||
View detailed page for tracked day → should show grams and correct totals
|
||||
Edit meal food quantity → should accept and store grams
|
||||
|
||||
|
||||
Rollout Plan
|
||||
|
||||
Deploy to staging: Test all functionality manually
|
||||
Run automated tests: Verify calculations
|
||||
Check existing data: Ensure no corruption
|
||||
Deploy to production: Monitor for errors
|
||||
Document changes: Update any user documentation
|
||||
|
||||
|
||||
Risk Assessment
|
||||
Low Risk:
|
||||
|
||||
Adding documentation
|
||||
Fixing calculation functions (if current behavior is already treating quantity as grams)
|
||||
|
||||
Medium Risk:
|
||||
|
||||
Changing API parameter names from quantity to grams
|
||||
Updating templates
|
||||
|
||||
High Risk:
|
||||
|
||||
If existing database has mixed data (some quantities are multipliers, some are grams)
|
||||
Need to audit actual database content before proceeding
|
||||
|
||||
|
||||
Notes
|
||||
|
||||
The CSV import already seems to expect grams based on the code
|
||||
The main issue appears to be in calculate_meal_nutrition() if it's not properly converting grams to multipliers
|
||||
Consider adding database constraints or validation to ensure quantity > 0 and reasonable ranges
|
||||
@@ -166,7 +166,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="serving-info">
|
||||
{{ "%.1f"|format(meal_food.num_servings) }} × {{ meal_food.serving_size }}{{ meal_food.serving_unit }} ({{ "%.0f"|format(meal_food.total_grams) }}g)
|
||||
{{ "%.0f"|format(meal_food.total_grams) }}g ({{ "%.2f"|format(meal_food.num_servings) }} servings of {{ meal_food.serving_size }}{{ meal_food.serving_unit }})
|
||||
</div>
|
||||
</td>
|
||||
<td class="nutrient-value">{{ "%.0f"|format(meal_food.calories) }}</td>
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="serving-info">
|
||||
{{ "%.1f"|format(meal_food.num_servings) }} × {{ meal_food.serving_size }}{{ meal_food.serving_unit }} ({{ "%.0f"|format(meal_food.total_grams) }}g)
|
||||
{{ "%.0f"|format(meal_food.total_grams) }}g ({{ "%.2f"|format(meal_food.num_servings) }} servings of {{ meal_food.serving_size }}{{ meal_food.serving_unit }})
|
||||
</div>
|
||||
</td>
|
||||
<td class="nutrient-value">{{ "%.0f"|format(meal_food.calories) }}</td>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{% if meal.meal_foods %}
|
||||
<ul class="list-unstyled">
|
||||
{% for meal_food in meal.meal_foods %}
|
||||
<li>{{ "%.2f" | format(meal_food.quantity) }}g × {{ meal_food.food.name }}</li>
|
||||
<li>{{ meal_food.quantity }}g of {{ meal_food.food.name }} ({{ (meal_food.quantity / meal_food.food.serving_size)|round(2) }} servings of {{ meal_food.food.serving_size }}{{ meal_food.food.serving_unit }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input type="number" step="0.01" class="form-control" name="quantity" value="1" required>
|
||||
<label class="form-label">Quantity (g)</label>
|
||||
<input type="number" step="0.01" class="form-control" name="quantity" value="100" placeholder="Enter weight in grams" required>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success" onclick="addFoodToTrackedMeal()">Add Food</button>
|
||||
</form>
|
||||
|
||||
5
templates/test_template.html
Normal file
5
templates/test_template.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Test Template</h1>
|
||||
<p>This is a test template to check if inheritance works.</p>
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@ def sample_chart_data(db_session):
|
||||
# Create sample food
|
||||
food = Food(
|
||||
name="Sample Food",
|
||||
serving_size="100g",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0,
|
||||
protein=10.0,
|
||||
@@ -35,7 +35,7 @@ def sample_chart_data(db_session):
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=1.0
|
||||
quantity=100.0
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
@@ -25,11 +25,9 @@ TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_
|
||||
def session_fixture():
|
||||
Base.metadata.create_all(bind=test_engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=test_engine)
|
||||
yield db
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=test_engine)
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture(session):
|
||||
@@ -50,7 +48,7 @@ def test_detailed_page_default_date(client, session):
|
||||
# Create mock data for today
|
||||
food = Food(
|
||||
name="Apple",
|
||||
serving_size="100",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=52,
|
||||
protein=0.3,
|
||||
@@ -71,7 +69,7 @@ def test_detailed_page_default_date(client, session):
|
||||
session.commit()
|
||||
session.refresh(meal)
|
||||
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0)
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=100.0)
|
||||
session.add(meal_food)
|
||||
session.commit()
|
||||
|
||||
@@ -84,7 +82,7 @@ def test_detailed_page_default_date(client, session):
|
||||
response = client.get("/detailed?person=Sarah")
|
||||
assert response.status_code == 200
|
||||
# Check for the unescaped version or the page title
|
||||
assert "Detailed Plan for" in response.text
|
||||
assert "Detailed Plan for Sarah" in response.text
|
||||
assert test_date.strftime('%B %d, %Y') in response.text
|
||||
assert "Fruit Snack" in response.text
|
||||
|
||||
@@ -93,7 +91,7 @@ def test_detailed_page_with_plan_date(client, session):
|
||||
# Create mock data
|
||||
food = Food(
|
||||
name="Apple",
|
||||
serving_size="100",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=52,
|
||||
protein=0.3,
|
||||
@@ -114,7 +112,7 @@ def test_detailed_page_with_plan_date(client, session):
|
||||
session.commit()
|
||||
session.refresh(meal)
|
||||
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0)
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=100.0)
|
||||
session.add(meal_food)
|
||||
session.commit()
|
||||
|
||||
@@ -126,7 +124,7 @@ def test_detailed_page_with_plan_date(client, session):
|
||||
response = client.get(f"/detailed?person=Sarah&plan_date={test_date.isoformat()}")
|
||||
assert response.status_code == 200
|
||||
# Check for the page content without assuming apostrophe encoding
|
||||
assert "Detailed Plan for" in response.text
|
||||
assert "Detailed Plan for Sarah" in response.text
|
||||
assert "Fruit Snack" in response.text
|
||||
|
||||
|
||||
@@ -134,7 +132,7 @@ def test_detailed_page_with_template_id(client, session):
|
||||
# Create mock data
|
||||
food = Food(
|
||||
name="Banana",
|
||||
serving_size="100",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=89,
|
||||
protein=1.1,
|
||||
@@ -155,7 +153,7 @@ def test_detailed_page_with_template_id(client, session):
|
||||
session.commit()
|
||||
session.refresh(meal)
|
||||
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0)
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=100.0)
|
||||
session.add(meal_food)
|
||||
session.commit()
|
||||
|
||||
@@ -177,7 +175,7 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session):
|
||||
# Create mock data for a tracked day
|
||||
food1 = Food(
|
||||
name="Chicken Breast",
|
||||
serving_size="100",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=165, protein=31, carbs=0, fat=3.6,
|
||||
fiber=0, sugar=0, sodium=74, calcium=11,
|
||||
@@ -185,7 +183,7 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session):
|
||||
)
|
||||
food2 = Food(
|
||||
name="Broccoli",
|
||||
serving_size="100",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=55, protein=3.7, carbs=11.2, fat=0.6,
|
||||
fiber=5.1, sugar=2.2, sodium=33, calcium=47,
|
||||
@@ -201,8 +199,8 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session):
|
||||
session.commit()
|
||||
session.refresh(meal)
|
||||
|
||||
meal_food1 = MealFood(meal_id=meal.id, food_id=food1.id, quantity=1.5) # 150g chicken
|
||||
meal_food2 = MealFood(meal_id=meal.id, food_id=food2.id, quantity=2.0) # 200g broccoli
|
||||
meal_food1 = MealFood(meal_id=meal.id, food_id=food1.id, quantity=150.0) # 150g chicken
|
||||
meal_food2 = MealFood(meal_id=meal.id, food_id=food2.id, quantity=200.0) # 200g broccoli
|
||||
session.add_all([meal_food1, meal_food2])
|
||||
session.commit()
|
||||
|
||||
@@ -230,8 +228,8 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session):
|
||||
assert "Chicken and Broccoli" in response.text
|
||||
assert "Chicken Breast" in response.text
|
||||
assert "Broccoli" in response.text
|
||||
assert "1.5 × 100g" in response.text # Check quantity and unit for chicken
|
||||
assert "2.0 × 100g" in response.text # Check quantity and unit for broccoli
|
||||
assert "150.0g of Chicken Breast (1.5 servings of 100.0g)" in response.text
|
||||
assert "200.0g of Broccoli (2.0 servings of 100.0g)" in response.text
|
||||
assert "248" in response.text # Check calories for chicken (1.5 * 165 = 247.5, rounded to 248)
|
||||
assert "110" in response.text # Check calories for broccoli (2.0 * 55 = 110)
|
||||
|
||||
@@ -240,7 +238,7 @@ def test_detailed_page_with_invalid_plan_date(client):
|
||||
response = client.get(f"/detailed?person=Sarah&plan_date={invalid_date.isoformat()}")
|
||||
assert response.status_code == 200
|
||||
# Check for content that indicates empty plan
|
||||
assert "Detailed Plan for" in response.text
|
||||
assert "Detailed Plan for Sarah" in response.text
|
||||
assert "No meals planned for this day." in response.text
|
||||
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ def test_edit_tracked_meal_with_override_flow(client: TestClient, session: Testi
|
||||
|
||||
# Prepare update data: update food1 quantity (should create a TrackedMealFood and delete original MealFood)
|
||||
updated_foods_data = [
|
||||
{"id": original_meal_food1.id, "food_id": food1.id, "quantity": 175.0, "is_custom": False}, # Original MealFood, but quantity changed
|
||||
{"id": None, "food_id": food2.id, "quantity": 100.0, "is_custom": False} # Unchanged original MealFood
|
||||
{"id": original_meal_food1.id, "food_id": food1.id, "grams": 175.0, "is_custom": False}, # Original MealFood, but quantity changed
|
||||
{"id": None, "food_id": food2.id, "grams": 100.0, "is_custom": False} # Unchanged original MealFood
|
||||
]
|
||||
|
||||
response_update = client.post(
|
||||
@@ -169,8 +169,8 @@ def test_update_tracked_meal_foods_endpoint(client: TestClient, session: Testing
|
||||
|
||||
# Prepare update data
|
||||
updated_foods = [
|
||||
{"id": tracked_meal_food1.id, "food_id": food1.id, "quantity": 200.0, "is_custom": True},
|
||||
{"id": None, "food_id": food2.id, "quantity": 50.0, "is_custom": False} # This represents original meal food
|
||||
{"id": tracked_meal_food1.id, "food_id": food1.id, "grams": 200.0, "is_custom": True},
|
||||
{"id": None, "food_id": food2.id, "grams": 50.0, "is_custom": False} # This represents original meal food
|
||||
]
|
||||
|
||||
response = client.post(
|
||||
@@ -210,7 +210,7 @@ def test_add_food_to_tracked_meal_endpoint(client: TestClient, session: TestingS
|
||||
json={
|
||||
"tracked_meal_id": tracked_meal.id,
|
||||
"food_id": food3.id,
|
||||
"quantity": 200
|
||||
"grams": 200
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from app.database import calculate_multiplier_from_grams
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.database import Base, get_db, Food, Meal, MealFood
|
||||
from app.database import TrackedMealFood
|
||||
from app.database import TrackedDay, TrackedMeal
|
||||
from main import app
|
||||
|
||||
# Setup a test database
|
||||
@@ -47,19 +50,19 @@ def sample_food_50g(session):
|
||||
def test_convert_grams_to_quantity_100g_food(session, sample_food_100g):
|
||||
"""Test convert_grams_to_quantity for a 100g serving size food"""
|
||||
grams = 150.0
|
||||
quantity = session.convert_grams_to_quantity(sample_food_100g.id, grams, session)
|
||||
quantity = calculate_multiplier_from_grams(sample_food_100g.id, grams, session)
|
||||
assert quantity == 1.5
|
||||
|
||||
def test_convert_grams_to_quantity_50g_food(session, sample_food_50g):
|
||||
"""Test convert_grams_to_quantity for a 50g serving size food"""
|
||||
grams = 125.0
|
||||
quantity = session.convert_grams_to_quantity(sample_food_50g.id, grams, session)
|
||||
quantity = calculate_multiplier_from_grams(sample_food_50g.id, grams, session)
|
||||
assert quantity == 2.5
|
||||
|
||||
def test_convert_grams_to_quantity_invalid_food_id(session):
|
||||
"""Test convert_grams_to_quantity with an invalid food ID"""
|
||||
with pytest.raises(ValueError, match="Food with ID 999 not found."):
|
||||
session.convert_grams_to_quantity(999, 100.0, session)
|
||||
calculate_multiplier_from_grams(999, 100.0, session)
|
||||
|
||||
def test_convert_grams_to_quantity_zero_serving_size(session):
|
||||
"""Test convert_grams_to_quantity with zero serving size"""
|
||||
@@ -68,7 +71,7 @@ def test_convert_grams_to_quantity_zero_serving_size(session):
|
||||
session.commit()
|
||||
session.refresh(food)
|
||||
with pytest.raises(ValueError, match="Serving size for food ID .* cannot be zero."):
|
||||
session.convert_grams_to_quantity(food.id, 100.0, session)
|
||||
calculate_multiplier_from_grams(food.id, 100.0, session)
|
||||
|
||||
def test_add_food_to_meal_grams_input(client, session, sample_food_100g):
|
||||
"""Test adding food to a meal with grams input"""
|
||||
@@ -79,14 +82,14 @@ def test_add_food_to_meal_grams_input(client, session, sample_food_100g):
|
||||
|
||||
response = client.post(
|
||||
f"/meals/{meal.id}/add_food",
|
||||
data={"food_id": sample_food_100g.id, "quantity": 250.0} # 250 grams
|
||||
data={"food_id": sample_food_100g.id, "grams": 250.0} # 250 grams
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
meal_food = session.query(MealFood).filter(MealFood.meal_id == meal.id).first()
|
||||
assert meal_food.food_id == sample_food_100g.id
|
||||
assert meal_food.quantity == 2.5 # 250g / 100g serving = 2.5 multiplier
|
||||
assert meal_food.quantity == 250.0
|
||||
|
||||
def test_update_meal_food_quantity_grams_input(client, session, sample_food_50g):
|
||||
"""Test updating meal food quantity with grams input"""
|
||||
@@ -97,8 +100,7 @@ def test_update_meal_food_quantity_grams_input(client, session, sample_food_50g)
|
||||
|
||||
# Add initial food with 100g (2.0 multiplier for 50g serving)
|
||||
initial_grams = 100.0
|
||||
initial_quantity = session.convert_grams_to_quantity(sample_food_50g.id, initial_grams, session)
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=initial_quantity)
|
||||
meal_food = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=initial_grams)
|
||||
session.add(meal_food)
|
||||
session.commit()
|
||||
session.refresh(meal_food)
|
||||
@@ -106,14 +108,13 @@ def test_update_meal_food_quantity_grams_input(client, session, sample_food_50g)
|
||||
updated_grams = 150.0
|
||||
response = client.post(
|
||||
"/meals/update_food_quantity",
|
||||
data={"meal_food_id": meal_food.id, "quantity": updated_grams}
|
||||
data={"meal_food_id": meal_food.id, "grams": updated_grams}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
session.refresh(meal_food)
|
||||
expected_quantity = session.convert_grams_to_quantity(sample_food_50g.id, updated_grams, session)
|
||||
assert meal_food.quantity == expected_quantity
|
||||
assert meal_food.quantity == updated_grams
|
||||
|
||||
# Test for bulk_upload_meals would require creating a mock UploadFile and CSV content
|
||||
# This is more complex and might be deferred or tested manually if the tool's capabilities are limited.
|
||||
@@ -131,7 +132,7 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g):
|
||||
"person": person,
|
||||
"date": date_str,
|
||||
"food_id": sample_food_100g.id,
|
||||
"quantity": grams, # 75 grams
|
||||
"grams": grams, # 75 grams
|
||||
"meal_time": "Breakfast"
|
||||
}
|
||||
)
|
||||
@@ -142,7 +143,7 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g):
|
||||
tracked_meal = session.query(Meal).filter(Meal.name == sample_food_100g.name).first()
|
||||
assert tracked_meal is not None
|
||||
meal_food = session.query(MealFood).filter(MealFood.meal_id == tracked_meal.id).first()
|
||||
assert meal_food.quantity == 0.75 # 75g / 100g serving = 0.75 multiplier
|
||||
assert meal_food.quantity == grams
|
||||
|
||||
def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g, sample_food_50g):
|
||||
"""Test updating tracked meal foods with grams input"""
|
||||
@@ -150,7 +151,8 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g
|
||||
date_str = "2023-01-02"
|
||||
|
||||
# Create a tracked day and meal
|
||||
tracked_day = TrackedDay(person=person, date="2023-01-02", is_modified=False)
|
||||
from datetime import date
|
||||
tracked_day = TrackedDay(person=person, date=date(2023, 1, 2), is_modified=False)
|
||||
session.add(tracked_day)
|
||||
session.commit()
|
||||
session.refresh(tracked_day)
|
||||
@@ -166,8 +168,8 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g
|
||||
session.refresh(tracked_meal)
|
||||
|
||||
# Add initial foods
|
||||
meal_food_100g = MealFood(meal_id=meal.id, food_id=sample_food_100g.id, quantity=1.0) # 100g
|
||||
meal_food_50g = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=2.0) # 100g
|
||||
meal_food_100g = MealFood(meal_id=meal.id, food_id=sample_food_100g.id, quantity=100.0) # 100g
|
||||
meal_food_50g = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=100.0) # 100g
|
||||
session.add_all([meal_food_100g, meal_food_50g])
|
||||
session.commit()
|
||||
session.refresh(meal_food_100g)
|
||||
@@ -175,8 +177,8 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g
|
||||
|
||||
# Update quantities: 100g food to 200g, 50g food to 75g
|
||||
updated_foods_data = [
|
||||
{"id": meal_food_100g.id, "food_id": sample_food_100g.id, "quantity": 200.0, "is_custom": False},
|
||||
{"id": meal_food_50g.id, "food_id": sample_food_50g.id, "quantity": 75.0, "is_custom": False}
|
||||
{"id": meal_food_100g.id, "food_id": sample_food_100g.id, "grams": 200.0, "is_custom": False},
|
||||
{"id": meal_food_50g.id, "food_id": sample_food_50g.id, "grams": 75.0, "is_custom": False}
|
||||
]
|
||||
|
||||
response = client.post(
|
||||
@@ -194,10 +196,10 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g
|
||||
TrackedMealFood.tracked_meal_id == tracked_meal.id,
|
||||
TrackedMealFood.food_id == sample_food_100g.id
|
||||
).first()
|
||||
assert tracked_food_100g.quantity == 2.0 # 200g / 100g serving = 2.0
|
||||
assert tracked_food_100g.quantity == 200.0
|
||||
|
||||
tracked_food_50g = session.query(TrackedMealFood).filter(
|
||||
TrackedMealFood.tracked_meal_id == tracked_meal.id,
|
||||
TrackedMealFood.food_id == sample_food_50g.id
|
||||
).first()
|
||||
assert tracked_food_50g.quantity == 1.5 # 75g / 50g serving = 1.5
|
||||
assert tracked_food_50g.quantity == 75.0
|
||||
|
||||
@@ -92,7 +92,7 @@ class TestMealFoods:
|
||||
"""Test POST /meals/{meal_id}/add_food"""
|
||||
response = client.post(f"/meals/{sample_meal.id}/add_food", data={
|
||||
"food_id": sample_food.id,
|
||||
"quantity": 2.5
|
||||
"grams": 250
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
196
tests/test_quantity_consistency.py
Normal file
196
tests/test_quantity_consistency.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import pytest
|
||||
|
||||
from app.database import (
|
||||
calculate_meal_nutrition,
|
||||
Food,
|
||||
Meal,
|
||||
MealFood,
|
||||
)
|
||||
|
||||
|
||||
def test_meal_nutrition_uses_grams_correctly(db_session):
|
||||
"""Verify that MealFood.quantity as grams calculates nutrition correctly"""
|
||||
# Create a food: 100 cal per 100g
|
||||
food = Food(
|
||||
name="Test Food",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0,
|
||||
protein=10.0,
|
||||
carbs=20.0,
|
||||
fat=5.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
# Create a meal with 200g of this food
|
||||
meal = Meal(name="Test Meal", meal_type="breakfast")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=200.0 # 200 grams
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
# Calculate nutrition
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
|
||||
# Should be 2x the base values (200g / 100g = 2x multiplier)
|
||||
assert nutrition['calories'] == 200.0
|
||||
assert nutrition['protein'] == 20.0
|
||||
assert nutrition['carbs'] == 40.0
|
||||
assert nutrition['fat'] == 10.0
|
||||
|
||||
|
||||
def test_fractional_servings(db_session):
|
||||
"""Test that fractional grams work correctly"""
|
||||
food = Food(
|
||||
name="Test Food",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
meal = Meal(name="Test Meal")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
# Add 50g (half serving)
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=50.0
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
assert nutrition['calories'] == 50.0
|
||||
|
||||
|
||||
def test_zero_serving_size_handling(db_session):
|
||||
"""Test handling of zero serving_size - should not divide by zero"""
|
||||
food = Food(
|
||||
name="Test Food Zero Serving",
|
||||
serving_size=0.0,
|
||||
serving_unit="g",
|
||||
calories=100.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
meal = Meal(name="Test Meal Zero")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=100.0
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
# Multiplier should be 0, so no nutrition added
|
||||
assert nutrition['calories'] == 0.0
|
||||
|
||||
|
||||
def test_small_gram_values(db_session):
|
||||
"""Test very small gram values (e.g., 0.1g)"""
|
||||
food = Food(
|
||||
name="Test Food Small",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
meal = Meal(name="Test Meal Small")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=0.1 # Very small amount
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
# Should be 0.001x multiplier
|
||||
assert nutrition['calories'] == 0.1
|
||||
|
||||
|
||||
def test_large_gram_values(db_session):
|
||||
"""Test large gram values (e.g., 10000g)"""
|
||||
food = Food(
|
||||
name="Test Food Large",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
|
||||
meal = Meal(name="Test Meal Large")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=food.id,
|
||||
quantity=10000.0 # Very large amount
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
# Should be 100x multiplier
|
||||
assert nutrition['calories'] == 10000.0
|
||||
|
||||
|
||||
def test_invalid_serving_size(db_session):
|
||||
"""Test invalid/non-numeric serving_size values"""
|
||||
# First create a valid food to test with
|
||||
valid_food = Food(
|
||||
name="Test Food Valid",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0
|
||||
)
|
||||
db_session.add(valid_food)
|
||||
db_session.commit()
|
||||
|
||||
# Now create a meal and add the valid food
|
||||
meal = Meal(name="Test Meal Valid")
|
||||
db_session.add(meal)
|
||||
db_session.commit()
|
||||
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=valid_food.id,
|
||||
quantity=100.0
|
||||
)
|
||||
db_session.add(meal_food)
|
||||
db_session.commit()
|
||||
|
||||
# Test that the calculation works with valid serving_size
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
assert nutrition['calories'] == 100.0
|
||||
|
||||
# Now test with None serving_size by updating the food
|
||||
valid_food.serving_size = None
|
||||
db_session.commit()
|
||||
|
||||
# Recalculate - should handle None gracefully
|
||||
nutrition = calculate_meal_nutrition(meal, db_session)
|
||||
assert nutrition['calories'] == 0
|
||||
@@ -280,7 +280,7 @@ class TestTrackerEdit:
|
||||
# Update the food quantity via API
|
||||
response = client.post("/tracker/update_tracked_food", json={
|
||||
"tracked_food_id": tracked_food.id,
|
||||
"quantity": 3.0,
|
||||
"grams": 300.0,
|
||||
"is_custom": True
|
||||
})
|
||||
assert response.status_code == 200
|
||||
@@ -290,7 +290,7 @@ class TestTrackerEdit:
|
||||
# Verify the update
|
||||
db_session.commit()
|
||||
updated_food = db_session.query(TrackedMealFood).get(tracked_food.id)
|
||||
assert updated_food.quantity == 3.0
|
||||
assert updated_food.quantity == 300.0
|
||||
assert updated_food.quantity != original_quantity
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ class TestTrackerSaveAsNewMeal:
|
||||
"tracked_meal_id": tracked_meal.id,
|
||||
"new_meal_name": new_meal_name,
|
||||
"foods": [
|
||||
{"food_id": sample_food.id, "quantity": 3.0}
|
||||
{"food_id": sample_food.id, "grams": 300.0}
|
||||
]
|
||||
})
|
||||
assert response.status_code == 200
|
||||
@@ -404,7 +404,7 @@ class TestTrackerAddFood:
|
||||
"person": "Sarah",
|
||||
"date": date.today().isoformat(),
|
||||
"food_id": sample_food.id,
|
||||
"quantity": 150.0,
|
||||
"grams": 150.0,
|
||||
"meal_time": "Dinner"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
@@ -422,7 +422,7 @@ class TestTrackerAddFood:
|
||||
|
||||
assert len(tracked_meal.meal.meal_foods) == 1
|
||||
assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id
|
||||
assert tracked_meal.meal.meal_foods[0].quantity == 1.5
|
||||
assert tracked_meal.meal.meal_foods[0].quantity == 150.0
|
||||
|
||||
def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session):
|
||||
"""
|
||||
@@ -449,7 +449,7 @@ class TestTrackerAddFood:
|
||||
"person": "Sarah",
|
||||
"date": date.today().isoformat(),
|
||||
"food_id": food.id,
|
||||
"quantity": grams_to_add,
|
||||
"grams": grams_to_add,
|
||||
"meal_time": "Snack 1"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
@@ -469,7 +469,7 @@ class TestTrackerAddFood:
|
||||
# Verify the food is in the tracked meal's foods and quantity is in servings
|
||||
assert len(tracked_meal.meal.meal_foods) == 1
|
||||
assert tracked_meal.meal.meal_foods[0].food_id == food.id
|
||||
assert tracked_meal.meal.meal_foods[0].quantity == expected_servings
|
||||
assert tracked_meal.meal.meal_foods[0].quantity == grams_to_add
|
||||
|
||||
# Verify nutrition calculation
|
||||
day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session)
|
||||
|
||||
Reference in New Issue
Block a user