From eeda5062aeaceddcec774a86ede7f1cc04b8e8fb Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 4 Jul 2025 14:38:36 +0200 Subject: [PATCH] Alpha release --- Options.ini | 13 +++ README.md | 39 ++++++- Server/DB/handler.py | 77 ++++++++++++++ Server/DB/querys.py | 147 ++++++++++++++++++++++++++ Server/Host/flaskApp.py | 151 +++++++++++++++++++++++++++ Server/Host/static/order-sytle.css | 7 ++ Server/Host/static/style.css | 79 ++++++++++++++ Server/Host/templates/index.html | 53 ++++++++++ Server/Host/templates/login.html | 23 ++++ Server/Host/templates/orders.html | 125 ++++++++++++++++++++++ Server/json/availiable-products.json | 37 +++++++ Tools/qrGenerator.py | 56 ++++++++++ app.py | 27 +++++ required.txt | 9 ++ 14 files changed, 842 insertions(+), 1 deletion(-) create mode 100644 Options.ini create mode 100644 Server/DB/handler.py create mode 100644 Server/DB/querys.py create mode 100644 Server/Host/flaskApp.py create mode 100644 Server/Host/static/order-sytle.css create mode 100644 Server/Host/static/style.css create mode 100644 Server/Host/templates/index.html create mode 100644 Server/Host/templates/login.html create mode 100644 Server/Host/templates/orders.html create mode 100644 Server/json/availiable-products.json create mode 100644 Tools/qrGenerator.py create mode 100644 app.py create mode 100644 required.txt diff --git a/Options.ini b/Options.ini new file mode 100644 index 0000000..3483e31 --- /dev/null +++ b/Options.ini @@ -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 + diff --git a/README.md b/README.md index 1aeef01..5371008 100644 --- a/README.md +++ b/README.md @@ -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. + +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 diff --git a/Server/DB/handler.py b/Server/DB/handler.py new file mode 100644 index 0000000..f393e40 --- /dev/null +++ b/Server/DB/handler.py @@ -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) \ No newline at end of file diff --git a/Server/DB/querys.py b/Server/DB/querys.py new file mode 100644 index 0000000..e60f879 --- /dev/null +++ b/Server/DB/querys.py @@ -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 diff --git a/Server/Host/flaskApp.py b/Server/Host/flaskApp.py new file mode 100644 index 0000000..dc924b0 --- /dev/null +++ b/Server/Host/flaskApp.py @@ -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 '

your order got ressived!

' + return '

your order coudnt be ressived try again Error:SQL

' + + # 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 '

404 wrong Secret?

' #TODO Make the HTML prettier for all + except: + return '

Server/code issue?

' + elif config['SETTINGS']['lockqrcode_whit_secret'] == True: + return '

your LOCKQRCODE is invalid.

' + 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 + diff --git a/Server/Host/static/order-sytle.css b/Server/Host/static/order-sytle.css new file mode 100644 index 0000000..f3b8b86 --- /dev/null +++ b/Server/Host/static/order-sytle.css @@ -0,0 +1,7 @@ +.container { + justify-content: space-evenly; + } + +.container div { + background-color: aquamarine; +} \ No newline at end of file diff --git a/Server/Host/static/style.css b/Server/Host/static/style.css new file mode 100644 index 0000000..a23ca3f --- /dev/null +++ b/Server/Host/static/style.css @@ -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; +} diff --git a/Server/Host/templates/index.html b/Server/Host/templates/index.html new file mode 100644 index 0000000..c8bffe7 --- /dev/null +++ b/Server/Host/templates/index.html @@ -0,0 +1,53 @@ + + + + + + + Document + + +
+
  • Tisch Nummer +
    + +
    +
  • +
  • Menu + {% for category in orderableItems.get('products') %} + + +
    +
    + {{ category }} + + + {% for product in orderableItems.get('products').get(category)%} + +
    + {{product['name']}} + +
    + + {% endfor %} +
    +
    + {% endfor %} + + +
  • + + +
    + + \ No newline at end of file diff --git a/Server/Host/templates/login.html b/Server/Host/templates/login.html new file mode 100644 index 0000000..462a86a --- /dev/null +++ b/Server/Host/templates/login.html @@ -0,0 +1,23 @@ + + + + Login Page + + +

    Login Page

    +
    + {{ form.csrf_token }} + {{ form.username.label }} + {{ form.username }} +
    +
    + {{ form.password.label }} + {{ form.password }} +
    +

    {{ form.remember_me }} {{ form.remember_me.label }}

    +
    + {{ form.submit }} +
    + + + \ No newline at end of file diff --git a/Server/Host/templates/orders.html b/Server/Host/templates/orders.html new file mode 100644 index 0000000..78f6921 --- /dev/null +++ b/Server/Host/templates/orders.html @@ -0,0 +1,125 @@ + + + + Orders + + + +

    Current Orders

    + + + + + + + + {% for order in orders%} + + + + + + + {% else %} + + {% endfor %} +
    DeskProductsActionOrder-Nr
    {{ order[0] }} +
      + {% for product in order[1] %} +
    • {{ product }}
    • + {% endfor %} +
    +
    + + order {{ order[2] }}
    No open orders.
    + + + + \ No newline at end of file diff --git a/Server/json/availiable-products.json b/Server/json/availiable-products.json new file mode 100644 index 0000000..120eedb --- /dev/null +++ b/Server/json/availiable-products.json @@ -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 + } + + ] + } + } + \ No newline at end of file diff --git a/Tools/qrGenerator.py b/Tools/qrGenerator.py new file mode 100644 index 0000000..5fade3e --- /dev/null +++ b/Tools/qrGenerator.py @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..ba08ce1 --- /dev/null +++ b/app.py @@ -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() diff --git a/required.txt b/required.txt new file mode 100644 index 0000000..a82e098 --- /dev/null +++ b/required.txt @@ -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; \ No newline at end of file