Alpha release

This commit is contained in:
Tim Berchtold 2025-07-04 14:38:36 +02:00
parent 62681acd16
commit eeda5062ae
14 changed files with 842 additions and 1 deletions

13
Options.ini Normal file
View file

@ -0,0 +1,13 @@
[DEFAULT]
host = 'http://0.0.0.0:5000'
max_desks = 8
[SETTINGS]
lockqrcode_whit_secret = True
user = Admin
path_json_settings = '/Server/json/availiable-products.json'
[OTHER]
log_diagnose = True
first_startup = False

View file

@ -1,2 +1,39 @@
# table-order # table-order (Alpha)
# Description
Table ordering with a QR code allows customers to scan a code at their table, access a digital menu, and place their order directly from their smartphone, streamlining the ordering process totaly writen in Python. Table ordering with a QR code allows customers to scan a code at their table, access a digital menu, and place their order directly from their smartphone, streamlining the ordering process totaly writen in Python.
Keep in mind there are currently bugs and also missing features all listed below please don't use in production jet!
# How to set it up
---
## Sites
currently there are 4 Sites
/login -> to Login
/logout -> just browse to this URL ending to logout for now
/orders -> All open orders (Not automatically refreshign on new order)
/order -> Where users can place there order.
to change what you can order take a look at the example in /json/availiable-products.json
## Settings
All settings are in /Options.ini
Default has to be changed to your needs:
host -> is where the QR code points to whit secret if enabled.
max_desks -> How many desk it shoud create.
Execute in Terminal the "pip install -r required.txt" to download depending and "python app.py" to run the Flask server and whit it the webserver.
##
Note:
This are early stages of this project listed are missing and planed.
# To Does
---
Must have features / fixes
TODO's in prio sortet
1. #TODO on new orderGet refresh orders list
2. #BUG Fixing
3. #Feature STOCK management
4. #TODO Settings and init menu or buttons
5. #Polish Make the HTML prettier for all
6. #Polish LOG exacter

77
Server/DB/handler.py Normal file
View file

@ -0,0 +1,77 @@
from __future__ import annotations
import json
import datetime
import os
from typing import List
from sqlalchemy import create_engine, BLOB, Column, Boolean, Float, Integer, String, JSON, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import DeclarativeBase, sessionmaker, relationship, Mapped, mapped_column
from loguru import logger
from flask_login import UserMixin
# create an in-memory SQLite database
engine = create_engine('sqlite:///db.sqlite', echo=True)
Session = sessionmaker(bind=engine)
session = Session()
Base = declarative_base()
class User(UserMixin, Base):
__tablename__ = 'User'
username = Column(String, primary_key=True, unique=True)
hashed_password = Column(String)
salt = Column(String)
class Log(Base): #TODO uSe
__tablename__ = 'Log'
number = Column(Integer, primary_key=True)
created_at = Column(DateTime, default=datetime.datetime.now)
desk_ = Column(Integer)
ip = Column(Integer)
def __init__(self, desk, ip, tel_nr):
self.desk = desk
self.ip = ip
class QR(Base): #TODO: Decide if encryption Salting hasing and anti rainbow attack is eeded for the qr codes?
__tablename__ = 'qr'
desk = Column(Integer, primary_key=True)
qr_secret = Column(Integer)
qr_img = Column(BLOB)
def __init__(self, desk, qr_img, qr_secret=0):
self.desk = desk
self.qr_secret = qr_secret
self.qr_img = qr_img
class Product(Base):
__tablename__ = 'product'
id = mapped_column(Integer, primary_key=True)
name = Column(String)
quantity = Column(Integer)
order_id = mapped_column(ForeignKey("order.id"))
order = relationship("Order", back_populates="order")
def __init__(self, quantity,name, order_id):
self.quantity = quantity
self.name = name
self.order_id = order_id
self.date = datetime.datetime.now()
class Order(Base):
__tablename__ = 'order'
id = mapped_column(Integer, primary_key=True)
order = relationship("Product", back_populates="order")
desk_number = Column(Integer)
finished = Column(Boolean)
def __init__(self, desk_number, finished=False, ):
self.desk_number = desk_number
self.desk = 1
#TODO: replace product-order.json whit json object POST from flask
# That means a UI for setting all values.
self.finished = finished
Base.metadata.create_all(engine)

147
Server/DB/querys.py Normal file
View file

