42 Commits

Author SHA1 Message Date
24a1757166 Update requirements.txt 2025-08-21 16:55:52 +02:00
5373d373ca Update find_item.html 2025-08-14 17:40:27 +02:00
Stef
dca3ff7efe Merge pull request #8 from StefBuwalda/development
Update find_item.html
2025-08-14 17:38:25 +02:00
7cedd8f74d Update find_item.html 2025-08-14 17:38:10 +02:00
Stef
faff1a60b8 Merge pull request #7 from StefBuwalda/development
Refactor add meal flow and remove v2 routes
2025-08-14 15:50:37 +02:00
73985b9b6d Refactor add meal flow and remove v2 routes
Migrated add_meal_v2 routes and templates to add_meal, renaming endpoints and updating references throughout the app. Removed legacy daily_log2 route and template, consolidating to daily_log. Moved macro_arr_to_json to utils.py for reuse. Updated navigation and redirect logic to use new routes. Improved item finding and barcode scanning UI.
2025-08-14 15:50:17 +02:00
Stef
7b84ab980e Merge pull request #6 from StefBuwalda/development
Hotfix
2025-08-14 12:52:56 +02:00
4aca63671b Update scan_barcode_v2.html 2025-08-14 12:52:38 +02:00
bd0468a600 Merge branch 'main' into development 2025-08-14 12:47:16 +02:00
c1f46357f0 Update daily_log2.html 2025-08-14 12:47:08 +02:00
Stef
1680383d47 Merge pull request #5 from StefBuwalda/development
Switch to new daily log as main log
2025-08-14 12:46:41 +02:00
4c76496920 Content aware class fix 2025-08-14 12:46:12 +02:00
c3e9c8631d Remove part of day from remaining code 2025-08-14 12:32:00 +02:00
f7f6d23562 Removed old log for new log 2025-08-14 12:29:02 +02:00
4f1b5a5667 Refactor meal addition flow and improve date handling
Refactored add_meal_v2 routes to simplify and clarify the meal addition process, including renaming endpoints and templates, and introducing decorators for date and item selection. Updated daily_log2 to use user's timezone and display the selected date. Adjusted templates and barcode scan logic to match new routes and improved user experience.
2025-08-14 06:59:29 +02:00
d78f48710e 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.
2025-08-14 06:08:17 +02:00
7ff345d3a2 Do not update pip 2025-08-14 04:07:15 +02:00
Stef
944ba17b31 Merge pull request #4 from StefBuwalda/development
Removed accidental packages from requirements.txt
2025-08-14 04:05:24 +02:00
8820fc07d7 Removed accidental packages from requirements.txt 2025-08-14 04:04:48 +02:00
Stef
072dd2c651 Merge pull request #3 from StefBuwalda/development
Update requirements.txt
2025-08-14 03:46:54 +02:00
8acb8453ea Update requirements.txt 2025-08-14 03:46:26 +02:00
Stef
fb160d364a Merge pull request #2 from StefBuwalda/development
Hopefully add caching to docker build action
2025-08-14 03:42:12 +02:00
fdf264b9c5 Update docker-ghcr.yml 2025-08-14 03:41:34 +02:00
91595ccc11 Caching pip downloads (hopefully) 2025-08-14 03:39:39 +02:00
6c01c6a923 Update Dockerfile 2025-08-14 03:34:31 +02:00
Stef
5a9e1f77c7 Merge pull request #1 from StefBuwalda/development
Adds a new daily log page made with AI assistance.
2025-08-14 03:18:58 +02:00
2454bc61cb Moved the bar width calculation from jinja to the dashboard values generation 2025-08-14 03:15:02 +02:00
85297daaaf Add new daily log dashboard and macro summary
Introduces a new route and template for an enhanced daily log dashboard with macro nutrient summary. Refactors FoodItem model to add type annotations and a macros() method, and updates form handling to default missing values to zero. Also adds a navigation link to the new dashboard in the base template.
2025-08-14 02:49:06 +02:00
c7395b07d9 Move logout route to auth blueprint
The logout route was relocated from the main app to the auth blueprint for better organization. The logout link in the base template was updated to reference the new route location.
2025-08-11 18:05:30 +02:00
97ff4acf02 Add change password functionality for users
Introduces a change password route, form, and template, allowing authenticated users to update their password. Updates the User model with a method to set the must_change_password flag. Adjusts login and navigation logic to support the new flow and ensures users are redirected to change their password if required.
2025-08-11 18:03:18 +02:00
0da580faf1 Refactor login flow to use auth blueprint
Moved login route and logic from app.py to application/auth/routes.py under the 'auth' blueprint. Updated all references to the login route to use 'auth.login'. Added a dedicated login.html template under application/auth/templates. Adjusted login_required utility and default_return logic for consistency.
2025-08-11 17:43:46 +02:00
ea2ea27d9e Refactor login_required and add auth blueprint
Moved the login_required logic to a new utils.py for reuse. Added a new auth blueprint and registered it in app.py. Updated user blueprint to use the shared login_required function.
2025-08-11 17:33:47 +02:00
cd9ae72864 Reapply "Add must_change_password field to User model"
This reverts commit 47241e341e.
2025-08-11 17:20:45 +02:00
47241e341e Revert "Add must_change_password field to User model"
This reverts commit 42747df92f.
2025-08-11 17:17:30 +02:00
42747df92f Add must_change_password field to User model
Introduces a new boolean column 'must_change_password' to the User model and database schema. This field enforces password change requirements for users and is included in the model's constructor and migration.
2025-08-11 17:17:10 +02:00
fda5f8e17b Update daily_log.html
Added a confirm button
2025-08-11 16:56:51 +02:00
18fa0fdb2d Remove admin food item and barcode test routes and templates
Deleted the /food_items and /barcode_test routes from admin, along with their associated templates and the delete_food functionality. This streamlines the admin blueprint by removing unused or deprecated features.
2025-08-11 16:56:40 +02:00
4d34049850 Change energy_100 column type to Float in food_item
This migration alters the 'energy_100' column in the 'food_item' table from INTEGER to Float to allow for decimal values. The downgrade reverses this change.
2025-08-11 16:52:57 +02:00
3f9bd8984d Change energy field from integer to float
Updated the FoodItem model and FoodItemForm to use float for the energy field instead of integer.
2025-08-11 16:51:31 +02:00
5a0dbef28f Update forms.py
added input mode to form to indicate a decimal value is expected for each value
2025-08-11 16:50:19 +02:00
93406db07e Update add_item.html
Changed the order of macros when adding new item
2025-08-11 16:43:00 +02:00
72fe1b602b Update routes.py
Fixed a bug where redirect was incorrect after adding an item
2025-08-11 16:36:51 +02:00
26 changed files with 749 additions and 644 deletions

