mirror of
https://github.com/sstent/foodplanner.git
synced 2026-01-25 11:11:36 +00:00
adding new bug fixes - changed the way meals are tracked and logged to make them copies not references
This commit is contained in:
101
alembic/versions/31fdce040eea_snapshot_existing_meals.py
Normal file
101
alembic/versions/31fdce040eea_snapshot_existing_meals.py
Normal 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
|
||||
@@ -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,9 +66,12 @@ 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."""
|
||||
|
||||
try:
|
||||
# ... (rest of the code)
|
||||
foods = db.query(Food).all()
|
||||
meals = db.query(Meal).all()
|
||||
plans = db.query(Plan).all()
|
||||
@@ -134,7 +137,13 @@ async def export_all_data(db: Session = Depends(get_db)):
|
||||
TrackedMealExport(
|
||||
meal_id=tm.meal_id,
|
||||
meal_time=tm.meal_time,
|
||||
quantity=tm.quantity,
|
||||
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
|
||||
]
|
||||
@@ -148,7 +157,7 @@ async def export_all_data(db: Session = Depends(get_db)):
|
||||
)
|
||||
)
|
||||
|
||||
return AllData(
|
||||
data = AllData(
|
||||
foods=[FoodExport.from_orm(f) for f in foods],
|
||||
meals=meals_export,
|
||||
plans=[PlanExport.from_orm(p) for p in plans],
|
||||
@@ -157,6 +166,18 @@ async def export_all_data(db: Session = Depends(get_db)):
|
||||
tracked_days=tracked_days_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"}
|
||||
)
|
||||
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)):
|
||||
"""Import all data from a JSON file, overwriting existing data."""
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
- ./app:/app/app
|
||||
- ./templates:/app/templates
|
||||
- ./main.py:/app/main.py
|
||||
BIN
meal_planner_2026-01-06_17-20-51.db:Zone.Identifier
Normal file
BIN
meal_planner_2026-01-06_17-20-51.db:Zone.Identifier
Normal file
Binary file not shown.
@@ -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,7 +235,9 @@
|
||||
|
||||
<!-- Right Column - Nutrition Totals -->
|
||||
<div class="col-md-4">
|
||||
<div class="card sticky-top" style="top: 20px;">
|
||||
<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>
|
||||
@@ -288,6 +294,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</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="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="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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user