@ -0,0 +1,147 @@
from loguru import logger
import json
import bcrypt
from Server.DB.handler import QR, Product, Order, session, User
class compare:
def is_user_pass_valid(username, password):
session_username = session.query(User).filter(User.username == username).one()
session.commit()
print(session_username.hashed_password)
if bcrypt.hashpw(password.encode('utf-8'), session_username.salt) == session_username.hashed_password.encode('utf-8'):
return True
return False
def is_QRSecret_valid(desk, secret):#TODO FIX! #Feature = True #TODO:Encryption Salting hasing and anti rainbow attack
q = session.query(QR).\
filter(QR.desk.like(int(desk))).\
order_by(QR.qr_secret)
q_QR = q.with_session(session).one()
if q_QR.qr_secret == secret:
return True
else:
return False
def process_main(ordered_list, desk):
'''
Get all verified Products from the order to the db entry
Input: Array ordered_list, INT desk
OUT: True, False if succeded adding to db
'''
if compare.verifie_order(ordered_list):
#products = Product()
order = Order(desk)
session.add(order)
session.commit()
for product in ordered_list:
if int(product[0]) > 0: #dont add not ordered to list
productDB = Product(quantity=int(product[0]),name=product[1],order_id = order.id)
session.add(productDB)
session.commit()
logger.success("Order at desk "+ str(desk) +" resived")
# add a new ordered_list to the database
return True
else:
print("There was an invalid order! Someone messing whit source?")
return False
#TODO: Placeholder
def verifie_order(ordered_list):
'''
if self.desk > self.desk_numbers:
return False
'''
valid_products_list = get.valid_products()
for product in ordered_list:
print(product[1],' ', valid_products_list)
if product[1] in valid_products_list: #product[0] is quantiiy 1 is name_product
pass
else:
return False
return True
class add:
def _create_user(username, password):
if session.query(User).filter_by(username = username).count() < 1:
bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hash = bcrypt.hashpw(bytes, salt).decode('utf-8')
user = User(username = username, hashed_password = hash, salt= salt)
session.add(user)
session.commit()
def _order(orders, desk):
'''
add order to db
INPUT: DICT orders, desk
OUT: True succes False failure
'''
pass
class get:
'''
def user_in_User(username):
calls db if user in User table
q_session = session.query(User).filter(User.username == username).one()
session.commit()
if q_session:
return q_session.id
return False
'''
def valid_products(get_json_=False):
with open(config['SETTINGS']['path_json_settings'], 'r') as file:
#Parse Json Product List
products = json.load(file)
if get_json_:
return products
else:
valide_products = []
for category in products.get('products'):
for product in products.get('products').get(category):
print(product)
if int(product['quantity']) >= 0:
valide_products.append(product['name'])
return valide_products
def all_orders():
# All unfinished orders (finished is False or string "False")
result = []
desk = 1
orders = session.query(Order).filter(Order.finished == 0).all()
session.commit()
for order in orders:
products = session.query(Product).filter(Product.order_id == order.id , Order.desk_number == desk).all()
session.commit()
list_products = []
for p in products:
#What it shows in orders.html
list_products.append(" x" + str(p.quantity) +" "+ p.name )
result.append((order.desk_number, list_products, order.id))
return result
class update:
#TODO GET JSON to -> DB
def update_products_fromJSON(self):
pass
class set:
def finish_order(order_number):
order = session.query(Order).filter(Order.id == int(order_number)).first()
if order:
order.finished = 1 # or "True
session.commit()
logger.info("Order "+str(order_number)+" marked as finished.")
return True
else:
return False

151
Server/Host/flaskApp.py Normal file
View file