View File

@@ -14,9 +14,11 @@ jobs:
runs-on: ubuntu-latest
steps:
# Checkout code
- name: Checkout code
uses: actions/checkout@v4.2.2
# Log in to GHCR
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.5.0
with:
@@ -24,13 +26,22 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Set up Docker Buildx (needed for ARM64 cross-build)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1
# Build and push Docker image with caching
- name: Build and push ARM64 image
uses: docker/build-push-action@v6.18.0
with:
context: .
push: true
platforms: linux/arm64
tags: ghcr.io/stefbuwalda/cal_counter:arm64,ghcr.io/stefbuwalda/cal_counter:latest
tags: |
ghcr.io/stefbuwalda/cal_counter:arm64
ghcr.io/stefbuwalda/cal_counter:latest
cache-from: type=registry,ref=ghcr.io/stefbuwalda/cal_counter:cache
cache-to: type=registry,ref=ghcr.io/stefbuwalda/cal_counter:cache,mode=max
build-args: |
PIP_NO_CACHE_DIR=1

View File

@@ -2,8 +2,13 @@ FROM python:3.12-slim
# Everything will be done in /app (Not in the main OS Image)
WORKDIR /app
COPY . .
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x ./entrypoint.sh
ENV FLASK_APP=app.py

42
app.py
View File

@@ -1,28 +1,24 @@
from flask import (
render_template,
redirect,
url_for,
request,
send_from_directory,
)
from flask_login import (
login_required,
logout_user,
login_user,
current_user,
)
from forms import LoginForm
from models import User
from application import db, app, login_manager
from application.admin.routes import admin_bp
from application.user.routes import user_bp
from application.auth.routes import bp as auth_bp
from application.add_meal.routes import bp as add_meal_bp
from typing import Optional
# Config
app.config["SECRET_KEY"] = "Stef123"
login_manager.login_view = "login" # type: ignore
login_manager.login_view = "auth.login" # type: ignore
@login_manager.user_loader # type: ignore
@@ -33,9 +29,15 @@ def load_user(user_id: int):
# Register blueprints
app.register_blueprint(admin_bp)
app.register_blueprint(user_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(add_meal_bp)
# @app.errorhandler(404)
# def page_not_found(e):
# return redirect("/")
# Routes
def default_return(next_page: Optional[str] = None):
return redirect(url_for("user.daily_log"))
@@ -49,7 +51,7 @@ def default_return(next_page: Optional[str] = None):
@app.route("/")
@login_required
def index():
return redirect(url_for("login"))
return redirect(url_for("auth.login"))
@app.route("/favicon.ico")
@@ -57,32 +59,6 @@ def favicon():
return send_from_directory("static", "favicon.ico")
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return default_return()
form = LoginForm()
if form.validate_on_submit():
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
login_user(user) # Log in the user
return default_return(next_page=next_page)
else:
pass
# invalid user
return render_template("login.html", form=form)
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("index"))
# Run
if __name__ == "__main__":
# If there are no users, create admin account

View File

@@ -6,14 +6,13 @@ from flask import (
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 datetime import datetime
from sqlalchemy.sql.elements import BinaryExpression
from typing import cast
@@ -25,31 +24,34 @@ bp = Blueprint(
)
def date_present(func):
def check_date():
if "selected_date" not in session:
return redirect(url_for("user.daily_log"))
def item_selected(func):
def check_item():
if check_item():
if "item_id" not in session:
return redirect(url_for("add_meal.find_item"))
@bp.before_request
def login_required():
if not current_user.is_authenticated:
return redirect(url_for("login"))
return redirect(url_for("auth.login"))
@bp.route("/select_meal/<int:meal_type>", 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"))
@date_present
@bp.route("/find_item", methods=["GET"])
def find_item():
return render_template("find_item.html")
@bp.route("/get_barcode", methods=["GET"])
def step2():
return render_template("scan_barcode.html")
@bp.route("/step3/<string:input>", methods=["GET"])
def step3(input: str):
# check if meal_type cookie is set
if "meal_type" not in session:
return redirect("/")
@date_present
@bp.route("/select_item/<string:input>", methods=["GET"])
def select_item(input: str):
# Check if input is a barcode
if input.isdigit():
item = current_user.food_items.filter_by(barcode=input).first()
@@ -59,26 +61,28 @@ def step3(input: str):
if item is None:
# Does not exist, add item
return redirect(url_for("add_meal.step3_alt1", input=input))
return redirect(url_for("add_meal.add_new_item", 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/<string:input>", methods=["GET"])
def step3_alt1(input: str):
@date_present
@bp.route("/add_new_item/<string:input>", methods=["GET"])
def add_new_item(input: str):
form = FoodItemForm()
if input.isdigit():
form.barcode.data = input
else:
form.name.data = input
return render_template("add_item.html", form=form)
return render_template("add_new_item.html", form=form)
@bp.route("/step3_alt1/<string:input>", methods=["POST"])
def step3_alt1_post(input: str):
@date_present
@bp.route("/add_new_item/<string:input>", methods=["POST"])
def post_new_item(input: str):
form = FoodItemForm()
if form.validate_on_submit():
@@ -117,29 +121,25 @@ def step3_alt1_post(input: str):
)
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))
return redirect(url_for("add_meal.select_item", input=input))
else:
print("[DEBUG] Form Invalid")
return redirect(url_for("add_meal.step3_alt1", input=input))
return redirect(url_for("add_meal.add_new_item", input=input))
@date_present
@item_selected
@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 not item:
return redirect(url_for("add_meal.find_item"))
if form.validate_on_submit():
assert form.amount.data
@@ -148,29 +148,20 @@ def step4():
food_item_id=item.id,
user_id=current_user.id,
amount=form.amount.data,
part_of_day=session["meal_type"],
date_=day,
date_=datetime.strptime(
session["selected_date"], "%Y-%m-%d"
).date(),
)
)
db.session.commit()
session.pop("meal_type")
session.pop("item_id")
return redirect(url_for("user.daily_log", offset=offset))
session.pop("selected_date")
return redirect(url_for("user.daily_log"))
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)
return render_template("step4.html", item=item, form=form)
@date_present
@bp.route("/query", methods=["GET"])
def query():
q = request.args.get("q", "").strip().lower()

