From b48a7675ddcd2e83e4f0a92fba90d0bf13720111 Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 11 Jan 2026 07:40:27 -0800 Subject: [PATCH] adding new bug fixes - changed the way meals are tracked and logged to make them copies not references --- .../31fdce040eea_snapshot_existing_meals.py | 101 +++++++++ app/api/routes/export.py | 194 ++++++++++-------- app/api/routes/meals.py | 8 +- app/api/routes/tracker.py | 32 ++- app/database.py | 6 +- docker-compose.yml | 6 +- ...ner_2026-01-06_17-20-51.db:Zone.Identifier | Bin 0 -> 25 bytes templates/tracker.html | 141 ++++++++----- 8 files changed, 343 insertions(+), 145 deletions(-) create mode 100644 alembic/versions/31fdce040eea_snapshot_existing_meals.py create mode 100644 meal_planner_2026-01-06_17-20-51.db:Zone.Identifier diff --git a/alembic/versions/31fdce040eea_snapshot_existing_meals.py b/alembic/versions/31fdce040eea_snapshot_existing_meals.py new file mode 100644 index 0000000..8e54c3e --- /dev/null +++ b/alembic/versions/31fdce040eea_snapshot_existing_meals.py @@ -0,0 +1,101 @@ +"""snapshot_existing_meals + +Revision ID: 31fdce040eea +Revises: 4522e2de4143 +Create Date: 2026-01-10 13:30:49.977264 + +""" +from typing import Sequence, Union +from sqlalchemy import orm, text +from app.database import Base +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '31fdce040eea' +down_revision: Union[str, None] = '4522e2de4143' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + + +def upgrade() -> None: + bind = op.get_bind() + session = orm.Session(bind=bind) + + # Use reflection or raw SQL to avoid importing app models directly + # This ensures the migration remains valid even if app models change later + + # 1. Get all tracked meals that are NOT already snapshots + # We join tracked_meals with meals to check the meal_type + sql = text(""" + SELECT tm.id as tracked_meal_id, tm.meal_id, m.name, m.meal_time + FROM tracked_meals tm + JOIN meals m ON tm.meal_id = m.id + WHERE m.meal_type != 'tracked_snapshot' + """) + + tracked_meals_to_snapshot = session.execute(sql).fetchall() + + print(f"Found {len(tracked_meals_to_snapshot)} tracked meals to snapshot.") + + for row in tracked_meals_to_snapshot: + tm_id = row.tracked_meal_id + original_meal_id = row.meal_id + original_name = row.name + original_meal_time = row.meal_time + + # 2. Create a new snapshot meal + # We can't easily use ORM since we don't have the classes, so we use raw SQL + insert_meal_sql = text(""" + INSERT INTO meals (name, meal_type, meal_time) + VALUES (:name, 'tracked_snapshot', :meal_time) + """) + + # execution_options={"autocommit": True} might be needed for some drivers, + # but session.execute usually handles it. + # For SQLite, we can get the last inserted id via cursor, but SQLAlchemy does this via result.lastrowid + + result = session.execute(insert_meal_sql, { + "name": original_name, + "meal_time": original_meal_time + }) + new_meal_id = result.lastrowid + + # 3. Copy ingredients from original meal to new snapshot + # Get ingredients + get_foods_sql = text(""" + SELECT food_id, quantity + FROM meal_foods + WHERE meal_id = :meal_id + """) + foods = session.execute(get_foods_sql, {"meal_id": original_meal_id}).fetchall() + + if foods: + insert_food_sql = text(""" + INSERT INTO meal_foods (meal_id, food_id, quantity) + VALUES (:meal_id, :food_id, :quantity) + """) + + for food in foods: + session.execute(insert_food_sql, { + "meal_id": new_meal_id, + "food_id": food.food_id, + "quantity": food.quantity + }) + + # 4. Update the stored tracked_meal to point to the new snapshot + update_tm_sql = text(""" + UPDATE tracked_meals + SET meal_id = :new_meal_id + WHERE id = :tm_id + """) + session.execute(update_tm_sql, {"new_meal_id": new_meal_id, "tm_id": tm_id}) + + session.commit() + + + +def downgrade() -> None: + pass diff --git a/app/api/routes/export.py b/app/api/routes/export.py index b8c7daa..5ee3ee7 100644 --- a/app/api/routes/export.py +++ b/app/api/routes/export.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body, File, UploadFile +from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body, File, UploadFile, Response from sqlalchemy.orm import Session from typing import List, Optional from datetime import date, datetime @@ -12,7 +12,7 @@ import re import json from app.database import get_db, Food, Meal, Plan, Template, WeeklyMenu, TrackedDay, MealFood, TemplateMeal, WeeklyMenuDay, TrackedMeal -from app.database import FoodCreate, FoodResponse, MealCreate, TrackedDayCreate, TrackedMealCreate, AllData, FoodExport, MealFoodExport, MealExport, PlanExport, TemplateMealExport, TemplateExport, TemplateMealDetail, TemplateDetail, WeeklyMenuDayExport, WeeklyMenuDayDetail, WeeklyMenuExport, WeeklyMenuDetail, TrackedMealExport, TrackedDayExport +from app.database import FoodCreate, FoodResponse, MealCreate, TrackedDayCreate, TrackedMealCreate, AllData, FoodExport, MealFoodExport, MealExport, PlanExport, TemplateMealExport, TemplateExport, TemplateMealDetail, TemplateDetail, WeeklyMenuDayExport, WeeklyMenuDayDetail, WeeklyMenuExport, WeeklyMenuDetail, TrackedMealExport, TrackedDayExport, TrackedMealFoodExport router = APIRouter() @@ -66,96 +66,117 @@ def validate_import_data(data: AllData): detail=f"Invalid tracked meal: meal_id {tracked_meal.meal_id} not found.", ) -@router.get("/export/all", response_model=AllData) +@router.get("/export/all") async def export_all_data(db: Session = Depends(get_db)): """Export all data from the database as a single JSON file.""" - foods = db.query(Food).all() - meals = db.query(Meal).all() - plans = db.query(Plan).all() - templates = db.query(Template).all() - weekly_menus = db.query(WeeklyMenu).all() - tracked_days = db.query(TrackedDay).all() - # Manual serialization to handle nested relationships - - # Meals with MealFoods - meals_export = [] - for meal in meals: - meal_foods_export = [ - MealFoodExport(food_id=mf.food_id, quantity=mf.quantity) - for mf in meal.meal_foods - ] - meals_export.append( - MealExport( - id=meal.id, - name=meal.name, - meal_type=meal.meal_type, - meal_time=meal.meal_time, - meal_foods=meal_foods_export, + try: + # ... (rest of the code) + foods = db.query(Food).all() + meals = db.query(Meal).all() + plans = db.query(Plan).all() + templates = db.query(Template).all() + weekly_menus = db.query(WeeklyMenu).all() + tracked_days = db.query(TrackedDay).all() + + # Manual serialization to handle nested relationships + + # Meals with MealFoods + meals_export = [] + for meal in meals: + meal_foods_export = [ + MealFoodExport(food_id=mf.food_id, quantity=mf.quantity) + for mf in meal.meal_foods + ] + meals_export.append( + MealExport( + id=meal.id, + name=meal.name, + meal_type=meal.meal_type, + meal_time=meal.meal_time, + meal_foods=meal_foods_export, + ) ) + + # Templates with TemplateMeals + templates_export = [] + for template in templates: + template_meals_export = [ + TemplateMealExport(meal_id=tm.meal_id, meal_time=tm.meal_time) + for tm in template.template_meals + ] + templates_export.append( + TemplateExport( + id=template.id, + name=template.name, + template_meals=template_meals_export, + ) + ) + + # Weekly Menus with WeeklyMenuDays + weekly_menus_export = [] + for weekly_menu in weekly_menus: + weekly_menu_days_export = [ + WeeklyMenuDayExport( + day_of_week=wmd.day_of_week, template_id=wmd.template_id + ) + for wmd in weekly_menu.weekly_menu_days + ] + weekly_menus_export.append( + WeeklyMenuExport( + id=weekly_menu.id, + name=weekly_menu.name, + weekly_menu_days=weekly_menu_days_export, + ) + ) + + # Tracked Days with TrackedMeals + tracked_days_export = [] + for tracked_day in tracked_days: + tracked_meals_export = [ + TrackedMealExport( + meal_id=tm.meal_id, + meal_time=tm.meal_time, + tracked_foods=[ + TrackedMealFoodExport( + food_id=tmf.food_id, + quantity=tmf.quantity, + is_override=tmf.is_override + ) for tmf in tm.tracked_foods + ] + ) + for tm in tracked_day.tracked_meals + ] + tracked_days_export.append( + TrackedDayExport( + id=tracked_day.id, + person=tracked_day.person, + date=tracked_day.date, + is_modified=tracked_day.is_modified, + tracked_meals=tracked_meals_export, + ) + ) + + data = AllData( + foods=[FoodExport.from_orm(f) for f in foods], + meals=meals_export, + plans=[PlanExport.from_orm(p) for p in plans], + templates=templates_export, + weekly_menus=weekly_menus_export, + tracked_days=tracked_days_export, ) - - # Templates with TemplateMeals - templates_export = [] - for template in templates: - template_meals_export = [ - TemplateMealExport(meal_id=tm.meal_id, meal_time=tm.meal_time) - for tm in template.template_meals - ] - templates_export.append( - TemplateExport( - id=template.id, - name=template.name, - template_meals=template_meals_export, - ) + + json_content = data.model_dump_json() + + return Response( + content=json_content, + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=meal_planner_backup.json"} ) - - # Weekly Menus with WeeklyMenuDays - weekly_menus_export = [] - for weekly_menu in weekly_menus: - weekly_menu_days_export = [ - WeeklyMenuDayExport( - day_of_week=wmd.day_of_week, template_id=wmd.template_id - ) - for wmd in weekly_menu.weekly_menu_days - ] - weekly_menus_export.append( - WeeklyMenuExport( - id=weekly_menu.id, - name=weekly_menu.name, - weekly_menu_days=weekly_menu_days_export, - ) - ) - - # Tracked Days with TrackedMeals - tracked_days_export = [] - for tracked_day in tracked_days: - tracked_meals_export = [ - TrackedMealExport( - meal_id=tm.meal_id, - meal_time=tm.meal_time, - quantity=tm.quantity, - ) - for tm in tracked_day.tracked_meals - ] - tracked_days_export.append( - TrackedDayExport( - id=tracked_day.id, - person=tracked_day.person, - date=tracked_day.date, - is_modified=tracked_day.is_modified, - tracked_meals=tracked_meals_export, - ) - ) - - return AllData( - foods=[FoodExport.from_orm(f) for f in foods], - meals=meals_export, - plans=[PlanExport.from_orm(p) for p in plans], - templates=templates_export, - weekly_menus=weekly_menus_export, - tracked_days=tracked_days_export, - ) + except Exception as e: + import traceback + logging.error(f"Error exporting data: {e}\n{traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) @router.post("/import/all") async def import_all_data(file: UploadFile = File(...), db: Session = Depends(get_db)): @@ -259,7 +280,6 @@ async def import_all_data(file: UploadFile = File(...), db: Session = Depends(ge tracked_day_id=tracked_day.id, meal_id=tm_data.meal_id, meal_time=tm_data.meal_time, - quantity=tm_data.quantity, ) ) db.commit() diff --git a/app/api/routes/meals.py b/app/api/routes/meals.py index c113947..18320c2 100644 --- a/app/api/routes/meals.py +++ b/app/api/routes/meals.py @@ -180,10 +180,10 @@ 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(...), db: Session = Depends(get_db)): + quantity: float = Form(...), db: Session = Depends(get_db)): try: - meal_food = MealFood(meal_id=meal_id, food_id=food_id, quantity=grams) + meal_food = MealFood(meal_id=meal_id, food_id=food_id, quantity=quantity) db.add(meal_food) db.commit() return {"status": "success"} @@ -210,14 +210,14 @@ 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(...), db: Session = Depends(get_db)): +async def update_meal_food_quantity(meal_food_id: int = Form(...), quantity: 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() if not meal_food: return {"status": "error", "message": "Meal food not found"} - meal_food.quantity = grams + meal_food.quantity = quantity db.commit() return {"status": "success"} except ValueError as ve: diff --git a/app/api/routes/tracker.py b/app/api/routes/tracker.py index a3caa03..7dbba84 100644 --- a/app/api/routes/tracker.py +++ b/app/api/routes/tracker.py @@ -75,8 +75,8 @@ async def tracker_page(request: Request, person: str = "Sarah", date: str = None ).all() # Template will handle filtering of deleted foods - # Get all meals for dropdown - meals = db.query(Meal).all() + # Get all meals for dropdown (exclude snapshots) + meals = db.query(Meal).filter(Meal.meal_type != "tracked_snapshot").all() # Get all templates for template dropdown templates_list = db.query(Template).all() @@ -138,10 +138,34 @@ async def tracker_add_meal(request: Request, db: Session = Depends(get_db)): db.commit() db.refresh(tracked_day) - # Create tracked meal + # 1. Fetch the original meal + original_meal = db.query(Meal).filter(Meal.id == int(meal_id)).first() + if not original_meal: + return {"status": "error", "message": "Meal not found"} + + # 2. Create a snapshot copy of the meal + snapshot_meal = Meal( + name=original_meal.name, + meal_type="tracked_snapshot", + meal_time=original_meal.meal_time + ) + db.add(snapshot_meal) + db.flush() # get ID + + # 3. Copy ingredients (MealFood) + meal_foods = db.query(MealFood).filter(MealFood.meal_id == original_meal.id).all() + for mf in meal_foods: + snapshot_food = MealFood( + meal_id=snapshot_meal.id, + food_id=mf.food_id, + quantity=mf.quantity + ) + db.add(snapshot_food) + + # 4. Create tracked meal pointing to the SNAPSHOT tracked_meal = TrackedMeal( tracked_day_id=tracked_day.id, - meal_id=int(meal_id), + meal_id=snapshot_meal.id, meal_time=meal_time ) db.add(tracked_meal) diff --git a/app/database.py b/app/database.py index 98adb42..b45e269 100644 --- a/app/database.py +++ b/app/database.py @@ -18,7 +18,7 @@ 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 typing import List, Optional, Union from datetime import date, datetime import os import logging @@ -173,7 +173,7 @@ class TrackedMealFood(Base): # Pydantic models class FoodCreate(BaseModel): name: str - serving_size: str + serving_size: Union[float, str] serving_unit: str calories: float protein: float @@ -189,7 +189,7 @@ class FoodCreate(BaseModel): class FoodResponse(BaseModel): id: int name: str - serving_size: str + serving_size: Union[float, str] serving_unit: str calories: float protein: float diff --git a/docker-compose.yml b/docker-compose.yml index e6e1632..6ec436a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,10 @@ services: - "8999:8999" environment: - DATABASE_URL=sqlite:////app/data/meal_planner.db + - PYTHONUNBUFFERED=1 volumes: - ./alembic:/app/alembic - - ./data:/app/data \ No newline at end of file + - ./data:/app/data + - ./app:/app/app + - ./templates:/app/templates + - ./main.py:/app/main.py \ No newline at end of file diff --git a/meal_planner_2026-01-06_17-20-51.db:Zone.Identifier b/meal_planner_2026-01-06_17-20-51.db:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x Food Carbs + Fiber Net Carbs Fat Protein @@ -153,6 +154,7 @@ }}) {{ "%.1f"|format(row_carbs) }}g + {{ "%.1f"|format(row_fiber) }}g {{ "%.1f"|format(row_carbs - row_fiber) }}g {{ "%.1f"|format(row_fat) }}g {{ "%.1f"|format(row_protein) }}g @@ -190,6 +192,7 @@ ({{ qty|round(1) }} g) {{ "%.1f"|format(row_carbs) }}g + {{ "%.1f"|format(row_fiber) }}g {{ "%.1f"|format(row_carbs - row_fiber) }}g {{ "%.1f"|format(row_fat) }}g {{ "%.1f"|format(row_protein) }}g @@ -206,6 +209,7 @@ Total {{ "%.1f"|format(meal_totals.carbs) }}g + {{ "%.1f"|format(meal_totals.fiber) }}g {{ "%.1f"|format(meal_totals.carbs - meal_totals.fiber) }}g {{ "%.1f"|format(meal_totals.fat) }}g @@ -231,58 +235,103 @@
-
-
-
Daily Totals
+
+ +
+
+
Daily Totals
+
+
+
+
+
+ {{ "%.0f"|format(day_totals.calories) }} +
Calories
+
+
+
+
+ {{ "%.1f"|format(day_totals.protein) }}g +
Protein ({{ day_totals.protein_pct }}%)
+
+
+
+
+ {{ "%.1f"|format(day_totals.carbs) }}g +
Carbs ({{ day_totals.carbs_pct }}%)
+
+
+
+
+ {{ "%.1f"|format(day_totals.fat) }}g +
Fat ({{ day_totals.fat_pct }}%)
+
+
+
+
+ {{ "%.1f"|format(day_totals.fiber) }}g +
Fiber
+
+
+
+
+ {{ "%.1f"|format(day_totals.net_carbs) }}g +
Net Carbs
+
+
+
+
+ {{ "%.0f"|format(day_totals.sugar) }}g +
Sugar
+
+
+
+
+ {{ "%.0f"|format(day_totals.sodium) }}mg +
Sodium
+
+
+
+
-
-
-
-
- {{ "%.0f"|format(day_totals.calories) }} -
Calories
+ + +
+
+
Macro Balance
+
+
+
+
+ Protein + {{ day_totals.protein_pct }}% +
+
+
+
-
-
- {{ "%.1f"|format(day_totals.protein) }}g -
Protein ({{ day_totals.protein_pct }}%)
+
+
+ Carbs + {{ day_totals.carbs_pct }}% +
+
+
-
-
- {{ "%.1f"|format(day_totals.carbs) }}g -
Carbs ({{ day_totals.carbs_pct }}%)
+
+
+ Fat + {{ day_totals.fat_pct }}%
-
-
-
- {{ "%.1f"|format(day_totals.fat) }}g -
Fat ({{ day_totals.fat_pct }}%)
-
-
-
-
- {{ "%.1f"|format(day_totals.fiber) }}g -
Fiber
-
-
-
-
- {{ "%.1f"|format(day_totals.net_carbs) }}g -
Net Carbs
-
-
-
-
- {{ "%.0f"|format(day_totals.sugar) }}g -
Sugar
-
-
-
-
- {{ "%.0f"|format(day_totals.sodium) }}mg -
Sodium
+
+