diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index 4cf116e..6dd5220 100644 Binary files a/__pycache__/main.cpython-313.pyc and b/__pycache__/main.cpython-313.pyc differ diff --git a/main.py b/main.py index 1e65748..000f10a 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/migrate_db_schema.py b/migrate_db_schema.py new file mode 100644 index 0000000..42670d8 --- /dev/null +++ b/migrate_db_schema.py @@ -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.") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c73cb50..de77c52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +jinja2==3.1.2 +openfoodfacts>=3.0.0 \ No newline at end of file diff --git a/templates/detailed.html b/templates/detailed.html index 873c6c1..e5d72d3 100644 --- a/templates/detailed.html +++ b/templates/detailed.html @@ -145,6 +145,9 @@
{{ meal_detail.plan.meal.name }} - {{ meal_detail.plan.meal.meal_type.title() }} + {% if meal_detail.plan.meal_time %} + ({{ meal_detail.plan.meal_time }}) + {% endif %} {{ "%.0f"|format(meal_detail.nutrition.calories) }} cal
diff --git a/templates/imports.html b/templates/imports.html index 9e4f6d8..bb5b3c6 100644 --- a/templates/imports.html +++ b/templates/imports.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %}
-
+

Food Import

@@ -11,8 +11,22 @@
- -
+ +
+

OpenFoodFacts Search

+
+ + +
+ + + +
+ +

Meal Import

@@ -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 = ' 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 = '
Searching...
'; + + 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 = `
Error: ${data.message}
`; + } + } catch (error) { + resultsList.innerHTML = `
Error: ${error.message}
`; + } +} + +function displayOpenFoodFactsResults(results) { + const resultsList = document.getElementById('offResultsList'); + + if (results.length === 0) { + resultsList.innerHTML = '
No results found
'; + return; + } + + let html = ''; + results.forEach((food, index) => { + html += ` +
+
+
+
${food.name}
+

+ ${food.serving_size}${food.serving_unit} | + ${food.calories} cal | + P: ${food.protein}g, C: ${food.carbs}g, F: ${food.fat}g +

+ ${food.brand ? `Brand: ${food.brand}` : ''} +
+ +
+
+ `; + }); + + 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(); + } +}); {% endblock %} \ No newline at end of file diff --git a/templates/plan.html b/templates/plan.html index 4de5f36..c4b4bbb 100644 --- a/templates/plan.html +++ b/templates/plan.html @@ -43,7 +43,9 @@ {% for plan in plans[day.date.isoformat()] %} - {{ plan.meal.name }} + + {{ plan.meal_time }}: {{ plan.meal.name }} + {% endfor %} {% if not plans[day.date.isoformat()] %} No meals @@ -89,6 +91,18 @@ {% endfor %}
+
+ + +
+
+ + +