View File

@@ -20,8 +20,13 @@
</div>
<div class="mb-3">
{{ form.protein.label(class="form-label") }}
{{ form.protein(class="form-control") }}
{{ 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>
<div class="mb-3">
@@ -35,13 +40,8 @@
</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") }}
{{ form.protein.label(class="form-label") }}
{{ form.protein(class="form-control") }}
</div>
{{ form.submit(class="btn btn-primary") }}

View File

@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block content %}
<div class="container d-flex flex-column justify-content-start align-items-center py-4" style="min-height: 100vh;">
<div class="card shadow-sm w-100">
<div class="card-body d-flex flex-column">
<h5 class="card-title text-center mb-4">Item Scanner</h5>
<video id="camera" autoplay class="w-100 mb-3" muted></video>
<div class="mb-3">
<label for="manualSearch" class="form-label">Or search manually</label>
<input type="text" class="form-control" id="manualSearch" placeholder="Enter item name">
</div>
<ul class="list-group mb-3" id="searchResults"></ul>
<button class="btn btn-primary w-100" id="createItemBtn">Create New Item</button>
</div>
</div>
</div>
{% endblock%}
{% block scripts %}
<script>
const baseURL = "{{url_for('add_meal.select_item', input='!')}}";
const baseURL2 = "{{url_for('add_meal.add_new_item', input='!')}}";
</script>
<script type="module">
// Import barcode scanner
import { BrowserMultiFormatReader } from 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/+esm';
// constants
const codeReader = new BrowserMultiFormatReader();
const videoElement = document.getElementById('camera');
// Start async camera thingymibob
document.addEventListener('DOMContentLoaded', async () => {
console.log('[DEBUG] Page loaded, starting barcode scan');
try {
await navigator.mediaDevices.getUserMedia({ video: true });
} catch (err) {
alert("No camera found or no camera permission");
console.error("Could not access the camera:", err);
return;
}
console.log('[DEBUG] Permission given and at least one device present');
const devices = await codeReader.listVideoInputDevices();
console.log('[DEBUG] Cameras found:', devices);
const rearCamera = devices.find(device => device.label.toLowerCase().includes('back'))
|| devices.find(device => device.label.toLowerCase().includes('rear'))
|| devices[0]; // fallback
const selectedDeviceId = rearCamera?.deviceId;
await codeReader.decodeFromVideoDevice(selectedDeviceId, videoElement, async (result, err) => {
if (result) {
// Result found, redirect to the URL
console.log(result)
const codeText = result.getText();
window.location.href = baseURL.replace("!", encodeURIComponent(codeText));
}
});
});
</script>
<script>
const searchInput = document.getElementById('manualSearch');
const resultsList = document.getElementById('searchResults');
const createBtn = document.getElementById('createItemBtn');
let controller; // keeps track of the current fetch
// TODO: Debounce input, wait for user to stop typing (f.e. 300ms)
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase();
// Check if there is enough input to fetch
if (query.length < 2) {
resultsList.innerHTML = '';
return;
}
if (controller) controller.abort(); // Abort previous fetch if still running
controller = new AbortController(); // new controller for this fetch
const signal = controller.signal; // Signal to fetch to listen for aborts
fetch(`{{url_for("add_meal.query")}}?q=${encodeURIComponent(query)}`, { signal })
.then(response => response.json())
.then(data => {
resultsList.innerHTML = ''; // clear before appending
data.forEach(item => {
// Create list item with button inside that changes the hidden form and submits it
const li = document.createElement('li');
li.className = 'list-group-item p-0';
const btn = document.createElement('button');
btn.className = 'list-group-item list-group-item-action m-0 border-0';
btn.style.width = '100%'; // make it fill the li
btn.textContent = item;
btn.addEventListener('click', () => {
window.location.href = baseURL.replace("!", encodeURIComponent(item));
});
li.appendChild(btn);
resultsList.appendChild(li);
});
}).catch(err => {
if (err.name == 'AbortError') {
console.log("Fetch aborted");
}
if (err.name !== 'AbortError') {
console.error('Fetch error:', err);
}
});
});
</script>
<script>
createBtn.addEventListener('click', () => {
const newItem = searchInput.value.trim()
window.location.href = newItem ? baseURL2.replace("!", encodeURIComponent(newItem)) : baseURL2;
});
</script>
{% endblock %}

View File

