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"