adding new bug fixes - changed the way meals are tracked and logged to make them copies not references

This commit is contained in:
2026-01-11 07:40:27 -08:00
parent ea45b32450
commit b48a7675dd
8 changed files with 343 additions and 145 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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
- ./data:/app/data
- ./app:/app/app
- ./templates:/app/templates
- ./main.py:/app/main.py

Binary file not shown.

View File

@@ -117,6 +117,7 @@
<tr>
<th style="width: 40%">Food</th>
<th class="text-end">Carbs</th>
<th class="text-end">Fiber</th>
<th class="text-end">Net Carbs</th>
<th class="text-end">Fat</th>
<th class="text-end">Protein</th>
@@ -153,6 +154,7 @@
}})</span>
</td>
<td class="text-end">{{ "%.1f"|format(row_carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_carbs - row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fat) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_protein) }}g</td>
@@ -190,6 +192,7 @@
<span class="text-muted ms-1">({{ qty|round(1) }} g)</span>
</td>
<td class="text-end">{{ "%.1f"|format(row_carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_carbs - row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fat) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_protein) }}g</td>
@@ -206,6 +209,7 @@
<tr class="table-secondary fw-bold">
<td>Total</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.carbs - meal_totals.fiber)
}}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.fat) }}g</td>
@@ -231,58 +235,103 @@
<!-- Right Column - Nutrition Totals -->
<div class="col-md-4">
<div class="card sticky-top" style="top: 20px;">
<div class="card-header">
<h5 class="mb-0">Daily Totals</h5>
<div class="sticky-top" style="top: 20px;">
<!-- Daily Totals Card -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Daily Totals</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-primary">{{ "%.0f"|format(day_totals.calories) }}</strong>
<div class="small text-muted">Calories</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-success">{{ "%.1f"|format(day_totals.protein) }}g</strong>
<div class="small text-muted">Protein ({{ day_totals.protein_pct }}%)</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.1f"|format(day_totals.carbs) }}g</strong>
<div class="small text-muted">Carbs ({{ day_totals.carbs_pct }}%)</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-danger">{{ "%.1f"|format(day_totals.fat) }}g</strong>
<div class="small text-muted">Fat ({{ day_totals.fat_pct }}%)</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.1f"|format(day_totals.fiber) }}g</strong>
<div class="small text-muted">Fiber</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-warning">{{ "%.1f"|format(day_totals.net_carbs) }}g</strong>
<div class="small text-muted">Net Carbs</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.0f"|format(day_totals.sugar) }}g</strong>
<div class="small text-muted">Sugar</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.0f"|format(day_totals.sodium) }}mg</strong>
<div class="small text-muted">Sodium</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-primary">{{ "%.0f"|format(day_totals.calories) }}</strong>
<div class="small text-muted">Calories</div>
<!-- Macro Balance Card -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Macro Balance</h5>
</div>
<div class="card-body">
<div class="mb-3">
<div class="d-flex justify-content-between small mb-1">
<span class="text-success fw-bold">Protein</span>
<span>{{ day_totals.protein_pct }}%</span>
</div>
<div class="progress" style="height: 10px;">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ day_totals.protein_pct }}%"
aria-valuenow="{{ day_totals.protein_pct }}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-success">{{ "%.1f"|format(day_totals.protein) }}g</strong>
<div class="small text-muted">Protein ({{ day_totals.protein_pct }}%)</div>
<div class="mb-3">
<div class="d-flex justify-content-between small mb-1">
<span class="fw-bold" style="color: #0d6efd;">Carbs</span>
<span>{{ day_totals.carbs_pct }}%</span>
</div>
<div class="progress" style="height: 10px;">
<div class="progress-bar" role="progressbar" style="width: {{ day_totals.carbs_pct }}%"
aria-valuenow="{{ day_totals.carbs_pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.1f"|format(day_totals.carbs) }}g</strong>
<div class="small text-muted">Carbs ({{ day_totals.carbs_pct }}%)</div>
<div class="mb-0">
<div class="d-flex justify-content-between small mb-1">
<span class="text-danger fw-bold">Fat</span>
<span>{{ day_totals.fat_pct }}%</span>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-danger">{{ "%.1f"|format(day_totals.fat) }}g</strong>
<div class="small text-muted">Fat ({{ day_totals.fat_pct }}%)</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.1f"|format(day_totals.fiber) }}g</strong>
<div class="small text-muted">Fiber</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-warning">{{ "%.1f"|format(day_totals.net_carbs) }}g</strong>
<div class="small text-muted">Net Carbs</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.0f"|format(day_totals.sugar) }}g</strong>
<div class="small text-muted">Sugar</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.0f"|format(day_totals.sodium) }}mg</strong>
<div class="small text-muted">Sodium</div>
<div class="progress" style="height: 10px;">
<div class="progress-bar bg-danger" role="progressbar"
style="width: {{ day_totals.fat_pct }}%" aria-valuenow="{{ day_totals.fat_pct }}"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>