From d78f48710e91aedbab6f3c028ffc0a2cd8cea5ae Mon Sep 17 00:00:00 2001 From: Stef Date: Thu, 14 Aug 2025 06:08:17 +0200 Subject: [PATCH] Add meal v2 flow and user timezone support Introduces a new add_meal_v2 blueprint with barcode scanning, item search, and improved meal logging UI. Adds user timezone support: login form now captures timezone, User model and database schema updated, and timezone is set on login. Refactors templates and forms to support these changes, and removes the old login template. --- app.py | 2 + application/add_meal_v2/routes.py | 187 ++++++++++++++++++ .../add_meal_v2/templates/add_item.html | 49 +++++ .../add_meal_v2/templates/scan_barcode.html | 134 +++++++++++++ application/add_meal_v2/templates/step4.html | 23 +++ application/auth/routes.py | 12 +- application/auth/templates/login.html | 18 +- application/templates/base.html | 3 + application/templates/login.html | 36 ---- application/user/templates/daily_log2.html | 19 +- application/utils.py | 10 + forms.py | 7 + migrations/versions/9eb23abd5294_.py | 40 ++++ models.py | 6 + 14 files changed, 503 insertions(+), 43 deletions(-) create mode 100644 application/add_meal_v2/routes.py create mode 100644 application/add_meal_v2/templates/add_item.html create mode 100644 application/add_meal_v2/templates/scan_barcode.html create mode 100644 application/add_meal_v2/templates/step4.html delete mode 100644 application/templates/login.html create mode 100644 migrations/versions/9eb23abd5294_.py diff --git a/app.py b/app.py index b937434..8342d29 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ from application.admin.routes import admin_bp from application.user.routes import user_bp from application.add_meal.routes import bp as add_meal_bp from application.auth.routes import bp as auth_bp +from application.add_meal_v2.routes import bp as add_meal_v2_bp from typing import Optional # Config @@ -31,6 +32,7 @@ app.register_blueprint(admin_bp) app.register_blueprint(user_bp) app.register_blueprint(add_meal_bp) app.register_blueprint(auth_bp) +app.register_blueprint(add_meal_v2_bp) # Routes diff --git a/application/add_meal_v2/routes.py b/application/add_meal_v2/routes.py new file mode 100644 index 0000000..8f10cd9 --- /dev/null +++ b/application/add_meal_v2/routes.py @@ -0,0 +1,187 @@ +from flask import ( + Blueprint, + redirect, + url_for, + render_template, + session, + request, + jsonify, + abort, +) +from flask_login import current_user +from forms import FoodItemForm, FoodLogForm +from application import db +from models import FoodItem, FoodLog +from sqlalchemy import and_, or_ +from datetime import datetime, timedelta, timezone +from sqlalchemy.sql.elements import BinaryExpression +from typing import cast + +bp = Blueprint( + "add_meal_v2", + __name__, + url_prefix="/add_meal_v2", + template_folder="templates", +) + + +@bp.before_request +def login_required(): + if not current_user.is_authenticated: + return redirect(url_for("auth.login")) + + +@bp.route("/select_meal/", methods=["GET"]) +def step1(meal_type: int): + assert type(meal_type) is int + assert 0 <= meal_type <= 3 + session["meal_type"] = meal_type + return redirect(url_for("add_meal.step2")) + + +@bp.route("/get_barcode", methods=["GET"]) +def step2(): + return render_template("scan_barcode.html") + + +@bp.route("/step3/", methods=["GET"]) +def step3(input: str): + # check if meal_type cookie is set + if "meal_type" not in session: + return redirect("/") + + # Check if input is a barcode + if input.isdigit(): + item = current_user.food_items.filter_by(barcode=input).first() + + else: + item = current_user.food_items.filter_by(name=input).first() + + if item is None: + # Does not exist, add item + return redirect(url_for("add_meal.step3_alt1", input=input)) + + # Track item to add and continue to next step + session["item_id"] = item.id + return redirect(url_for("add_meal.step4")) + + +@bp.route("/step3_alt1/", methods=["GET"]) +def step3_alt1(input: str): + form = FoodItemForm() + + if input.isdigit(): + form.barcode.data = input + else: + form.name.data = input + return render_template("add_item.html", form=form) + + +@bp.route("/step3_alt1/", methods=["POST"]) +def step3_alt1_post(input: str): + form = FoodItemForm() + + if form.validate_on_submit(): + # Form has valid input + barcode = form.barcode.data + name = form.name.data + assert name + 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 + + # Check if name or barcode already exists + name_filter = cast(BinaryExpression, FoodItem.name == name) + barcode_filter = cast(BinaryExpression, FoodItem.barcode == barcode) + filter_exp = or_(name_filter, barcode_filter) + item = current_user.food_items.filter(filter_exp).first() + + if item is None: + # Item does not exist, add to DB + barcode = ( + barcode if barcode else None + ) # Turn empty strings into None + db.session.add( + FoodItem( + name=name, + owner_id=current_user.id, + energy=form.energy.data, + protein=form.protein.data, + carbs=form.carbs.data, + fat=form.fat.data, + barcode=barcode, + saturated_fat=form.saturated_fat.data, + sugar=form.sugar.data, + ) + ) + db.session.commit() + print("[DEBUG] New FoodItem Added") + input = barcode if barcode else name # update input + else: + print(f"Item exists: {item.barcode} {item.name}") + + # Item added or already present, return to step 3. + return redirect(url_for("add_meal.step3", input=input)) + else: + print("[DEBUG] Form Invalid") + return redirect(url_for("add_meal.step3_alt1", input=input)) + + +@bp.route("/step4", methods=["GET", "POST"]) +def step4(): + if "item_id" not in session: + return redirect(url_for("add_meal.step2")) + form = FoodLogForm() + item = db.session.get(FoodItem, session["item_id"]) + + offset = session["offset"] + if offset is None or item is None: + abort(404) + + today = datetime.now(timezone.utc).date() + day = today + timedelta(days=offset) + + if form.validate_on_submit(): + assert form.amount.data + db.session.add( + FoodLog( + food_item_id=item.id, + user_id=current_user.id, + amount=form.amount.data, + part_of_day=session["meal_type"], + date_=day, + ) + ) + db.session.commit() + session.pop("meal_type") + session.pop("item_id") + return redirect(url_for("user.daily_log", offset=offset)) + + match session["meal_type"]: + case 0: + tod = "Breakfast" + case 1: + tod = "Lunch" + case 2: + tod = "Dinner" + case 3: + tod = "Snack" + case _: + tod = "Unknown" + return render_template("step4.html", tod=tod, item=item, form=form) + + +@bp.route("/query", methods=["GET"]) +def query(): + q = request.args.get("q", "").strip().lower() + if not q: + return jsonify([]) + + words = q.split() + filters = [ + FoodItem.name.ilike(f"%{word}%") for word in words # type: ignore + ] + + results = current_user.food_items.filter(and_(*filters)).all() + return jsonify([item.name for item in results]) diff --git a/application/add_meal_v2/templates/add_item.html b/application/add_meal_v2/templates/add_item.html new file mode 100644 index 0000000..f44b33d --- /dev/null +++ b/application/add_meal_v2/templates/add_item.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block content %} +
+ {{ form.hidden_tag() }} + +
+ {{ form.barcode.label(class="form-label") }} + {{ form.barcode(class="form-control-plaintext", readonly=true) }} +
+ +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} +
+ +
+ {{ form.energy.label(class="form-label") }} + {{ form.energy(class="form-control") }} +
+ +
+ {{ form.fat.label(class="form-label") }} + {{ form.fat(class="form-control") }} +
+ +
+ {{ form.saturated_fat.label(class="form-label") }} + {{ form.saturated_fat(class="form-control") }} +
+ +
+ {{ form.carbs.label(class="form-label") }} + {{ form.carbs(class="form-control") }} +
+ +
+ {{ form.sugar.label(class="form-label") }} + {{ form.sugar(class="form-control") }} +
+ +
+ {{ form.protein.label(class="form-label") }} + {{ form.protein(class="form-control") }} +
+ + {{ form.submit(class="btn btn-primary") }} +
+{% endblock%} \ No newline at end of file diff --git a/application/add_meal_v2/templates/scan_barcode.html b/application/add_meal_v2/templates/scan_barcode.html new file mode 100644 index 0000000..1169b99 --- /dev/null +++ b/application/add_meal_v2/templates/scan_barcode.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

