openfood test

This commit is contained in:
2025-09-19 18:58:05 -07:00
parent 7d3e4b3339
commit da426d625e
7 changed files with 352 additions and 25 deletions

Binary file not shown.

152
main.py
View File

@@ -14,7 +14,9 @@ from typing import List, Optional
from datetime import date, datetime
import os
import csv
import requests
from fastapi import File, UploadFile
import openfoodfacts
# Database setup - Use SQLite for easier setup
DATABASE_URL = "sqlite:///./meal_planner.db"
@@ -31,7 +33,7 @@ templates = Jinja2Templates(directory="templates")
# Database Models
class Food(Base):
__tablename__ = "foods"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
serving_size = Column(String)
@@ -44,6 +46,7 @@ class Food(Base):
sugar = Column(Float, default=0)
sodium = Column(Float, default=0)
calcium = Column(Float, default=0)
source = Column(String, default="manual") # manual, csv, openfoodfacts
class Meal(Base):
__tablename__ = "meals"
@@ -73,6 +76,7 @@ class Plan(Base):
person = Column(String, index=True) # Person A or Person B
date = Column(Date, index=True) # Store actual calendar dates
meal_id = Column(Integer, ForeignKey("meals.id"))
meal_time = Column(String, default="Breakfast") # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
meal = relationship("Meal")
@@ -109,6 +113,7 @@ class FoodCreate(BaseModel):
sugar: float = 0
sodium: float = 0
calcium: float = 0
source: str = "manual"
class FoodResponse(BaseModel):
id: int
@@ -123,6 +128,7 @@ class FoodResponse(BaseModel):
sugar: float
sodium: float
calcium: float
source: str
class Config:
from_attributes = True
@@ -257,9 +263,13 @@ async def bulk_upload_foods(file: UploadFile = File(...), db: Session = Depends(
# Update existing food
for key, value in food_data.items():
setattr(existing, key, value)
# Ensure source is set for existing foods
if not existing.source:
existing.source = "csv"
stats['updated'] += 1
else:
# Create new food
food_data['source'] = "csv"
food = Food(**food_data)
db.add(food)
stats['created'] += 1
@@ -281,13 +291,14 @@ async def add_food(request: Request, db: Session = Depends(get_db),
protein: float = Form(...), carbs: float = Form(...),
fat: float = Form(...), fiber: float = Form(0),
sugar: float = Form(0), sodium: float = Form(0),
calcium: float = Form(0)):
calcium: float = Form(0), source: str = Form("manual")):
try:
food = Food(
name=name, serving_size=serving_size, serving_unit=serving_unit,
calories=calories, protein=protein, carbs=carbs, fat=fat,
fiber=fiber, sugar=sugar, sodium=sodium, calcium=calcium
fiber=fiber, sugar=sugar, sodium=sodium, calcium=calcium,
source=source
)
db.add(food)
db.commit()
@@ -298,18 +309,19 @@ async def add_food(request: Request, db: Session = Depends(get_db),
@app.post("/foods/edit")
async def edit_food(request: Request, db: Session = Depends(get_db),
food_id: int = Form(...), name: str = Form(...),
serving_size: str = Form(...), serving_unit: str = Form(...),
calories: float = Form(...), protein: float = Form(...),
carbs: float = Form(...), fat: float = Form(...),
fiber: float = Form(0), sugar: float = Form(0),
sodium: float = Form(0), calcium: float = Form(0)):
food_id: int = Form(...), name: str = Form(...),
serving_size: str = Form(...), serving_unit: str = Form(...),
calories: float = Form(...), protein: float = Form(...),
carbs: float = Form(...), fat: float = Form(...),
fiber: float = Form(0), sugar: float = Form(0),
sodium: float = Form(0), calcium: float = Form(0),
source: str = Form("manual")):
try:
food = db.query(Food).filter(Food.id == food_id).first()
if not food:
return {"status": "error", "message": "Food not found"}
food.name = name
food.serving_size = serving_size
food.serving_unit = serving_unit
@@ -321,7 +333,8 @@ async def edit_food(request: Request, db: Session = Depends(get_db),
food.sugar = sugar
food.sodium = sodium
food.calcium = calcium
food.source = source
db.commit()
return {"status": "success", "message": "Food updated successfully"}
except Exception as e:
@@ -339,6 +352,110 @@ async def delete_foods(food_ids: dict = Body(...), db: Session = Depends(get_db)
db.rollback()
return {"status": "error", "message": str(e)}
@app.get("/foods/search_openfoodfacts")
async def search_openfoodfacts(query: str, limit: int = 10):
"""Search OpenFoodFacts database for foods using the official SDK"""
try:
# Initialize OpenFoodFacts API with User-Agent
api = openfoodfacts.API(user_agent="FoodPlanner/1.0")
# Perform text search
search_result = api.product.text_search(query, page_size=limit)
results = []
if search_result and 'products' in search_result:
for product in search_result['products']:
# Extract nutritional information
nutriments = product.get('nutriments', {})
# Get serving size
serving_size = product.get('serving_size', '100g')
if not serving_size or serving_size == '':
serving_size = '100g'
# Extract serving quantity and unit
serving_quantity = 100 # default to 100g
serving_unit = 'g'
try:
# Try to parse serving size (e.g., "30g", "1 cup")
import re
match = re.match(r'(\d+(?:\.\d+)?)\s*([a-zA-Z]+)', serving_size)
if match:
serving_quantity = float(match.group(1))
serving_unit = match.group(2)
else:
# If no match, assume 100g
serving_quantity = 100
serving_unit = 'g'
except:
serving_quantity = 100
serving_unit = 'g'
# Calculate per serving values (SDK returns per 100g values)
def get_nutrient_value(key, default=0):
value = nutriments.get(key, default)
if value:
try:
# Convert to float if it's a string or other type
numeric_value = float(value)
if serving_quantity != 100:
# Convert to per serving
numeric_value = (numeric_value * serving_quantity) / 100
return round(numeric_value, 2)
except (ValueError, TypeError):
return default
return default
food_data = {
'name': product.get('product_name', product.get('product_name_en', 'Unknown Product')),
'serving_size': str(serving_quantity),
'serving_unit': serving_unit,
'calories': get_nutrient_value('energy-kcal_100g', 0),
'protein': get_nutrient_value('proteins_100g', 0),
'carbs': get_nutrient_value('carbohydrates_100g', 0),
'fat': get_nutrient_value('fat_100g', 0),
'fiber': get_nutrient_value('fiber_100g', 0),
'sugar': get_nutrient_value('sugars_100g', 0),
'sodium': get_nutrient_value('sodium_100g', 0),
'calcium': get_nutrient_value('calcium_100g', 0),
'source': 'openfoodfacts',
'openfoodfacts_id': product.get('code', ''),
'brand': product.get('brands', ''),
'image_url': product.get('image_url', '')
}
results.append(food_data)
return {"status": "success", "results": results}
except Exception as e:
return {"status": "error", "message": f"OpenFoodFacts search failed: {str(e)}"}
@app.post("/foods/add_openfoodfacts")
async def add_openfoodfacts_food(request: Request, db: Session = Depends(get_db),
name: str = Form(...), serving_size: str = Form(...),
serving_unit: str = Form(...), calories: float = Form(...),
protein: float = Form(...), carbs: float = Form(...),
fat: float = Form(...), fiber: float = Form(0),
sugar: float = Form(0), sodium: float = Form(0),
calcium: float = Form(0), openfoodfacts_id: str = Form("")):
try:
food = Food(
name=name, serving_size=serving_size, serving_unit=serving_unit,
calories=calories, protein=protein, carbs=carbs, fat=fat,
fiber=fiber, sugar=sugar, sodium=sodium, calcium=calcium,
source="openfoodfacts"
)
db.add(food)
db.commit()
return {"status": "success", "message": "Food added from OpenFoodFacts successfully"}
except Exception as e:
db.rollback()
return {"status": "error", "message": str(e)}
# Meals tab
@app.get("/meals", response_class=HTMLResponse)
async def meals_page(request: Request, db: Session = Depends(get_db)):
@@ -590,12 +707,12 @@ async def plan_page(request: Request, person: str = "Person A", week_start_date:
@app.post("/plan/add")
async def add_to_plan(request: Request, person: str = Form(...),
plan_date: str = Form(...), meal_id: int = Form(...),
db: Session = Depends(get_db)):
meal_time: str = Form("Breakfast"), db: Session = Depends(get_db)):
try:
from datetime import datetime
plan_date_obj = datetime.fromisoformat(plan_date).date()
plan = Plan(person=person, date=plan_date_obj, meal_id=meal_id)
plan = Plan(person=person, date=plan_date_obj, meal_id=meal_id, meal_time=meal_time)
db.add(plan)
db.commit()
return {"status": "success"}
@@ -616,7 +733,8 @@ async def get_day_plan(person: str, date: str, db: Session = Depends(get_db)):
"id": plan.id,
"meal_id": plan.meal_id,
"meal_name": plan.meal.name,
"meal_type": plan.meal.meal_type
"meal_type": plan.meal.meal_type,
"meal_time": plan.meal_time
})
return result
except Exception as e:
@@ -639,7 +757,7 @@ async def update_day_plan(request: Request, person: str = Form(...),
# Add new plans
for meal_id in meal_id_list:
plan = Plan(person=person, date=plan_date, meal_id=meal_id)
plan = Plan(person=person, date=plan_date, meal_id=meal_id, meal_time="Breakfast")
db.add(plan)
db.commit()

63
migrate_db_schema.py Normal file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Database migration script to add new fields for food sources and meal times.
Run this script to update the database schema.
"""
import sqlite3
from sqlalchemy import create_engine, text
from main import Base, engine
def migrate_database():
"""Add new columns to existing tables"""
# Connect to database
conn = sqlite3.connect('./meal_planner.db')
cursor = conn.cursor()
try:
# Check if source column exists in foods table
cursor.execute("PRAGMA table_info(foods)")
columns = cursor.fetchall()
column_names = [col[1] for col in columns]
if 'source' not in column_names:
print("Adding 'source' column to foods table...")
cursor.execute("ALTER TABLE foods ADD COLUMN source TEXT DEFAULT 'manual'")
print("✓ Added source column to foods table")
# Check if meal_time column exists in plans table
cursor.execute("PRAGMA table_info(plans)")
columns = cursor.fetchall()
column_names = [col[1] for col in columns]
if 'meal_time' not in column_names:
print("Adding 'meal_time' column to plans table...")
cursor.execute("ALTER TABLE plans ADD COLUMN meal_time TEXT DEFAULT 'Breakfast'")
print("✓ Added meal_time column to plans table")
# Update existing records to have proper source values
print("Updating existing food records with source information...")
# Set source to 'csv' for foods that might have been imported
# Note: This is a heuristic - you may need to adjust based on your data
cursor.execute("""
UPDATE foods
SET source = 'csv'
WHERE name LIKE '%(%'
AND source = 'manual'
""")
conn.commit()
print("✓ Migration completed successfully!")
except Exception as e:
print(f"✗ Migration failed: {e}")
conn.rollback()
finally:
conn.close()
if __name__ == "__main__":
print("Starting database migration...")
migrate_database()
print("Migration script completed.")

View File

@@ -3,4 +3,5 @@ uvicorn[standard]==0.24.0
sqlalchemy>=2.0.24
#psycopg2-binary==2.9.9
python-multipart==0.0.6
jinja2==3.1.2
jinja2==3.1.2
openfoodfacts>=3.0.0

View File

@@ -145,6 +145,9 @@
<div class="meal-header">
<span>
<i class="bi bi-egg-fried"></i> {{ meal_detail.plan.meal.name }} - {{ meal_detail.plan.meal.meal_type.title() }}
{% if meal_detail.plan.meal_time %}
<small class="text-muted">({{ meal_detail.plan.meal_time }})</small>
{% endif %}
</span>
<span class="badge bg-light text-dark">{{ "%.0f"|format(meal_detail.nutrition.calories) }} cal</span>
</div>

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="col-md-4">
<h3>Food Import</h3>
<form action="/foods/upload" method="post" enctype="multipart/form-data">
<div class="mb-3">
@@ -11,8 +11,22 @@
<button type="submit" class="btn btn-secondary mb-4">Upload Foods CSV</button>
</form>
</div>
<div class="col-md-6">
<div class="col-md-4">
<h3>OpenFoodFacts Search</h3>
<div class="mb-3">
<label class="form-label">Search for Food</label>
<input type="text" class="form-control" id="offSearch" placeholder="e.g., apple, banana, pizza">
</div>
<button type="button" class="btn btn-primary mb-4" onclick="searchOpenFoodFacts()">Search</button>
<div id="offResults" class="mt-3" style="display: none;">
<h6>Search Results:</h6>
<div id="offResultsList" class="list-group"></div>
</div>
</div>
<div class="col-md-4">
<h3>Meal Import</h3>
<form action="/meals/upload" method="post" enctype="multipart/form-data">
<div class="mb-3">
@@ -40,10 +54,10 @@ document.querySelectorAll('form').forEach(form => {
e.preventDefault();
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(form.action, {
@@ -52,7 +66,7 @@ document.querySelectorAll('form').forEach(form => {
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const results = await response.json();
resultsDiv.style.display = 'block';
document.getElementById('created-count').textContent = results.created || 0;
@@ -79,5 +93,107 @@ document.querySelectorAll('form').forEach(form => {
}
});
});
// OpenFoodFacts search functionality
async function searchOpenFoodFacts() {
const query = document.getElementById('offSearch').value.trim();
if (!query) {
alert('Please enter a search term');
return;
}
const resultsDiv = document.getElementById('offResults');
const resultsList = document.getElementById('offResultsList');
// Show loading
resultsDiv.style.display = 'block';
resultsList.innerHTML = '<div class="text-center"><div class="spinner-border" role="status"></div> Searching...</div>';
try {
const response = await fetch(`/foods/search_openfoodfacts?query=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.status === 'success') {
displayOpenFoodFactsResults(data.results);
} else {
resultsList.innerHTML = `<div class="alert alert-danger">Error: ${data.message}</div>`;
}
} catch (error) {
resultsList.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
}
}
function displayOpenFoodFactsResults(results) {
const resultsList = document.getElementById('offResultsList');
if (results.length === 0) {
resultsList.innerHTML = '<div class="alert alert-info">No results found</div>';
return;
}
let html = '';
results.forEach((food, index) => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">${food.name}</h6>
<p class="mb-1 text-muted small">
${food.serving_size}${food.serving_unit} |
${food.calories} cal |
P: ${food.protein}g, C: ${food.carbs}g, F: ${food.fat}g
</p>
${food.brand ? `<small class="text-muted">Brand: ${food.brand}</small>` : ''}
</div>
<button class="btn btn-sm btn-success" onclick="addOpenFoodFactsFood(${index})">
<i class="bi bi-plus-circle"></i> Add
</button>
</div>
</div>
`;
});
resultsList.innerHTML = html;
// Store results for later use
window.offSearchResults = results;
}
async function addOpenFoodFactsFood(index) {
const food = window.offSearchResults[index];
if (!food) return;
try {
const formData = new FormData();
Object.keys(food).forEach(key => {
if (key !== 'image_url' && key !== 'openfoodfacts_id' && key !== 'brand') {
formData.append(key, food[key]);
}
});
const response = await fetch('/foods/add_openfoodfacts', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.status === 'success') {
alert('Food added successfully!');
// Optionally reload the page or update UI
} else {
alert('Error adding food: ' + result.message);
}
} catch (error) {
alert('Error adding food: ' + error.message);
}
}
// Allow Enter key to trigger search
document.getElementById('offSearch').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchOpenFoodFacts();
}
});
</script>
{% endblock %}

View File

@@ -43,7 +43,9 @@
</td>
<td>
{% for plan in plans[day.date.isoformat()] %}
<span class="badge bg-secondary me-1">{{ plan.meal.name }}</span>
<span class="badge bg-secondary me-1" title="{{ plan.meal_time }}">
<small>{{ plan.meal_time }}:</small> {{ plan.meal.name }}
</span>
{% endfor %}
{% if not plans[day.date.isoformat()] %}
<em class="text-muted">No meals</em>
@@ -89,6 +91,18 @@
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Meal Time</label>
<select class="form-control" name="meal_time" required>
<option value="Breakfast">Breakfast</option>
<option value="Lunch">Lunch</option>
<option value="Dinner">Dinner</option>
<option value="Snack 1">Snack 1</option>
<option value="Snack 2">Snack 2</option>
<option value="Beverage 1">Beverage 1</option>
<option value="Beverage 2">Beverage 2</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
@@ -127,6 +141,18 @@
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Meal Time</label>
<select class="form-control" id="mealTimeSelectForDay" name="meal_time" required>
<option value="Breakfast">Breakfast</option>
<option value="Lunch">Lunch</option>
<option value="Dinner">Dinner</option>
<option value="Snack 1">Snack 1</option>
<option value="Snack 2">Snack 2</option>
<option value="Beverage 1">Beverage 1</option>
<option value="Beverage 2">Beverage 2</option>
</select>
</div>
<button type="button" class="btn btn-success btn-sm" onclick="addMealToCurrentDay()">
<i class="bi bi-plus"></i> Add Selected Meal
</button>