Skip to content

Commit

Permalink
New Features
Browse files Browse the repository at this point in the history
- Added SSL
- OpenFoodFacts API
- Product names will link to OpenFoodFacts (If exists)
- Option to manually type barcode (with Fetch to pull this from OpenFoodFacts)
- Scan Barcode (ability to use phone's camera to scan the barcode and search OpenFoodFacts)

**If the product does not exist when scanned or fetched, user prompt to manually complete form**
  • Loading branch information
mintcreg authored Dec 20, 2024
1 parent ce78b75 commit 4676841
Show file tree
Hide file tree
Showing 11 changed files with 459 additions and 45 deletions.
5 changes: 2 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
ARG BUILD_FROM
FROM $BUILD_FROM

# Install necessary packages
RUN apk add --no-cache python3 py3-pip
# Install necessary packages including openssl
RUN apk add --no-cache python3 py3-pip openssl

# Create a virtual environment
RUN python3 -m venv /opt/venv
Expand All @@ -13,7 +13,6 @@ RUN /opt/venv/bin/pip install --no-cache-dir -r /tmp/requirements.txt

# Copy the application files
COPY webapp /opt/webapp
#COPY custom_components /opt/custom_components

# Copy the run script to s6-overlay services directory
COPY run.sh /etc/services.d/pantry_tracker/run
Expand Down
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,10 @@ The ability to save a copy of the database and restore an existing database

[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fmintcreg%2Fpantry_tracker)

2: Install the addon
2: Install Addon from the addon store

3: Install [Pantry Tracker - Custom Components](https://github.com/mintcreg/pantry_tracker_components) (*Note; this needs to be installed after the pantry_tracker addon*)

> [!IMPORTANT]
> In order for Home Assistant to create sensors, custom_components must be installed
4: Restart Home Assistant

5: Navigate to [http://homeassistant.local:5000/](http://homeassistant.local:5000/) and add products/categories
Expand Down
46 changes: 43 additions & 3 deletions run.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
#!/usr/bin/with-contenv bash
# Start the Flask app
# Start the Flask app with SSL

# No need to activate the virtual environment; PATH is set
python /opt/webapp/app.py
# Enable debug mode for the shell script (optional but helpful for troubleshooting)
set -x

# Define absolute paths to the SSL certificates
CERT_FILE="/config/pantry_data/keys/cert.pem"
KEY_FILE="/config/pantry_data/keys/key.pem"

# Ensure the keys directory exists
mkdir -p /config/pantry_data/keys

# Function to generate self-signed SSL certificates
generate_certificates() {
echo "Generating self-signed SSL certificates..."
openssl req -x509 -newkey rsa:4096 -nodes \
-keyout "$KEY_FILE" -out "$CERT_FILE" \
-days 365 -subj "/CN=localhost"
if [ $? -eq 0 ]; then
echo "Certificates generated successfully at $CERT_FILE and $KEY_FILE"
else
echo "Failed to generate SSL certificates."
exit 1
fi
}

# Check if certificates already exist
if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then
generate_certificates
else
echo "SSL certificates already exist. Skipping generation."
fi

# Verify that certificates exist after generation
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "SSL certificates are present."
else
echo "Failed to generate SSL certificates."
exit 1
fi

# Start the Flask app with SSL using exec to replace the shell with the Flask process
echo "Starting Flask app with SSL..."
exec python /opt/webapp/app.py
88 changes: 72 additions & 16 deletions webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from models import Base, Category, Product, Count
from schemas import CategorySchema, ProductSchema
from marshmallow import ValidationError
import requests # For interacting with OpenFoodFacts

app = Flask(__name__)

Expand Down Expand Up @@ -142,7 +143,7 @@ def products():
try:
products = session.query(Product).all()
product_list = [
{"name": prod.name, "url": prod.url, "category": prod.category.name} for prod in products
{"name": prod.name, "url": prod.url, "category": prod.category.name, "barcode": prod.barcode} for prod in products
]
logger.info("Fetched products: %s", product_list)
return jsonify(product_list)
Expand All @@ -163,6 +164,7 @@ def products():
name = data.get("name")
url = data.get("url")
category_name = data.get("category")
barcode = data.get("barcode") # Optional barcode

try:
# Check if category exists
Expand All @@ -177,7 +179,14 @@ def products():
logger.warning("Duplicate product attempted: %s", name)
return jsonify({"status": "error", "message": "Duplicate product"}), 400

new_product = Product(name=name, url=url, category=category)
# If barcode is provided, ensure it's unique
if barcode:
existing_barcode = session.query(Product).filter_by(barcode=barcode).first()
if existing_barcode:
logger.warning("Duplicate barcode attempted: %s", barcode)
return jsonify({"status": "error", "message": "Barcode already exists"}), 400

new_product = Product(name=name, url=url, category=category, barcode=barcode)
session.add(new_product)

# Initialize count to 0
Expand All @@ -189,12 +198,12 @@ def products():

products = session.query(Product).all()
product_list = [
{"name": prod.name, "url": prod.url, "category": prod.category.name} for prod in products
{"name": prod.name, "url": prod.url, "category": prod.category.name, "barcode": prod.barcode} for prod in products
]
return jsonify({"status": "ok", "products": product_list})
except Exception as e:
session.rollback()
logger.error(f"Error adding product '{name}': {e}")
logger.error("Error adding product '%s': %s", name, e)
return jsonify({"status": "error", "message": "Failed to add product"}), 500
finally:
Session.remove()
Expand All @@ -219,12 +228,12 @@ def products():

products = session.query(Product).all()
product_list = [
{"name": prod.name, "url": prod.url, "category": prod.category.name} for prod in products
{"name": prod.name, "url": prod.url, "category": prod.category.name, "barcode": prod.barcode} for prod in products
]
return jsonify({"status": "ok", "products": product_list})
except Exception as e:
session.rollback()
logger.error(f"Error deleting product '{product_name}': {e}")
logger.error("Error deleting product '%s': %s", product_name, e)
return jsonify({"status": "error", "message": "Failed to delete product"}), 500
finally:
Session.remove()
Expand Down Expand Up @@ -262,21 +271,19 @@ def update_count():
count_entry.count = max(count_entry.count - amount, 0)
else:
logger.warning("Invalid action '%s' in update_count", action)
session.remove()
Session.remove()
return jsonify({"status": "error", "message": "Invalid action"}), 400

session.commit()
logger.info(f"Updated count for {product_name}: {count_entry.count}")
logger.info("Updated count for %s: %s", product_name, count_entry.count)
return jsonify({"status": "ok", "count": count_entry.count})
except Exception as e:
session.rollback()
logger.error(f"Error updating count for {product_name}: {e}")
logger.error("Error updating count for %s: %s", product_name, e)
return jsonify({"status": "error", "message": "Failed to update count"}), 500
finally:
Session.remove()



@app.route("/counts", methods=["GET"])
def get_counts():
session = Session()
Expand All @@ -289,7 +296,7 @@ def get_counts():
logger.info("Fetched counts: %s", counts)
return jsonify(counts)
except Exception as e:
logger.error(f"Error fetching counts: {e}")
logger.error("Error fetching counts: %s", e)
return jsonify({"status": "error", "message": "Failed to fetch counts"}), 500
finally:
Session.remove()
Expand All @@ -303,13 +310,11 @@ def health():
# New Backup & Restore Functionality
###################################


# Display the backup/restore page
@app.route("/backup", methods=["GET"], endpoint="backup_page")
def backup():
return render_template("backup.html")


# Download the current database file
@app.route("/download_db", methods=["GET"])
def download_db():
Expand All @@ -336,6 +341,57 @@ def upload_db():

return redirect(url_for('backup_page'))

###################################
# OpenFoodFacts Integration
###################################

def fetch_product_from_openfoodfacts(barcode: str):
"""Fetch product data from OpenFoodFacts using the barcode."""
url = f"https://world.openfoodfacts.net/api/v0/product/{barcode}.json" # CHANGEME Need to .org for production
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
if data.get('status') == 1:
product_data = data.get('product', {})
# Extract desired fields. Modify as needed.
extracted_data = {
"name": product_data.get('product_name', 'Unknown Product'),
"barcode": barcode,
"category": product_data.get('categories', 'Uncategorized').split(',')[0].strip(),
"image_front_small_url": product_data.get('image_front_small_url', None) # New field
# Add more fields as needed
}
return extracted_data
else:
logger.warning(f"Product with barcode {barcode} not found in OpenFoodFacts.")
return None
except requests.RequestException as e:
logger.error(f"Error fetching product from OpenFoodFacts: {e}")
return None


@app.route("/fetch_product", methods=["GET"])
def fetch_product():
"""Endpoint to fetch product data from OpenFoodFacts using a barcode."""
barcode = request.args.get('barcode')
if not barcode:
logger.warning("Barcode not provided in fetch_product request.")
return jsonify({"status": "error", "message": "Barcode is required"}), 400

product_data = fetch_product_from_openfoodfacts(barcode)
if product_data:
return jsonify({"status": "ok", "product": product_data})
else:
return jsonify({"status": "error", "message": "Product not found or failed to fetch data"}), 404

if __name__ == "__main__":
# For ingress, listen on port 5000
app.run(host="0.0.0.0", port=5000)
# For ingress, listen on port 5000 with SSL
CERT_FILE = '/config/pantry_data/keys/cert.pem'
KEY_FILE = '/config/pantry_data/keys/key.pem'

if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
logger.error("SSL certificates not found. Exiting.")
exit(1)

app.run(host="0.0.0.0", port=5000, ssl_context=(CERT_FILE, KEY_FILE))
2 changes: 2 additions & 0 deletions webapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class Product(Base):
name = Column(String, unique=True, nullable=False)
url = Column(String, nullable=False)
category_id = Column(Integer, ForeignKey('categories.id'), nullable=False)
barcode = Column(String, unique=True, nullable=True) # Existing optional barcode field
image_front_small_url = Column(String, nullable=True) # New optional image URL field

category = relationship("Category", back_populates="products")
count = relationship("Count", back_populates="product", uselist=False, cascade="all, delete-orphan")
Expand Down
2 changes: 1 addition & 1 deletion webapp/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ SQLAlchemy
portalocker
marshmallow
sqlalchemy

requests
2 changes: 2 additions & 0 deletions webapp/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ class ProductSchema(Schema):
name = fields.Str(required=True, validate=validate.Length(min=1))
url = fields.Url(required=True)
category = fields.Str(required=True, validate=validate.Length(min=1))
barcode = fields.Str(required=False, allow_none=True, validate=validate.Length(min=8, max=13)) # Existing optional barcode field
image_front_small_url = fields.Str(required=False, allow_none=True, validate=validate.URL()) # New optional image URL field
Loading

0 comments on commit 4676841

Please sign in to comment.