Barcode Scanner

+

Use your camera to scan barcodes

+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ +
+ + +
+ + +
    +
    +
    + + + +
    +
      +
    +
    +
    + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/application/add_meal_v2/templates/step4.html b/application/add_meal_v2/templates/step4.html new file mode 100644 index 0000000..85cd332 --- /dev/null +++ b/application/add_meal_v2/templates/step4.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block content %} +

    {{ tod }}

    +

    {{ item.name }}

    + +
    + {{ form.hidden_tag() }} + +
    +
    + {{item_id}} +
    +
    + +
    + {{ form.amount.label(class="form-label") }} + {{ form.amount(class="form-control") }} +
    + + {{ form.submit(class="btn btn-primary") }} +
    +{% endblock%} \ No newline at end of file diff --git a/application/auth/routes.py b/application/auth/routes.py index 6d6f4b2..b14c3f9 100644 --- a/application/auth/routes.py +++ b/application/auth/routes.py @@ -1,8 +1,8 @@ -from flask import Blueprint, request, render_template, redirect, url_for +from flask import Blueprint, render_template, redirect, url_for from flask_login import current_user, login_user, logout_user from forms import LoginForm, ChangePasswordForm from models import User -from application.utils import default_return +from application.utils import default_return, is_valid_timezone from application import db bp = Blueprint( @@ -19,12 +19,16 @@ def login(): form = LoginForm() if form.validate_on_submit(): + assert form.timezone.data user = User.query.filter_by(username=form.username.data).first() if user and user.check_password(password=form.password.data): # User found and password correct - next_page = request.args.get("next") # Get next page if given + tz = form.timezone.data + if is_valid_timezone(tz): + user.set_timezone(tz) + db.session.commit() login_user(user) # Log in the user - return default_return(next_page=next_page) + return default_return() else: pass # invalid user diff --git a/application/auth/templates/login.html b/application/auth/templates/login.html index 69597be..e33c8db 100644 --- a/application/auth/templates/login.html +++ b/application/auth/templates/login.html @@ -3,7 +3,10 @@ {% block content %}
    -

    Login

    +

    Login

    +

    + Your timezone will be saved to show times correctly. +

    {{ form.hidden_tag() }} @@ -27,10 +30,21 @@ {% endif %}
    + {{ form.timezone(id="timezone") }} +
    {{ form.submit(class="btn btn-primary btn-lg") }}
    -{% endblock%} \ No newline at end of file +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/application/templates/base.html b/application/templates/base.html index a6e2fd3..7d14a2d 100644 --- a/application/templates/base.html +++ b/application/templates/base.html @@ -83,6 +83,9 @@ + + {% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/application/templates/login.html b/application/templates/login.html deleted file mode 100644 index e65d635..0000000 --- a/application/templates/login.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
    -
    -

    Login

    -
    - {{ form.hidden_tag() }} - -
    - {{ form.username.label(class="form-label") }} - {{ form.username(class="form-control", placeholder="Enter username") }} - {% if form.username.errors %} -
    - {{ form.username.errors[0] }} -
    - {% endif %} -
    - -
    - {{ form.password.label(class="form-label") }} - {{ form.password(class="form-control", placeholder="Enter password") }} - {% if form.password.errors %} -
    - {{ form.password.errors[0] }} -
    - {% endif %} -
    - -
    - {{ form.submit(class="btn btn-primary btn-lg") }} -
    -
    -
    -
    -{% endblock%} \ No newline at end of file diff --git a/application/user/templates/daily_log2.html b/application/user/templates/daily_log2.html index 6e72d98..fed4194 100644 --- a/application/user/templates/daily_log2.html +++ b/application/user/templates/daily_log2.html @@ -52,7 +52,7 @@ - + Add Item @@ -64,4 +64,21 @@ +{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/application/utils.py b/application/utils.py index 6631122..74f14cb 100644 --- a/application/utils.py +++ b/application/utils.py @@ -1,6 +1,7 @@ from flask_login import current_user from flask import redirect, url_for, flash from typing import Optional +from zoneinfo import ZoneInfo def login_required(): @@ -19,3 +20,12 @@ def default_return(next_page: Optional[str] = None): if current_user.is_admin: return redirect(url_for("admin.food_items")) return redirect(url_for("dashboard")) + + +def is_valid_timezone(tz: str) -> bool: + try: + ZoneInfo(tz) + except Exception: + print(Exception) + return False + return True diff --git a/forms.py b/forms.py index 0497b9e..ea3ab1d 100644 --- a/forms.py +++ b/forms.py @@ -4,13 +4,20 @@ from wtforms import ( PasswordField, SubmitField, FloatField, + HiddenField, ) from wtforms.validators import DataRequired, InputRequired, Optional +class SelectDateForm(FlaskForm): + date = HiddenField(validators=[DataRequired()]) + submit = SubmitField("+ Add Item") + + class LoginForm(FlaskForm): username = StringField("Username", validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()]) + timezone = HiddenField("Timezone", validators=[DataRequired()]) submit = SubmitField("Log in") diff --git a/migrations/versions/9eb23abd5294_.py b/migrations/versions/9eb23abd5294_.py new file mode 100644 index 0000000..34381ae --- /dev/null +++ b/migrations/versions/9eb23abd5294_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 9eb23abd5294 +Revises: 101002a6ef17 +Create Date: 2025-08-14 05:40:27.342711 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "9eb23abd5294" +down_revision = "101002a6ef17" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "timezone", + sa.String(length=64), + nullable=False, + server_default="UTC", + ) + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("timezone") + + # ### end Alembic commands ### diff --git a/models.py b/models.py index 32d27d9..667b01d 100644 --- a/models.py +++ b/models.py @@ -4,6 +4,7 @@ from application import db from typing import Optional from forms import FoodItemForm from datetime import datetime, timezone, date +from application.utils import is_valid_timezone class User(UserMixin, db.Model): @@ -11,6 +12,7 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(150), unique=True, nullable=False) password = db.Column(db.String, nullable=False) + timezone = db.Column(db.String(64), nullable=False, default="UTC") is_admin = db.Column(db.Boolean, nullable=False, default=False) must_change_password = db.Column(db.Boolean, nullable=False, default=False) @@ -39,6 +41,10 @@ class User(UserMixin, db.Model): def set_pw_change(self, change: bool) -> None: self.must_change_password = change + def set_timezone(self, tz: str) -> None: + if is_valid_timezone(tz): + self.timezone = tz + class Unit(db.Model): __tablename__ = "unit"