@ -0,0 +1,151 @@
from Server.DB.handler import Order, User
from flask import Flask, abort,flash, render_template, request, redirect, url_for, jsonify
# create a session to manage the connection to the database
import time
import configparser
from Server.DB.handler import session, QR
from Server.DB.querys import compare, get, set, add
from flask_login import LoginManager, login_required, login_user, logout_user
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect
from wtforms import StringField, PasswordField, SubmitField, BooleanField
from Tools.qrGenerator import generate_QR_Code
import os
config = configparser.ConfigParser()
config.sections()
config.read('Options.ini')
login_manager = LoginManager()
csrf = CSRFProtect()
app = Flask(__name__)
SECRET_KEY = os.urandom(32)
app.config['SECRET_KEY'] = SECRET_KEY
csrf.init_app(app)
login_manager.init_app(app)
#default db values
# create the Base if not present whit default values like admin+password and qr codes.
if config['OTHER']['first_startup'] == 'True':
print("Please enter a Secure Admin Password:")
add._create_user('Admin',input())
config.set('OTHER', 'first_startup', 'False')
with open('Options.ini', 'w') as configfile:
config.write(configfile)
generate_QR_Code()
@login_manager.user_loader
def user_loader(username):
user = User()
user.id = username
print(username, user.id)
return user
class LoginForm(FlaskForm):
username = StringField('name')
password = PasswordField('Password')
remember_me = BooleanField('Remember Me') #TODO
submit = SubmitField('Submit')
@app.route("/",)
def view_form():
return "loaded"
@app.route("/login", methods=['GET','POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = user_loader(form.username.data)
#TODO if username not in User:
#TODO return if username or pass wrong a red textbox in ui...
if user and compare.is_user_pass_valid(form.username.data, form.password.data):
login_user(user) # store user id in session
#TODO url_has_allowed_host_and_scheme should check if the url is safe
# for redirects, meaning it matches the request host.
# See Django's url_has_allowed_host_and_scheme for an example.
# if not url_has_allowed_host_and_scheme(next, request.host):
# return abort(400)
#next = request.args.get('next') is written in documentaion FLASK
#return redirect(next, url_for('orders')) # redirect to orders page
return redirect(url_for('orders')) # redirect to orders page
return render_template('login.html', form=form)
@app.route("/logout") #TODO make a button
@login_required
def logout():
logout_user()
return redirect('/')
@app.route("/orders", methods=['GET','POST'])
@login_required
def orders(): #TODO on new orderGet refresh orders list
if request.method == 'POST':
order_id = request.form.get('order_id')
action = request.form.get('action') # 'finish' or 'undo'
order = session.get(Order, int(order_id))
if order: #SQL error handeling just in case
if action == 'finish':
order.finished = True
elif action == 'undo':
order.finished = False
session.commit()
return jsonify(success=True)
return jsonify(success=False)
return render_template('orders.html', orders=get.all_orders())
@app.route("/order_get", methods=['GET','POST'])
def order_get():
desk= 1
#POST order
if request.method == 'POST' and config['SETTINGS']['lockqrcode_whit_secret'] == True:
ordered_list = []
desk = request.form['desk']
form = request.form
for key in form:
if key.startswith('order-name.'):
name = key.partition('.')[-1]
value = request.form[key]
ordered_list.append([value,name])
# Adding order to DB
if compare.process_main(ordered_list, desk):
return '<h1>your order got ressived!</h1>'
return '<h1>your order coudnt be ressived try again Error:SQL </h1>'
# Veriefie if auth or not from GET
elif request.method == 'GET':
desk = int(request.args['desk'])
secret = int(request.args['secret'])
try:#TODO FIX! #Feature = True #TODO:Encryption Salting hasing and anti rainbow attack for qr code?? (needed?)
if config['SETTINGS']['lockqrcode_whit_secret'] and compare.is_QRSecret_valid(desk,secret):
return render_template('index.html', desk=desk, MAX_DESKS=config['DEFAULT']['max_desks'], orderableItems = get.valid_products(get_json_=True))
elif config['SETTINGS']['lockqrcode_whit_secret'] == False:
return render_template('index.html', desk=desk, MAX_DESKS=config['DEFAULT']['max_desks'],orderableItems = get.valid_products(get_json_=True))
return '<h1>404 wrong Secret?</h1>' #TODO Make the HTML prettier for all
except:
return '<h1>Server/code issue?</h1>'
elif config['SETTINGS']['lockqrcode_whit_secret'] == True:
return '<h1>your LOCKQRCODE is invalid.</h1>'
else:
return render_template('index.html',desk=desk, MAX_DESKS=config['DEFAULT']['max_desks'],orderableItems = get.valid_products(get_json_=True))
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

View file

@ -0,0 +1,7 @@
.container {
justify-content: space-evenly;
}
.container div {
background-color: aquamarine;
}

View file

@ -0,0 +1,79 @@
html body {
min-height: 100%;
list-style-type: none;
}
body {
height: 98vh;
background: white;
}
/* FLEXBOX DEFINITION */
.flex-container {
height: 100%;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.box {
margin: 5px;
line-height: 2vh;
color: white;
font-weight: bold;
font-size: 3em;
text-align: center;
align-items: center;
}
/* BOX 1 DESK NR */
.desk-nr {
margin-top: 5vw;
background: lightcyan;
color: black;
font-size: 7vw;
}
.desk-nr div {
padding-top: 5vw;
margin-bottom: 6vw;
}
.desk-select {
padding-top: 1vw;
padding-bottom: 1vw;
}
/* BOX 3 CONTENT ORDER */
.content {
margin-top: 5vw;
flex-grow: 1;
background: lightcyan;
border: rgb(46, 42, 42);
color: black;
font-size: 8vw;
}
.content div {
margin-top:8vw;
}
.products {
font-size: 4vw;
}
.content div summary {
color: rgb(46, 42, 42);
line-height: 8vh;
border-bottom: 2px double rgb(46, 42, 42) ;
margin-bottom: 5vw;
}
/* BOX 4 CONTENT ORDER BUTTON SEND */
.order {
background-color: rgb(209, 45, 45);
padding: 1vw;
line-height: 8vh;
}

View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/style.css">
<title>Document</title>
</head>
<body>
<form name="order" onsubmit="return validateForm()" action="{{ url_for('order_get') }}" method="POST" class="flex-container">
<li class="box desk-nr">Tisch Nummer
<div>
<select name="desk" class="desk-select">
{% for e in range(1, MAX_DESKS+1) %}
{% if desk == e %}
<option selected>{{e}}</option>
{% else %}
<option>{{e}}</option>
{% endif %}
{% endfor %}
</select>
</div>
</li>
<li class="box content">Menu
{% for category in orderableItems.get('products') %}
<div>
<details>
<summary>{{ category }}</summary>
{% for product in orderableItems.get('products').get(category)%}
<div class="products" id="{ product['name'] }">
{{product['name']}}
<input id="points" type="number" value=0 name="{{ "order-name." + product['name'] }}" step="1">
</div>
{% endfor %}
</details>
</div>
{% endfor %}
</li>
<button type="submit" class="box order">Weiter</button>
</form>
</body>
</html>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>Login Page</title>
</head>
<body>
<h1>Login Page</h1>
<form method="POST" action="{{ url_for('login') }}">
{{ form.csrf_token }}
{{ form.username.label }}
{{ form.username }}
<br>
<br>
{{ form.password.label }}
{{ form.password }}
<br>
<p>{{ form.remember_me }} {{ form.remember_me.label }}</p>
<br>
{{ form.submit }}
</form>
</body>
</html>

View file

@ -0,0 +1,125 @@
<!doctype html>
<html>
<head>
<title>Orders</title>
<style>
table { border-collapse: collapse; width: 80%; margin: 40px auto; }
th, td { border: 1px solid #cccccc; padding: 12px 18px; }
th { background: #f0f0f0; }
.finish-btn {
background-color: #4CAF50; /* Green */
color: white;
border: none;
padding: 7px 18px;
cursor: pointer;
border-radius: 5px;
}
.undo-btn {
background-color: #f44336; /* Red */
color: white;
border: none;
padding: 7px 18px;
cursor: pointer;
border-radius: 5px;
}
.finish-btn:hover { background-color: #388e3c; }
.undo-btn:hover { background-color: #d32f2f; }
</style>
</head>
<body>
<h1 style="text-align:center;">Current Orders</h1>
<table>
<tr>
<th>Desk</th>
<th>Products</th>
<th>Action</th>
<th>Order-Nr</th>
</tr>
{% for order in orders%}
<tr id="order-{{ order[2] }}">
<td>{{ order[0] }}</td>
<td>
<ul>
{% for product in order[1] %}
<li>{{ product }}</li>
{% endfor %}
</ul>
</td>
<td>
<button
class="finish-btn"
onclick="markFinished({{ order[2] }})"
id="btn-{{ order[2] }}">
Finished
</button>
</td>
<td>order {{ order[2] }} </td>
</tr>
{% else %}
<tr><td colspan="3">No open orders.</td></tr>
{% endfor %}
</table>
<script>
// Track active timers for each order
window.activeTimers = {};
function markFinished(orderId) {
fetch('/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `order_id=${orderId}&action=finish`
})
.then(response => response.json())
.then(data => {
if (data.success) {
const btn = document.getElementById(`btn-${orderId}`);
btn.className = 'undo-btn';
btn.onclick = () => undoFinished(orderId);
// Start countdown (10s)
let secondsLeft = 10;
btn.textContent = `Undo (${secondsLeft}s)`;
// Store timer reference
window.activeTimers[orderId] = setInterval(() => {
secondsLeft--;
btn.textContent = `Undo (${secondsLeft}s)`;
// Auto-remove after 10s
if (secondsLeft <= 0) {
clearInterval(window.activeTimers[orderId]);
document.getElementById(`order-${orderId}`).remove();
delete window.activeTimers[orderId];
}
}, 1000);
}
});
}
function undoFinished(orderId) {
// Clear the countdown timer
if (window.activeTimers[orderId]) {
clearInterval(window.activeTimers[orderId]);
delete window.activeTimers[orderId];
}
fetch('/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `order_id=${orderId}&action=undo`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Revert button to original state
const btn = document.getElementById(`btn-${orderId}`);
btn.className = 'finish-btn';
btn.textContent = 'Finished';
btn.onclick = () => markFinished(orderId);
}
});
}
</script>
</body>
</html>

View file

@ -0,0 +1,37 @@
{
"products": {
"Süsses": [
{
"name": "Haribos",
"price": 4,
"quantity": 2
},
{
"name": "Skittles",
"price": 4,
"quantity": 2
}
],
"Getränke": [
{
"name": "IceTea",
"price": 4,
"quantity": 2
},
{
"name": "Fanta",
"price": 4,
"quantity": 5
}
],
"Öm": [
{
"name": "Molecule Man",
"price": 10,
"quantity": 5
}
]
}
}

56
Tools/qrGenerator.py Normal file
View file

@ -0,0 +1,56 @@
# Importing library
import qrcode
import random
import io
from Server.DB.handler import QR
from Server.DB.handler import session
import configparser
config = configparser.ConfigParser()
config.sections()
config.read('Options.ini')
def randint(min,max):
a = random.randint(min,max)
return a
def generate_QR_Code():
if config['SETTINGS']['lockqrcode_whit_secret'] == 'True':
for desk in range(int(config['DEFAULT']['max_desks'])):
img_byte_arr = io.BytesIO()
secret = random.randint(1,9999999)
data = config['DEFAULT']['host'] + '/order_get?desk='+str(desk)+'&secret='+str(secret)
qr_img = qrcode.make(data)
qr_img.save(img_byte_arr, format='PNG')
img_byte_arr = img_byte_arr.getvalue()
qr = QR(desk,img_byte_arr, qr_secret=secret)
existing_qr = session.query(QR).filter_by(desk=desk).first()
if existing_qr:
existing_qr.qr_img = img_byte_arr # Update existing row
existing_qr.qr_secret = secret
else:
session.add(qr) # Insert new row
session.commit()
else: #no Optional features
for desk in range(int(config['DEFAULT']['max_desks'])):
img_byte_arr = io.BytesIO()
data = config['DEFAULT']['host'] + '/order_get?desk='+str(desk)
qr_img = qrcode.make(data)
qr_img.save(img_byte_arr, format='PNG')
img_byte_arr = img_byte_arr.getvalue()
qr = QR(desk,img_byte_arr)
existing_qr = session.query(QR).filter_by(desk=desk).first()
if existing_qr:
existing_qr.qr_img = img_byte_arr # Update existing row
existing_qr.qr_secret = 0
else:
session.add(qr) # Insert new row
session.commit()
# Encoding data using make() function
# Saving as an image file

27
app.py Normal file
View file

@ -0,0 +1,27 @@
import sys
from loguru import logger
from Server.Host.flaskApp import app
from Server.DB.querys import add
import configparser
config = configparser.ConfigParser()
config.sections()
config.read('Options.ini')
'''
INFO if doing Asynchronous, Thread-safe, Multiprocess-safe
All sinks added to the logger are thread-safe by default.
They are not multiprocess-safe,
but you can enqueue the messages to ensure logs
integrity. This same argument can also be used if
you want async logging.
'''
@logger.catch
def run(): #TODO: sys.stderr back to a file log??
logger.add(sys.stderr, format="{time} {level} {message}", filter="startup", level="INFO")
logger.add(sys.stderr, backtrace=True, diagnose=config['OTHER']['log_diagnose'])
app.run(debug=True)
if __name__ == "__main__":
#First startup is handled in DB/handler
run()

9
required.txt Normal file
View file

@ -0,0 +1,9 @@
flask==3.0.3;
flask-login==0.6.3;
flask_wtf==1.2.2;
WTForms==3.2.1;
Flask-SQLAlchemy==3.1.1.;
qrcode==8.0
pillow==11.1.0
loguru==0.7.3
bcrypt==4.3.0;