@@ -1,134 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-5">
<!-- Header -->
<div class="text-center mb-4">
<h1 class="fw-bold">Barcode Scanner</h1>
<p class="text-muted">Use your camera to scan barcodes</p>
</div>
<!-- Video preview -->
<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>
<!-- Start/Stop buttons -->
<div class="d-flex justify-content-center gap-3 mb-4">
<button id="startButton" class="btn btn-primary px-4">Start Scanning</button>
<button id="stopButton" class="btn btn-danger px-4">Stop</button>
</div>
<!-- Search box and suggestions -->
<div class="d-flex justify-content-center mt-4">
<div class="w-100 position-relative" style="max-width: 500px;">
<!-- Input group (search + go button) -->
<div class="input-group">
<input type="text" id="search-box" class="form-control" placeholder="Search..." autocomplete="off">
<button id="go-button" class="btn btn-success">Go</button>
</div>
<!-- Suggestions -->
<ul id="suggestions" class="list-group position-absolute w-100 mt-1" style="z-index: 1000;"></ul>
</div>
</div>
<!-- Suggestions list -->
<div class="d-flex justify-content-center">
<ul id="suggestions" class="list-group position-absolute mt-1 w-100" style="max-width: 500px; z-index: 1000;">
</ul>
</div>
</div>
<script>
const searchBox = document.getElementById('search-box');
const suggestionsBox = document.getElementById('suggestions');
const goButton = document.getElementById('go-button');
searchBox.addEventListener('input', function () {
const query = searchBox.value;
if (query.length < 2) {
suggestionsBox.innerHTML = '';
return;
}
fetch(`{{url_for("add_meal.query")}}?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsBox.innerHTML = '';
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
// Apply Bootstrap classes
li.classList.add('list-group-item', 'list-group-item-action', 'cursor-pointer');
// Add click behavior
li.addEventListener('click', () => {
searchBox.value = item;
suggestionsBox.innerHTML = '';
});
// Add to suggestions box
suggestionsBox.appendChild(li);
});
});
});
// ✅ Redirect when the "Go" button is clicked
goButton.addEventListener('click', () => {
const value = searchBox.value.trim();
if (value) {
const baseURL = "{{url_for('add_meal.step3', input='!')}}";
window.location.href = baseURL.replace("!", encodeURIComponent(value));
}
});
</script>
<script type="module">
import { BrowserMultiFormatReader } from 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/+esm';
// constants
const codeReader = new BrowserMultiFormatReader();
const videoElement = document.getElementById('video');
// Start scanning for barcode
document.getElementById('startButton').addEventListener('click', async () => {
console.log('[DEBUG] Start button clicked')
try {
await navigator.mediaDevices.getUserMedia({ video: true });
} catch (err) {
alert("No camera found or no camera permission");
console.error("Could not access the camera:", err);
return;
}
console.log('[DEBUG] Permission given and at least one device present');
const devices = await codeReader.listVideoInputDevices();
console.log('[DEBUG] Cameras found:', devices);
const rearCamera = devices.find(device => device.label.toLowerCase().includes('back'))
|| devices.find(device => device.label.toLowerCase().includes('rear'))
|| devices[0]; // fallback
const selectedDeviceId = rearCamera?.deviceId;
await codeReader.decodeFromVideoDevice(selectedDeviceId, videoElement, async (result, err) => {
if (result) {
// Result found, this should post the barcode
const codeText = result.getText();
const baseURL = "{{url_for('add_meal.step3', input='!')}}";
window.location.href = baseURL.replace("!", encodeURIComponent(codeText));
}
})
})
document.getElementById('stopButton').addEventListener('click', () => {
codeReader.reset();
});
</script>
{% endblock %}

View File

@@ -1,7 +1,6 @@
{% extends "base.html" %}
{% block content %}
<p>{{ tod }}</p>
<p>{{ item.name }}</p>
<form method="POST">

View File

@@ -1,7 +1,5 @@
from flask import Blueprint, render_template, abort, redirect, url_for
from flask import Blueprint, abort
from flask_login import current_user
from models import FoodItem
from application import db
admin_bp = Blueprint(
"admin",
@@ -15,24 +13,3 @@ admin_bp = Blueprint(
def admin_required():
if not current_user.is_admin:
abort(403)
@admin_bp.route("/food_items", methods=["GET"])
def food_items():
items = FoodItem.query.all()
return render_template("food_items.html", items=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:
if item.owner_id == current_user.id:
db.session.delete(item)
db.session.commit()
return redirect(url_for("admin.food_items"))

View File

@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block title %}
ZXing Barcode Scanner
{% endblock %}
{% block content %}
<div class="container text-center">
<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>
<div>
<h5>Result:</h5>
<p id="result" class="fw-bold text-success"></p>
</div>
</div>
<script type="module">
import { BrowserMultiFormatReader } from 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/+esm';
const codeReader = new BrowserMultiFormatReader();
const videoElement = document.getElementById('video');
const resultElement = document.getElementById('result');
document.getElementById('startButton').addEventListener('click', async () => {
await navigator.mediaDevices.getUserMedia({ video: true });
console.log('[DEBUG] Start button clicked');
const devices = await codeReader.listVideoInputDevices();
console.log('[DEBUG] Cameras found:', devices);
const selectedDeviceId = devices[0]?.deviceId;
if (!selectedDeviceId) {
alert('No camera found!');
return;
}
codeReader.decodeFromVideoDevice(selectedDeviceId, videoElement, (result, err, controls) => {
if (result) {
resultElement.textContent = result.getText();
controls.stop();
}
});
});
document.getElementById('stopButton').addEventListener('click', () => {
codeReader.reset();
resultElement.textContent = '';
});
</script>
{% endblock %}

View File

@@ -1,47 +0,0 @@
{% extends "base.html" %}
{% block title %}
Food Nutritional Info
{% endblock %}
{% block content %}
<div class="container mt-5">
<h1 class="mb-4">Food Nutritional Information (per 100g/100ml)</h1>
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>Energy (kcal)</th>
<th>fat (g)</th>
<th>Saturated fat (g)</th>
<th>Sugars (g)</th>
<th>Carbs (g)</th>
<th>Protein (g)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for food in items %}
<tr>
<td class="bg-body-tertiary">{{ food.name }}</td>
<td class="bg-body-tertiary">{{ food.energy_100 }}</td>
<td class="bg-body-tertiary">{{ food.fat_100 }}</td>
<td class="bg-body-tertiary">{{ food.saturated_fat_100 }}</td>
<td class="bg-body-tertiary">{{ food.sugar_100 }}</td>
<td class="bg-body-tertiary">{{ food.carbs_100 }}</td>
<td class="bg-body-tertiary">{{ food.protein_100 }}</td>
<td class="bg-body-tertiary">
<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>
</table>
</div>
</div>
{% endblock%}

View File

@@ -0,0 +1,63 @@
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, is_valid_timezone
from application import db
bp = Blueprint(
"auth",
__name__,
template_folder="templates",
)
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return default_return()
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
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()
else:
pass
# invalid user
return render_template("login.html", form=form)
@bp.route("/change_password", methods=["GET", "POST"])
def change_password():
if not current_user.is_authenticated:
return redirect(url_for("auth.login"))
form = ChangePasswordForm()
if form.validate_on_submit():
cur_check = current_user.check_password(
password=form.current_password.data
)
eq_check = form.new_password.data == form.confirm_password.data
if cur_check and eq_check:
current_user.change_password(form.new_password.data)
current_user.set_pw_change(False)
db.session.commit()
return default_return()
return render_template("change_password.html", form=form)
@bp.route("/logout")
def logout():
if not current_user.is_authenticated:
return redirect(url_for("auth.login"))
logout_user()
return redirect(url_for("index"))

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<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">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.current_password.label(class="form-label") }}
{{ form.current_password(class="form-control", placeholder="") }}
{% if form.current_password.errors %}
<div class="text-danger small">
{{ form.current_password.errors[0] }}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.new_password.label(class="form-label") }}
{{ form.new_password(class="form-control", placeholder="Enter password") }}
{% if form.new_password.errors %}
<div class="text-danger small">
{{ form.new_password.errors[0] }}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control", placeholder="Enter password") }}
{% if form.confirm_password.errors %}
<div class="text-danger small">
{{ form.confirm_password.errors[0] }}
</div>
{% endif %}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
</div>
{% endblock%}

View File

@@ -3,8 +3,11 @@
{% block content %}
<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>
<h3 class="mb-1 text-center">Login</h3>
<p class="text-center text-muted small mb-4">
Your timezone will be saved to show times correctly.
</p>
<form method="post">
{{ form.hidden_tag() }}
<div class="mb-3">
@@ -27,10 +30,21 @@
{% endif %}
</div>
{{ form.timezone(id="timezone") }}
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
</div>
{% endblock%}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const tzField = document.getElementById('timezone');
tzField.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
});
</script>
{% endblock %}

View File

@@ -23,7 +23,7 @@
<div class="d-flex w-100">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('user.daily_log') }}">Daily Log</a>
<a class="nav-link" href="{{ url_for('user.daily_log') }}">Daily Log (new)</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('user.dashboard') }}">Dashboard</a>
@@ -32,11 +32,11 @@
<ul class="navbar-nav">
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
{% endif %}
<li class="nav-item">
@@ -80,6 +80,9 @@
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}
{% endblock %}
</body>
</html>

View File

@@ -11,7 +11,10 @@ from flask_login import current_user
from application import db
from forms import FoodItemForm
from models import FoodItem, FoodLog
from datetime import datetime, timezone, timedelta
from datetime import datetime
from application.utils import login_required, macro_arr_to_json
from numpy import array
from zoneinfo import ZoneInfo
user_bp = Blueprint(
"user",
@@ -19,11 +22,7 @@ user_bp = Blueprint(
template_folder="templates",
)
@user_bp.before_request
def login_required():
if not current_user.is_authenticated:
return redirect(url_for("login"))
user_bp.before_request(login_required)
@user_bp.route("/dashboard", methods=["GET"])
@@ -32,6 +31,35 @@ def dashboard():
return render_template("dashboard.html", items=items)
@user_bp.route("/daily_log", methods=["GET"])
def daily_log():
# Get today's date according to user's timezone
today = datetime.now(ZoneInfo(current_user.timezone)).date()
# Save date in session
session["selected_date"] = today.isoformat()
# Get logs from today
logs_today = current_user.food_logs.filter_by(
date_=today.isoformat()
).all()
# calculate macros
macros = array((0.0, 0.0, 0.0, 0.0))
for log in logs_today:
macros += array(log.food_item.macros()) / 100 * log.amount
macros = macro_arr_to_json(macros.tolist())
# Render HTML
return render_template(
"daily_log.html",
macros=macros,
logs=logs_today,
today=today.strftime("%d/%m/%Y"),
min=min,
)
@user_bp.route("/delete_food_item/<int:id>", methods=["POST"])
def delete_food_item(id: int):
item = FoodItem.query.get(id)
@@ -68,40 +96,6 @@ def edit_food_item(id: int):
return redirect(url_for("user.dashboard"))
@user_bp.route("/", methods=["GET"])
@user_bp.route("/<offset>", methods=["GET"])
def daily_log(offset: int = 0):
try:
offset = int(offset)
except ValueError:
abort(400) # or handle invalid input
today = datetime.now(timezone.utc).date()
day = today + timedelta(days=offset)
session["offset"] = offset
logs_today = current_user.food_logs.filter_by(date_=day).all()
logs = [[], [], [], []]
calories: float = 0
protein: float = 0
carbs: float = 0
fat: float = 0
for log in logs_today:
logs[log.part_of_day].append(log)
calories += log.amount * log.food_item.energy_100 / 100
protein += log.amount * log.food_item.protein_100 / 100
carbs += log.amount * log.food_item.carbs_100 / 100
fat += log.amount * log.food_item.fat_100 / 100
return render_template(
"daily_log.html",
date=(day.strftime("%d/%m/%y")),
logs=logs,
calories=calories,
protein=protein,
carbs=carbs,
fat=fat,
offset=offset,
)
@user_bp.route("/remove_log/<int:id>", methods=["POST"])
def remove_log(id: int):
log = db.session.get(FoodLog, id)
@@ -112,6 +106,4 @@ def remove_log(id: int):
# Delete log
db.session.delete(log)
db.session.commit()
if "offset" in session:
return redirect(url_for("user.daily_log", offset=session["offset"]))
return redirect(url_for("user.daily_log"))

View File

@@ -1,210 +1,65 @@
{% extends "base.html" %}
{% extends 'base.html' %}
{% block title %}
Food Nutritional Info
{% endblock %}
{% block title %}Daily Calorie Dashboard{% endblock %}
{% block content %}
<div class="container my-4">
<h2 class="mb-3">Daily Calorie Dashboard ({{ today }})</h2>
<!-- Daily Overview Section -->
<div class="container">
<div class="mb-4 p-3 border rounded d-flex align-items-center justify-content-between">
<!-- Previous Day Button -->
<form method="get" action="{{url_for('user.daily_log', offset=offset - 1)}}" class="m-0">
<button type="submit" class="btn btn-outline-primary d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px;">
&laquo;
</button>
</form>
<!-- Main Content -->
<div class="flex-grow-1 text-center">
<h2>Daily Overview ({{date}})</h2>
<!-- Row 1 -->
<div class="row justify-content-center text-center mb-2">
<div class="col-auto">
<strong>Calories:</strong> {{ '%.0f' % calories }} kcal
<!-- Macro Summary -->
<div class="card p-3 mb-3">
<h5>Macros</h5>
{% for macro in macros %}
<div class="mb-2">
<span class="macro-text">{{ macro.name }}: {{ macro.current }} / {{ macro.target }}</span>
<div class="progress rounded" style="height: 24px;">
<div class="progress-bar bg-danger macro-bar" role="progressbar"
style="width: {{ macro.bar_width_overflow }}%">
{{ (macro.current - macro.target) }}{{ macro.unit }}
</div>
<div class="col-auto">
<strong>Protein:</strong> {{ '%.1f' % protein }} g
</div>
</div>
<!-- Row 2 -->
<div class="row justify-content-center text-center">
<div class="col-auto">
<strong>Carbs:</strong> {{ '%.1f' % carbs }} g
</div>
<div class="col-auto">
<strong>Fat:</strong> {{ '%.1f' % fat }} g
<div class="progress-bar bg-success macro-bar" role="progressbar" style="width: {{ macro.bar_width }}%">
{{ min(macro.current, macro.target) }}{{ macro.unit }}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Next Day Button -->
<form method="get" action="{{url_for('user.daily_log', offset=offset + 1)}}" class="m-0">
<button type="submit" class="btn btn-outline-primary d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px;">
&raquo;
</button>
</form>
<!-- Items List -->
<div class="card p-3">
<h5>Items Eaten Today</h5>
<div class="list-group list-group-flush">
{% for log in logs %}
<div class="list-group-item item-row d-flex justify-content-between align-items-center">
<span>({{ log.amount }}g) {{ log.food_item.name }}</span>
<span>{{ log.food_item.energy_100 * log.amount / 100 }} kcal</span>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Bottom Navigation Buttons -->
<div class="container-fluid fixed-bottom py-2">
<div class="d-flex p-3">
<!-- Left Button -->
<a href="" class="btn card flex-fill me-2 rounded-pill">
Previous
</a>
<div class="p-3 border rounded">
<div class="text-center">
<h2>Eaten today</h2>
</div>
<div class="p-3 mb-2 border rounded">
<!-- Header row (centered vertically for consistency) -->
<div class="row align-items-center mb-2">
<div class="col">
<h4 class="mb-0">Breakfast</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<h4 class="mb-0">Amount</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<a href="{{ url_for('add_meal.step1', meal_type=0) }}"
class="btn btn-sm btn-primary px-3 py-1">Add</a>
</div>
</div>
<!-- Center Button (highlighted) -->
<a id="set_link_date" href="{{ url_for('add_meal.find_item') }}"
class="btn btn-success flex-fill mx-2 fw-bold rounded-pill">
Add Item
</a>
<!-- Data rows -->
<div>
{% for log in logs[0] %}
<div class="row mb-2">
<div class="col text-wrap">
{{ log.food_item.name }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
{{ "{:g}".format(log.amount) }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
<form method="POST" action="{{url_for('user.remove_log', id=log.id)}}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger px-3 py-1"
title="Delete">&times;</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="p-3 mb-2 border rounded">
<!-- Header row (centered vertically for consistency) -->
<div class="row align-items-center mb-2">
<div class="col">
<h4 class="mb-0">Lunch</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<h4 class="mb-0">Amount</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<a href="{{ url_for('add_meal.step1', meal_type=1) }}"
class="btn btn-sm btn-primary px-3 py-1">Add</a>
</div>
</div>
<!-- Data rows -->
<div>
{% for log in logs[1] %}
<div class="row mb-2">
<div class="col text-wrap">
{{ log.food_item.name }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
{{ "{:g}".format(log.amount) }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
<form method="POST" action="{{url_for('user.remove_log', id=log.id)}}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger px-3 py-1"
title="Delete">&times;</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="p-3 mb-2 border rounded">
<!-- Header row (centered vertically for consistency) -->
<div class="row align-items-center mb-2">
<div class="col">
<h4 class="mb-0">Dinner</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<h4 class="mb-0">Amount</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<a href="{{ url_for('add_meal.step1', meal_type=2) }}"
class="btn btn-sm btn-primary px-3 py-1">Add</a>
</div>
</div>
<!-- Data rows -->
<div>
{% for log in logs[2] %}
<div class="row mb-2">
<div class="col text-wrap">
{{ log.food_item.name }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
{{ "{:g}".format(log.amount) }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
<form method="POST" action="{{url_for('user.remove_log', id=log.id)}}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger px-3 py-1"
title="Delete">&times;</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="p-3 mb-2 border rounded">
<!-- Header row (centered vertically for consistency) -->
<div class="row align-items-center mb-2">
<div class="col">
<h4 class="mb-0">Snacks</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<h4 class="mb-0">Amount</h4>
</div>
<div class="col-auto text-end" style="min-width: 80px;">
<a href="{{ url_for('add_meal.step1', meal_type=3) }}"
class="btn btn-sm btn-primary px-3 py-1">Add</a>
</div>
</div>
<!-- Data rows -->
<div>
{% for log in logs[3] %}
<div class="row mb-2">
<div class="col text-wrap">
{{ log.food_item.name }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
{{ "{:g}".format(log.amount) }}
</div>
<div class="col-auto text-end align-self-start" style="min-width: 80px;">
<form method="POST" action="{{url_for('user.remove_log', id=log.id)}}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger px-3 py-1"
title="Delete">&times;</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Right Button -->
<a href="" class="btn card flex-fill ms-2 rounded-pill">
Next
</a>
</div>
</div>
{% endblock%}
{% endblock %}

82
application/utils.py Normal file
View File

@@ -0,0 +1,82 @@
from flask_login import current_user
from flask import redirect, url_for, flash
from typing import Optional
from zoneinfo import ZoneInfo
def login_required():
if not current_user.is_authenticated:
return redirect(url_for("auth.login"))
if current_user.must_change_password:
flash("You have to change your password")
return redirect(url_for("auth.change_password"))
return
def default_return(next_page: Optional[str] = None):
return redirect(url_for("user.daily_log"))
if next_page:
return redirect(next_page)
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
def macro_arr_to_json(data: list[float]):
assert len(data) == 4
cal = data[0]
pro = data[3]
car = data[2]
fat = data[1]
macros = [
{
"name": "Calories",
"current": cal,
"target": 2000,
"bar_width": 100 - abs(cal / 20 - 100),
"bar_width_overflow": max(0, cal / 20 - 100),
"unit": " kcal",
"color": "bg-calories",
"overflow_color": "bg-calories-dark",
},
{
"name": "Protein",
"current": pro,
"target": 150,
"bar_width": 100 - abs(pro / 1.5 - 100),
"bar_width_overflow": max(0, pro / 1.5 - 100),
"unit": "g",
"color": "bg-protein",
"overflow_color": "bg-protein-dark",
},
{
"name": "Carbs",
"current": car,
"target": 250,
"bar_width": 100 - abs(car / 2.5 - 100),
"bar_width_overflow": max(0, car / 2.5 - 100),
"unit": "g",
"color": "bg-carbs",
"overflow_color": "bg-carbs-dark",
},
{
"name": "Fat",
"current": fat,
"target": 70,
"bar_width": 100 - abs(fat / 0.7 - 100),
"bar_width_overflow": max(0, fat / 0.7 - 100),
"unit": "g",
"color": "bg-fat",
"overflow_color": "bg-fat-dark",
},
]
return macros

View File

@@ -3,28 +3,67 @@ from wtforms import (
StringField,
PasswordField,
SubmitField,
IntegerField,
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()])
submit = SubmitField("Login")
timezone = HiddenField("Timezone", validators=[DataRequired()])
submit = SubmitField("Log in")
class ChangePasswordForm(FlaskForm):
current_password = PasswordField(
"Current password", validators=[DataRequired()]
)
new_password = PasswordField("New password", validators=[DataRequired()])
confirm_password = PasswordField(
"Confirm new password", validators=[DataRequired()]
)
submit = SubmitField("Change password")
class FoodItemForm(FlaskForm):
barcode = StringField("Barcode", validators=[Optional()])
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", validators=[Optional()])
fat = FloatField("fat per 100g", validators=[InputRequired()])
energy = FloatField(
"Energy per 100g",
validators=[InputRequired()],
render_kw={"inputmode": "decimal"},
)
protein = FloatField(
"protein per 100g",
validators=[InputRequired()],
render_kw={"inputmode": "decimal"},
)
carbs = FloatField(
"carbs per 100g",
validators=[InputRequired()],
render_kw={"inputmode": "decimal"},
)
sugar = FloatField(
"sugar per 100g",
validators=[Optional()],
render_kw={"inputmode": "decimal"},
)
fat = FloatField(
"fat per 100g",
validators=[InputRequired()],
render_kw={"inputmode": "decimal"},
)
saturated_fat = FloatField(
"saturated_fat per 100g", validators=[Optional()]
"saturated_fat per 100g",
validators=[Optional()],
render_kw={"inputmode": "decimal"},
)
submit = SubmitField("Add Item")

View File

@@ -0,0 +1,40 @@
"""empty message
Revision ID: 101002a6ef17
Revises: dea130d45cec
Create Date: 2025-08-11 17:16:34.617851
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "101002a6ef17"
down_revision = "dea130d45cec"
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(
"must_change_password",
sa.Boolean(),
nullable=False,
server_default="1",
)
)
# ### 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("must_change_password")
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 65eaeafb0904
Revises: 9eb23abd5294
Create Date: 2025-08-14 12:28:56.157288
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '65eaeafb0904'
down_revision = '9eb23abd5294'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('food_log', schema=None) as batch_op:
batch_op.drop_column('part_of_day')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('food_log', schema=None) as batch_op:
batch_op.add_column(sa.Column('part_of_day', sa.INTEGER(), nullable=False))
# ### end Alembic commands ###

View File

@@ -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 ###

View File

@@ -0,0 +1,38 @@
"""empty message
Revision ID: dea130d45cec
Revises: f5fbbe915d51
Create Date: 2025-08-11 16:51:55.485569
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dea130d45cec'
down_revision = 'f5fbbe915d51'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('food_item', schema=None) as batch_op:
batch_op.alter_column('energy_100',
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('food_item', schema=None) as batch_op:
batch_op.alter_column('energy_100',
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -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,18 +12,25 @@ 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)
food_items = db.relationship("FoodItem", lazy="dynamic", backref="user")
food_logs = db.relationship("FoodLog", lazy="dynamic", backref="user")
def __init__(
self, username: str, password: str, is_admin: bool = False
self,
username: str,
password: str,
is_admin: bool = False,
must_change_password: bool = False,
) -> None:
super().__init__()
self.username = username
self.password = generate_password_hash(password=password)
self.is_admin = is_admin
self.must_change_password = must_change_password
def check_password(self, password: str) -> bool:
return check_password_hash(pwhash=self.password, password=password)
@@ -30,6 +38,13 @@ class User(UserMixin, db.Model):
def change_password(self, password: str) -> None:
self.password = generate_password_hash(password=password)
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"
@@ -40,17 +55,18 @@ class Unit(db.Model):
class FoodItem(db.Model):
__tablename__ = "food_item"
id = db.Column(db.Integer, primary_key=True)
barcode = db.Column(db.String)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
name = db.Column(db.String(150), nullable=False)
energy_100 = db.Column(db.Integer, nullable=False)
protein_100 = db.Column(db.Float, nullable=False)
carbs_100 = db.Column(db.Float, nullable=False)
sugar_100 = db.Column(db.Float)
fat_100 = db.Column(db.Float, nullable=False)
saturated_fat_100 = db.Column(db.Float)
energy_100: float = db.Column(db.Float, nullable=False)
protein_100: float = db.Column(db.Float, nullable=False)
carbs_100: float = db.Column(db.Float, nullable=False)
sugar_100: Optional[float] = db.Column(db.Float)
fat_100: float = db.Column(db.Float, nullable=False)
saturated_fat_100: Optional[float] = db.Column(db.Float)
food_logs = db.relationship(
"FoodLog",
@@ -68,7 +84,7 @@ class FoodItem(db.Model):
self,
name: str,
owner_id: int,
energy: int,
energy: float,
protein: float,
carbs: float,
fat: float,
@@ -90,26 +106,20 @@ class FoodItem(db.Model):
def updateFromForm(self, form: FoodItemForm):
self.name = form.name.data
self.energy_100 = form.energy.data
self.protein_100 = form.protein.data
self.carbs_100 = form.carbs.data
self.energy_100 = form.energy.data or 0
self.protein_100 = form.protein.data or 0
self.carbs_100 = form.carbs.data or 0
self.sugar_100 = form.sugar.data
self.fat_100 = form.fat.data
self.fat_100 = form.fat.data or 0
self.saturated_fat_100 = form.saturated_fat.data
def to_dict(self):
return {
"id": self.id,
"barcode": self.barcode,
"name": self.name,
"owner_id": self.owner_id,
"energy_100": self.energy_100,
"protein_100": self.protein_100,
"carbs_100": self.carbs_100,
"sugar_100": self.sugar_100,
"fat_100": self.fat_100,
"saturated_fat_100": self.saturated_fat_100,
}
def macros(self) -> tuple[float, float, float, float]:
return (
self.energy_100,
self.fat_100,
self.carbs_100,
self.protein_100,
)
class FoodLog(db.Model):
@@ -124,7 +134,6 @@ class FoodLog(db.Model):
food_item_id = db.Column(
db.Integer, db.ForeignKey("food_item.id"), nullable=False
)
part_of_day = db.Column(db.Integer, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
amount = db.Column(db.Float, nullable=False)
@@ -133,7 +142,6 @@ class FoodLog(db.Model):
food_item_id: int,
user_id: int,
amount: float,
part_of_day: int,
date_: Optional[date] = None,
):
super().__init__()
@@ -142,4 +150,3 @@ class FoodLog(db.Model):
self.amount = amount
if date_ is not None:
self.date_ = date_
self.part_of_day = part_of_day

View File

@@ -1,4 +1,8 @@
alembic==1.16.1
# For Python >=3.8, importlib-metadata backport is not needed
python_version >= "3.12.10"
# Package requirements
alembic==1.16.4
blinker==1.9.0
click==8.2.1
colorama==0.4.6
@@ -7,12 +11,13 @@ Flask-Login==0.6.3
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.2
greenlet==3.2.2
greenlet==3.2.4
itsdangerous==2.2.0
Jinja2==3.1.6
Mako==1.3.10
MarkupSafe==3.0.2
SQLAlchemy==2.0.41
typing_extensions==4.13.2
numpy==2.3.2
SQLAlchemy==2.0.43
typing_extensions==4.14.1
Werkzeug==3.1.3
WTForms==3.2.1

10
seed.py
View File

@@ -22,10 +22,10 @@ with app.app_context():
)
FoodLog.query.delete()
db.session.add(FoodLog(1, 1, 200, 0))
db.session.add(FoodLog(1, 1, 200, 1))
db.session.add(FoodLog(1, 1, 200, 2))
db.session.add(FoodLog(1, 1, 200, 3))
db.session.add(FoodLog(1, 1, 100, 1))
db.session.add(FoodLog(1, 1, 200))
db.session.add(FoodLog(1, 1, 200))
db.session.add(FoodLog(1, 1, 200))
db.session.add(FoodLog(1, 1, 200))
db.session.add(FoodLog(1, 1, 100))
db.session.commit()