mirror of
https://github.com/StefBuwalda/cal_counter.git
synced 2025-10-29 19:00:00 +00:00
Add food item management and improve barcode workflow
Implemented routes and forms for adding and viewing food items by barcode, including templates for displaying and entering nutritional information. Enhanced the scan workflow to redirect to food item details or entry form. Added admin ability to delete food items. Improved UI for login and scan pages. Updated FoodItem model and form fields for consistency and accuracy.
This commit is contained in:
66
app.py
66
app.py
@@ -5,10 +5,11 @@ from flask_login import (
|
||||
login_user,
|
||||
current_user,
|
||||
)
|
||||
from forms import LoginForm
|
||||
from forms import LoginForm, FoodItemForm
|
||||
from models import User, FoodItem
|
||||
from application import db, app, login_manager
|
||||
from application.admin.routes import admin_bp
|
||||
from typing import Optional
|
||||
|
||||
# Config
|
||||
app.config["SECRET_KEY"] = "Iman"
|
||||
@@ -28,7 +29,16 @@ app.register_blueprint(admin_bp)
|
||||
# Routes
|
||||
|
||||
|
||||
def default_return(next_page: Optional[str] = None):
|
||||
if next_page:
|
||||
return redirect(next_page)
|
||||
if current_user.is_admin:
|
||||
return redirect(url_for("admin.food_items"))
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
@@ -36,7 +46,7 @@ def index():
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("dashboard"))
|
||||
return default_return()
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
@@ -45,7 +55,7 @@ def login():
|
||||
# User found and password correct
|
||||
next_page = request.args.get("next") # Get next page if given
|
||||
login_user(user) # Log in the user
|
||||
return redirect(next_page or url_for("dashboard"))
|
||||
return default_return(next_page=next_page)
|
||||
else:
|
||||
pass
|
||||
# invalid user
|
||||
@@ -81,6 +91,56 @@ def nutri(barcode):
|
||||
return jsonify({})
|
||||
|
||||
|
||||
@app.route("/food_item/<int:barcode>", methods=["GET"])
|
||||
@login_required
|
||||
def food_item(barcode):
|
||||
food = FoodItem.query.filter_by(barcode=barcode).first()
|
||||
if food:
|
||||
return render_template("food_item.html", item=food)
|
||||
else:
|
||||
return render_template(
|
||||
"add_food_item.html",
|
||||
barcode=barcode,
|
||||
form=FoodItemForm(barcode=barcode),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/add_food_item", methods=["POST"])
|
||||
@login_required
|
||||
def add_food_item():
|
||||
form = FoodItemForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
print("[DEBUG] Valid form")
|
||||
if FoodItem.query.filter_by(barcode=form.barcode.data).first() is None:
|
||||
assert form.name.data is not None
|
||||
assert form.energy.data is not None
|
||||
assert form.protein.data is not None
|
||||
assert form.carbs.data is not None
|
||||
assert form.fat.data is not None
|
||||
assert form.barcode.data is not None
|
||||
db.session.add(
|
||||
FoodItem(
|
||||
name=form.name.data,
|
||||
energy=form.energy.data,
|
||||
protein=form.protein.data,
|
||||
carbs=form.carbs.data,
|
||||
fats=form.fat.data,
|
||||
barcode=form.barcode.data,
|
||||
saturated_fats=form.saturated_fat.data,
|
||||
sugar=form.sugar.data,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
print("[DEBUG] New item added")
|
||||
else:
|
||||
print("[DEBUG] Invalid form")
|
||||
if form.barcode.data:
|
||||
return redirect(url_for("food_item", barcode=form.barcode.data))
|
||||
else:
|
||||
return redirect(url_for("scan"))
|
||||
|
||||
|
||||
# Run
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask import Blueprint, render_template, abort, redirect, url_for
|
||||
from flask_login import current_user
|
||||
from models import FoodItem
|
||||
from application import db
|
||||
|
||||
admin_bp = Blueprint(
|
||||
"admin",
|
||||
@@ -25,3 +26,12 @@ def food_items():
|
||||
@admin_bp.route("/barcode_test", methods=["GET"])
|
||||
def barcode_test():
|
||||
return render_template("barcode_test.html")
|
||||
|
||||
|
||||
@admin_bp.route("/delete_food/<int:id>", methods=["POST"])
|
||||
def delete_food(id):
|
||||
item = FoodItem.query.get(id)
|
||||
if item:
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.food_items"))
|
||||
|
||||
@@ -18,6 +18,7 @@ Food Nutritional Info
|
||||
<th>Sugars (g)</th>
|
||||
<th>Carbs (g)</th>
|
||||
<th>Protein (g)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -30,6 +31,12 @@ Food Nutritional Info
|
||||
<td>{{ food.sugar_100g }}</td>
|
||||
<td>{{ food.carbs_100g }}</td>
|
||||
<td>{{ food.protein_100g }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin.delete_food', id=food.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this item?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
49
application/templates/add_food_item.html
Normal file
49
application/templates/add_food_item.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<form method="POST" action="{{ url_for('add_food_item') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.barcode.label(class="form-label") }}
|
||||
{{ form.barcode(class="form-control", readonly=true, style="background-color: #e9ecef; cursor: not-allowed;") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.energy.label(class="form-label") }}
|
||||
{{ form.energy(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.protein.label(class="form-label") }}
|
||||
{{ form.protein(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.carbs.label(class="form-label") }}
|
||||
{{ form.carbs(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.sugar.label(class="form-label") }}
|
||||
{{ form.sugar(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.fat.label(class="form-label") }}
|
||||
{{ form.fat(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.saturated_fat.label(class="form-label") }}
|
||||
{{ form.saturated_fat(class="form-control") }}
|
||||
</div>
|
||||
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</form>
|
||||
{% endblock%}
|
||||
@@ -10,7 +10,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="bg-light p-4">
|
||||
<body class="bg-light">
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
@@ -20,8 +20,14 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('scan') }}">Scan</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
|
||||
</li>
|
||||
|
||||
44
application/templates/food_item.html
Normal file
44
application/templates/food_item.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-4" style="max-width: 600px;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">{{ item.name }}</h5>
|
||||
<small class="text-muted">Barcode: {{ item.barcode }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-5">Energy per 100g</dt>
|
||||
<dd class="col-sm-7">{{ item.energy_100g }} kcal</dd>
|
||||
|
||||
<dt class="col-sm-5">Protein per 100g</dt>
|
||||
<dd class="col-sm-7">{{ "%.1f"|format(item.protein_100g) }} g</dd>
|
||||
|
||||
<dt class="col-sm-5">Carbohydrates per 100g</dt>
|
||||
<dd class="col-sm-7">{{ "%.1f"|format(item.carbs_100g) }} g</dd>
|
||||
|
||||
<dt class="col-sm-5">Sugar per 100g</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if item.sugar_100g is not none %}
|
||||
{{ "%.1f"|format(item.sugar_100g) }} g
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">Fats per 100g</dt>
|
||||
<dd class="col-sm-7">{{ "%.1f"|format(item.fats_100g) }} g</dd>
|
||||
|
||||
<dt class="col-sm-5">Saturated fats per 100g</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if item.saturated_fats_100g is not none %}
|
||||
{{ "%.1f"|format(item.saturated_fats_100g) }} g
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock%}
|
||||
@@ -1,10 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{{form.hidden_tag()}}
|
||||
{{form.username()}}
|
||||
{{form.password()}}
|
||||
{{form.submit()}}
|
||||
</form>
|
||||
<div class="container d-flex justify-content-center align-items-center">
|
||||
<div class="card shadow-sm p-4" style="width: 100%; max-width: 400px;">
|
||||
<h3 class="mb-4 text-center">Login</h3>
|
||||
<form method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control", placeholder="Enter username") }}
|
||||
{% if form.username.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.username.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Enter password") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.password.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
{{ form.submit(class="btn btn-primary btn-lg") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock%}
|
||||
@@ -1,76 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div id="main" class="container text-center">
|
||||
<div class="container py-5">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="fw-bold">📷 ZXing Barcode Scanner</h1>
|
||||
<p class="text-muted">Use your camera to scan barcodes in real time.</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center mb-4">
|
||||
<video id="video" class="border rounded shadow-sm" style="width: 100%; max-width: 500px;" autoplay
|
||||
muted></video>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<button id="startButton" class="btn btn-primary px-4">Start Scanning</button>
|
||||
<button id="stopButton" class="btn btn-danger px-4 ms-3">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="scan_result">
|
||||
<div>
|
||||
<h5>Result:</h5>
|
||||
<p id="result" class="fw-bold text-success"></p>
|
||||
</div>
|
||||
|
||||
<div id="product-info"></div>
|
||||
</template>
|
||||
|
||||
<template id="template_Reader">
|
||||
<h1 class="mb-4">📷 ZXing Barcode Scanner</h1>
|
||||
|
||||
<div class="mb-3">
|
||||
<video id="video" class="border rounded shadow-sm" width="100%" style="max-width: 500px;"></video>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button id="startButton" class="btn btn-primary">Start Scanning</button>
|
||||
<button id="stopButton" class="btn btn-danger ms-2">Stop</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import { BrowserMultiFormatReader } from 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/+esm';
|
||||
|
||||
const mainElement = document.getElementById('main');
|
||||
const readerTemplate = document.getElementById('template_Reader')
|
||||
const readerClone = readerTemplate.content.cloneNode(true);
|
||||
const resultTemplate = document.getElementById('scan_result')
|
||||
const resultClone = resultTemplate.content.cloneNode(true);
|
||||
mainElement.appendChild(readerClone);
|
||||
|
||||
// constants
|
||||
const codeReader = new BrowserMultiFormatReader();
|
||||
const videoElement = document.getElementById('video');
|
||||
|
||||
|
||||
async function fetchProductData(barcode) {
|
||||
// Step 1: GET Data
|
||||
const response = await fetch(`/nutri/${barcode}`);
|
||||
// Step 2: Check if response wasn't ok
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
// Step 3: Convert response to json and check if empty
|
||||
const nutritionData = await response.json();
|
||||
mainElement.innerHTML = '';
|
||||
mainElement.appendChild(resultClone);
|
||||
const resultElement = document.getElementById('result');
|
||||
resultElement.textContent = barcode;
|
||||
const container = document.getElementById('product-info');
|
||||
if (Object.keys(nutritionData).length === 0) {
|
||||
// No data, enter new data
|
||||
}
|
||||
else {
|
||||
container.innerHTML = `
|
||||
<h2>${nutritionData.name}</h2>
|
||||
<p><strong>Barcode:</strong> ${nutritionData.barcode}</p>
|
||||
<p><strong>Energy:</strong> ${nutritionData.energy_100g} kcal per 100g</p>
|
||||
<p><strong>Carbs:</strong> ${nutritionData.carbs_100g} g</p>
|
||||
<p><strong>Sugar:</strong> ${nutritionData.sugar_100g} g</p>
|
||||
<p><strong>Fats:</strong> ${nutritionData.fats_100g} g</p>
|
||||
<p><strong>Saturated Fats:</strong> ${nutritionData.saturated_fats_100g} g</p>
|
||||
<p><strong>Protein:</strong> ${nutritionData.protein_100g} g</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Start scanning for barcode
|
||||
document.getElementById('startButton').addEventListener('click', async () => {
|
||||
console.log('[DEBUG] Start button clicked')
|
||||
@@ -89,20 +45,14 @@
|
||||
await codeReader.decodeFromVideoDevice(selectedDeviceId, videoElement, async (result, err) => {
|
||||
if (result) {
|
||||
const codeText = result.getText();
|
||||
try {
|
||||
await fetchProductData(codeText)
|
||||
codeReader.reset();
|
||||
} catch (error) {
|
||||
console.error('Error fetching nutrition data:', error);
|
||||
}
|
||||
const baseURL = "{{url_for('food_item', barcode='0')}}"
|
||||
window.location.href = baseURL.replace("0", encodeURIComponent(codeText))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('stopButton').addEventListener('click', () => {
|
||||
codeReader.reset();
|
||||
resultElement.textContent = '';
|
||||
container.innerHTML = ""
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
22
forms.py
22
forms.py
@@ -1,9 +1,27 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired
|
||||
from wtforms import (
|
||||
StringField,
|
||||
PasswordField,
|
||||
SubmitField,
|
||||
IntegerField,
|
||||
FloatField,
|
||||
)
|
||||
from wtforms.validators import DataRequired, InputRequired
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField("Username", validators=[DataRequired()])
|
||||
password = PasswordField("Password", validators=[DataRequired()])
|
||||
submit = SubmitField("Login")
|
||||
|
||||
|
||||
class FoodItemForm(FlaskForm):
|
||||
barcode = IntegerField("Barcode", validators=[InputRequired()])
|
||||
name = StringField("Product Name", validators=[DataRequired()])
|
||||
energy = IntegerField("Energy per 100g", validators=[InputRequired()])
|
||||
protein = FloatField("protein per 100g", validators=[InputRequired()])
|
||||
carbs = FloatField("carbs per 100g", validators=[InputRequired()])
|
||||
sugar = FloatField("sugar per 100g")
|
||||
fat = FloatField("fat per 100g", validators=[InputRequired()])
|
||||
saturated_fat = FloatField("saturated_fat per 100g")
|
||||
submit = SubmitField("Add Item")
|
||||
|
||||
20
models.py
20
models.py
@@ -36,26 +36,26 @@ class Unit(db.Model):
|
||||
class FoodItem(db.Model):
|
||||
__tablename__ = "food_item"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
barcode = db.Column(db.Integer)
|
||||
barcode = db.Column(db.Integer, nullable=False)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||
|
||||
energy_100g = db.Column(db.Integer, nullable=False)
|
||||
protein_100g = db.Column(db.Float, nullable=False)
|
||||
carbs_100g = db.Column(db.Integer, nullable=False)
|
||||
sugar_100g = db.Column(db.Integer)
|
||||
fats_100g = db.Column(db.Integer, nullable=False)
|
||||
saturated_fats_100g = db.Column(db.Integer)
|
||||
carbs_100g = db.Column(db.Float, nullable=False)
|
||||
sugar_100g = db.Column(db.Float)
|
||||
fats_100g = db.Column(db.Float, nullable=False)
|
||||
saturated_fats_100g = db.Column(db.Float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
energy: int,
|
||||
protein: float,
|
||||
carbs: int,
|
||||
fats: int,
|
||||
sugar: Optional[int] = None,
|
||||
saturated_fats: Optional[int] = None,
|
||||
barcode: Optional[int] = None,
|
||||
carbs: float,
|
||||
fats: float,
|
||||
barcode: int,
|
||||
sugar: Optional[float] = None,
|
||||
saturated_fats: Optional[float] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.energy_100g = energy
|
||||
|
||||
Reference in New Issue
Block a user