working import

This commit is contained in:
2025-09-19 11:12:53 -07:00
parent c8453dce89
commit 688757b0e5
5 changed files with 393 additions and 84 deletions

Binary file not shown.

161
main.py
View File

@@ -1,18 +1,20 @@
# Meal Planner FastAPI Application
# Run with: uvicorn main:app --reload
from fastapi import FastAPI, Depends, HTTPException, Request, Form
from fastapi import FastAPI, Depends, HTTPException, Request, Form, Body
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text, Date
from sqlalchemy import or_
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session, relationship
from pydantic import BaseModel
from typing import List, Optional
from datetime import date, datetime
import os
import csv
from fastapi import File, UploadFile
# Database setup - Use SQLite for easier setup
DATABASE_URL = "sqlite:///./meal_planner.db"
# For production, use PostgreSQL: DATABASE_URL = "postgresql://username:password@localhost/meal_planner"
@@ -185,6 +187,148 @@ async def foods_page(request: Request, db: Session = Depends(get_db)):
foods = db.query(Food).all()
return templates.TemplateResponse("foods.html", {"request": request, "foods": foods})
@app.post("/foods/upload")
async def bulk_upload_foods(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""Handle bulk food upload from CSV"""
try:
contents = await file.read()
decoded = contents.decode('utf-8').splitlines()
reader = csv.DictReader(decoded)
stats = {'created': 0, 'updated': 0, 'errors': []}
for row_num, row in enumerate(reader, 2): # Row numbers start at 2 (1-based + header)
try:
# Map CSV columns to model fields
food_data = {
'name': f"{row['ID']} ({row['Brand']})",
'serving_size': str(round(float(row['Serving (g)']), 3)),
'serving_unit': 'g',
'calories': round(float(row['Calories']), 2),
'protein': round(float(row['Protein (g)']), 2),
'carbs': round(float(row['Carbohydrate (g)']), 2),
'fat': round(float(row['Fat (g)']), 2),
'fiber': round(float(row.get('Fiber (g)', 0)), 2),
'sugar': round(float(row.get('Sugar (g)', 0)), 2),
'sodium': round(float(row.get('Sodium (mg)', 0)), 2),
'calcium': round(float(row.get('Calcium (mg)', 0)), 2)
}
# Check for existing food
existing = db.query(Food).filter(Food.name == food_data['name']).first()
if existing:
# Update existing food
for key, value in food_data.items():
setattr(existing, key, value)
stats['updated'] += 1
else:
# Create new food
food = Food(**food_data)
db.add(food)
stats['created'] += 1
except (KeyError, ValueError) as e:
stats['errors'].append(f"Row {row_num}: {str(e)}")
db.commit()
return stats
except Exception as e:
db.rollback()
return {"status": "error", "message": str(e)}
@app.post("/meals/upload")
async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""Handle bulk meal upload from CSV"""
try:
contents = await file.read()
decoded = contents.decode('utf-8').splitlines()
reader = csv.reader(decoded)
stats = {'created': 0, 'updated': 0, 'errors': []}
# Skip header rows
next(reader) # First header
next(reader) # Second header
for row_num, row in enumerate(reader, 3): # Start at row 3
if not row:
continue
try:
meal_name = row[0].strip()
ingredients = []
# Process ingredient pairs (item, grams)
for i in range(1, len(row), 2):
if i+1 >= len(row) or not row[i].strip():
continue
food_name = row[i].strip()
quantity = round(float(row[i+1].strip()) / 100, 3) # Convert grams to 100g units and round to 3 decimal places
# Try multiple matching strategies for food names
food = None
# Strategy 1: Exact match
food = db.query(Food).filter(Food.name.ilike(food_name)).first()
# Strategy 2: Match food name within stored name (handles "ID (Brand) Name" format)
if not food:
food = db.query(Food).filter(Food.name.ilike(f"%{food_name}%")).first()
# Strategy 3: Try to match food name after closing parenthesis in "ID (Brand) Name" format
if not food:
# Look for pattern like ") mushrooms" at end of name
search_pattern = f") {food_name}"
food = db.query(Food).filter(Food.name.ilike(f"%{search_pattern}%")).first()
if not food:
# Get all food names for debugging
all_foods = db.query(Food.name).limit(10).all()
food_names = [f[0] for f in all_foods]
raise ValueError(f"Food '{food_name}' not found. Available foods include: {', '.join(food_names[:5])}...")
ingredients.append((food.id, quantity))
# Create/update meal
existing = db.query(Meal).filter(Meal.name == meal_name).first()
if existing:
# Remove existing ingredients
db.query(MealFood).filter(MealFood.meal_id == existing.id).delete()
existing.meal_type = "custom" # Default type
stats['updated'] += 1
else:
existing = Meal(name=meal_name, meal_type="custom")
db.add(existing)
stats['created'] += 1
db.flush() # Get meal ID
# Add new ingredients
for food_id, quantity in ingredients:
meal_food = MealFood(
meal_id=existing.id,
food_id=food_id,
quantity=quantity
)
db.add(meal_food)
db.commit()
except (ValueError, IndexError) as e:
db.rollback()
stats['errors'].append(f"Row {row_num}: {str(e)}")
except Exception as e:
db.rollback()
stats['errors'].append(f"Row {row_num}: Unexpected error - {str(e)}")
return stats
except Exception as e:
db.rollback()
return {"status": "error", "message": str(e)}
@app.post("/foods/add")
async def add_food(request: Request, db: Session = Depends(get_db),
name: str = Form(...), serving_size: str = Form(...),
@@ -230,6 +374,19 @@ async def add_food_to_meal(meal_id: int, food_id: int = Form(...),
db.commit()
return {"status": "success"}
@app.post("/meals/delete")
async def delete_meals(meal_ids: dict = Body(...), db: Session = Depends(get_db)):
try:
# Delete meal foods first
db.query(MealFood).filter(MealFood.meal_id.in_(meal_ids["meal_ids"])).delete(synchronize_session=False)
# Delete meals
db.query(Meal).filter(Meal.id.in_(meal_ids["meal_ids"])).delete(synchronize_session=False)
db.commit()
return {"status": "success"}
except Exception as e:
db.rollback()
return {"status": "error", "message": str(e)}
# Plan tab
@app.get("/plan", response_class=HTMLResponse)
async def plan_page(request: Request, person: str = "Person A", db: Session = Depends(get_db)):

View File

@@ -1,6 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
sqlalchemy>=2.0.24
#psycopg2-binary==2.9.9
python-multipart==0.0.6
jinja2==3.1.2

View File

@@ -2,6 +2,15 @@
{% block content %}
<div class="row">
<div class="col-md-4">
<h3>Bulk Import</h3>
<form action="/foods/upload" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" accept=".csv" required>
</div>
<button type="submit" class="btn btn-secondary mb-4">Upload CSV</button>
</form>
<h3>Add New Food</h3>
<form action="/foods/add" method="post">
<div class="mb-3">
@@ -62,6 +71,15 @@
</form>
</div>
<div class="col-md-8" id="upload-results" style="display: none;">
<div class="alert alert-success">
<strong>Upload Results:</strong>
<span id="created-count"></span> created,
<span id="updated-count"></span> updated
<div id="error-list" class="mt-2 text-danger"></div>
</div>
</div>
<div class="col-md-8">
<h3>Foods Database</h3>
<div class="table-responsive">
@@ -82,15 +100,16 @@
<tbody>
{% for food in foods %}
<tr>
<td><input type="checkbox" name="selected_foods" value="{{ food.id }}"></td>
<td>{{ food.name }}</td>
<td>{{ food.serving_size }} {{ food.serving_unit }}</td>
<td>{{ "%.1f"|format(food.calories) }}</td>
<td>{{ "%.1f"|format(food.protein) }}g</td>
<td>{{ "%.1f"|format(food.carbs) }}g</td>
<td>{{ "%.1f"|format(food.fat) }}g</td>
<td>{{ "%.1f"|format(food.fiber) }}g</td>
<td>{{ "%.1f"|format(food.sodium) }}mg</td>
<td>{{ "%.1f"|format(food.calcium) }}mg</td>
<td>{{ "%.2f"|format(food.calories) }}</td>
<td>{{ "%.2f"|format(food.protein) }}g</td>
<td>{{ "%.2f"|format(food.carbs) }}g</td>
<td>{{ "%.2f"|format(food.fat) }}g</td>
<td>{{ "%.2f"|format(food.fiber) }}g</td>
<td>{{ "%.2f"|format(food.sodium) }}mg</td>
<td>{{ "%.2f"|format(food.calcium) }}mg</td>
</tr>
{% endfor %}
</tbody>
@@ -98,4 +117,52 @@
</div>
</div>
</div>
<script>
document.querySelector('form[action="/foods/upload"]').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const submitBtn = form.querySelector('button[type="submit"]');
const resultsDiv = document.getElementById('upload-results');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Uploading...';
try {
const formData = new FormData(form);
const response = await fetch('/foods/upload', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const results = await response.json();
// Always show results div even if no changes
resultsDiv.style.display = 'block';
document.getElementById('created-count').textContent = results.created || 0;
document.getElementById('updated-count').textContent = results.updated || 0;
if (results.errors?.length > 0) {
document.getElementById('error-list').innerHTML =
`<strong>Errors (${results.errors.length}):</strong><br>` + results.errors.join('<br>');
} else {
document.getElementById('error-list').innerHTML = '';
}
if (results.created || results.updated) {
setTimeout(() => window.location.reload(), 2000);
}
} catch (error) {
resultsDiv.style.display = 'block';
resultsDiv.querySelector('.alert').className = 'alert alert-danger';
document.getElementById('error-list').innerHTML =
`<strong>Upload Failed:</strong> ${error.message}`;
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Upload CSV';
}
});
</script>
{% endblock %}

View File

@@ -1,101 +1,186 @@
<!-- templates/meals.html -->
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-md-4">
<h3>Add New Food</h3>
<form action="/foods/add" method="post">
<h3>Bulk Import Meals</h3>
<form action="/meals/upload" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Name</label>
<label class="form-label">CSV File</label>
<input type="file" class="form-control" name="file" accept=".csv" required>
</div>
<button type="submit" class="btn btn-secondary mb-4">Upload CSV</button>
</form>
<h3>Create New Meal</h3>
<form action="/meals/add" method="post">
<div class="mb-3">
<label class="form-label">Meal Name</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="row">
<div class="col-6">
<label class="form-label">Serving Size</label>
<input type="text" class="form-control" name="serving_size" required>
</div>
<div class="col-6">
<label class="form-label">Unit</label>
<input type="text" class="form-control" name="serving_unit" required>
</div>
<div class="mb-3">
<label class="form-label">Meal Type</label>
<select class="form-control" name="meal_type" required>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="dinner">Dinner</option>
<option value="snack">Snack</option>
</select>
</div>
<div class="row mt-3">
<div class="col-6">
<label class="form-label">Calories</label>
<input type="number" step="0.1" class="form-control" name="calories" required>
</div>
<div class="col-6">
<label class="form-label">Protein (g)</label>
<input type="number" step="0.1" class="form-control" name="protein" required>
</div>
</div>
<div class="row mt-3">
<div class="col-6">
<label class="form-label">Carbs (g)</label>
<input type="number" step="0.1" class="form-control" name="carbs" required>
</div>
<div class="col-6">
<label class="form-label">Fat (g)</label>
<input type="number" step="0.1" class="form-control" name="fat" required>
</div>
</div>
<div class="row mt-3">
<div class="col-6">
<label class="form-label">Fiber (g)</label>
<input type="number" step="0.1" class="form-control" name="fiber" value="0">
</div>
<div class="col-6">
<label class="form-label">Sugar (g)</label>
<input type="number" step="0.1" class="form-control" name="sugar" value="0">
</div>
</div>
<div class="row mt-3">
<div class="col-6">
<label class="form-label">Sodium (mg)</label>
<input type="number" step="0.1" class="form-control" name="sodium" value="0">
</div>
<div class="col-6">
<label class="form-label">Calcium (mg)</label>
<input type="number" step="0.1" class="form-control" name="calcium" value="0">
</div>
</div>
<button type="submit" class="btn btn-primary mt-3">Add Food</button>
<button type="submit" class="btn btn-primary">Create Meal</button>
</form>
<div class="mt-4">
<h4>Add Food to Meal</h4>
<form id="addFoodForm">
<div class="mb-3">
<label class="form-label">Select Meal</label>
<select class="form-control" id="mealSelect">
<option value="">Choose meal...</option>
{% for meal in meals %}
<option value="{{ meal.id }}">{{ meal.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Select Food</label>
<select class="form-control" id="foodSelect">
<option value="">Choose food...</option>
{% for food in foods %}
<option value="{{ food.id }}">{{ food.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Quantity</label>
<input type="number" step="0.1" class="form-control" id="quantity" value="1">
</div>
<button type="button" onclick="addFoodToMeal()" class="btn btn-success">Add Food</button>
</form>
</div>
</div>
<div class="col-md-8">
<h3>Foods Database</h3>
<h3>Meals</h3>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><input type="checkbox" id="select-all-meals" onclick="toggleAllMeals(this)"></th>
<th>Name</th>
<th>Serving</th>
<th>Cal</th>
<th>Protein</th>
<th>Carbs</th>
<th>Fat</th>
<th>Fiber</th>
<th>Sodium</th>
<th>Calcium</th>
<th>Type</th>
<th>Food Items</th>
</tr>
</thead>
<tbody>
{% for food in foods %}
{% for meal in meals %}
<tr>
<td>{{ food.name }}</td>
<td>{{ food.serving_size }} {{ food.serving_unit }}</td>
<td>{{ "%.1f"|format(food.calories) }}</td>
<td>{{ "%.1f"|format(food.protein) }}g</td>
<td>{{ "%.1f"|format(food.carbs) }}g</td>
<td>{{ "%.1f"|format(food.fat) }}g</td>
<td>{{ "%.1f"|format(food.fiber) }}g</td>
<td>{{ "%.1f"|format(food.sodium) }}mg</td>
<td>{{ "%.1f"|format(food.calcium) }}mg</td>
<td><input type="checkbox" name="selected_meals" value="{{ meal.id }}"></td>
<td>{{ meal.name }}</td>
<td>{{ meal.meal_type.title() }}</td>
<td>
{% if meal.meal_foods %}
<ul class="list-unstyled">
{% for meal_food in meal.meal_foods %}
<li>{{ meal_food.quantity }} × {{ meal_food.food.name }}</li>
{% endfor %}
</ul>
{% else %}
<em>No foods added</em>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<button class="btn btn-danger mt-3" onclick="deleteSelectedMeals()" style="margin-top: 20px !important;">
<i class="bi bi-trash"></i> Delete Selected Meals
</button>
100| </div>
</div>
{% endblock %}
<script>
function toggleAllMeals(source) {
console.log('Toggling all meals');
const checkboxes = document.querySelectorAll('input[name="selected_meals"]');
checkboxes.forEach(checkbox => checkbox.checked = source.checked);
}
async function deleteSelectedMeals() {
console.log('Delete selected meals called');
const selected = Array.from(document.querySelectorAll('input[name="selected_meals"]:checked'))
.map(checkbox => checkbox.value);
if (selected.length === 0) {
alert('Please select meals to delete');
return;
}
if (confirm(`Delete ${selected.length} selected meals?`)) {
try {
console.log('Deleting meals:', selected);
const response = await fetch('/meals/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({meal_ids: selected})
});
const result = await response.json();
console.log('Delete response:', result);
if (response.ok) {
window.location.reload();
} else {
alert(`Delete failed: ${result.message || response.status}`);
}
} catch (error) {
console.error('Delete failed:', error);
alert('Delete failed: ' + error.message);
}
}
}
// Meal CSV upload handling
document.querySelector('form[action="/meals/upload"]').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const submitBtn = form.querySelector('button[type="submit"]');
const resultsDiv = document.createElement('div');
resultsDiv.className = 'alert alert-info mt-3';
form.parentNode.insertBefore(resultsDiv, form.nextSibling);
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploading...';
try {
const formData = new FormData(form);
const response = await fetch('/meals/upload', {
method: 'POST',
body: formData
});
const results = await response.json();
if (results.errors?.length > 0) {
resultsDiv.className = 'alert alert-danger';
resultsDiv.innerHTML = `<strong>Errors (${results.errors.length}):</strong><br>` +
results.errors.join('<br>');
} else {
resultsDiv.className = 'alert alert-success';
resultsDiv.innerHTML = `Successfully created ${results.created} meals, updated ${results.updated}`;
}
if (results.created || results.updated) {
setTimeout(() => window.location.reload(), 2000);
}
} catch (error) {
resultsDiv.className = 'alert alert-danger';
resultsDiv.textContent = `Upload failed: ${error.message}`;
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Upload CSV';
}
});
</script>
{% endblock %}