From 8b12d17935e9a9d9e68d6fc628f3c8582defdca7 Mon Sep 17 00:00:00 2001 From: elpatron Date: Tue, 24 Feb 2026 16:33:16 +0100 Subject: [PATCH] =?UTF-8?q?Implementieren=20der=20PWA,=20Multi-Instanzen-P?= =?UTF-8?q?asswort-Schutz=20und=20Kassen-L=C3=B6schfunktion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 288 +++++++++++++++++++++++++--------------- gen_icons.py | 36 +++++ kasse.db | Bin 0 -> 20480 bytes static/icon-192x192.png | Bin 0 -> 645 bytes static/icon-512x512.png | Bin 0 -> 2001 bytes static/manifest.json | 21 +++ static/sw.js | 56 ++++++++ templates/admin.html | 89 +++++++++++++ templates/index.html | 149 ++++++++++++++------- templates/landing.html | 117 ++++++++++++++++ templates/login.html | 42 ++++++ 11 files changed, 645 insertions(+), 153 deletions(-) create mode 100644 gen_icons.py create mode 100644 kasse.db create mode 100644 static/icon-192x192.png create mode 100644 static/icon-512x512.png create mode 100644 static/manifest.json create mode 100644 static/sw.js create mode 100644 templates/admin.html create mode 100644 templates/landing.html create mode 100644 templates/login.html diff --git a/app.py b/app.py index eae399a..ade3a56 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,10 @@ import os import time import logging -from flask import Flask, render_template, request, session, send_from_directory, g +import sqlite3 +import uuid +from werkzeug.security import generate_password_hash, check_password_hash +from flask import Flask, render_template, request, session, send_from_directory, g, redirect, url_for from flask_bootstrap import Bootstrap app = Flask(__name__) @@ -18,133 +21,210 @@ app.logger.addHandler(handler) Bootstrap(app) -version = "1.0.9/2024-05-28" -postcounter = 0 -gesamtwert = 0 -change = 0 -givenfloat = 0 -sum = "" -item1 = 0 -item2 = 0 -item3 = 0 -item4 = 0 -item5 = 0 -item6 = 0 +version = "1.1.0/2026-02-24" + +def get_db_connection(): + conn = sqlite3.connect('kasse.db') + conn.row_factory = sqlite3.Row + return conn + +def init_db(): + conn = get_db_connection() + conn.execute(''' + CREATE TABLE IF NOT EXISTS instances ( + id TEXT PRIMARY KEY, + password TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + try: + conn.execute('ALTER TABLE instances ADD COLUMN password TEXT') + except sqlite3.OperationalError: + pass # Column already exists + + conn.execute(''' + CREATE TABLE IF NOT EXISTS products ( + instance_id TEXT, + position INTEGER, + name TEXT, + price REAL, + icon TEXT, + color_class TEXT, + FOREIGN KEY (instance_id) REFERENCES instances (id), + PRIMARY KEY (instance_id, position) + ) + ''') + conn.commit() + conn.close() + +init_db() app.logger.info('Starting erdbeerhannah ' + version) -# https://code-maven.com/flask-display-elapsed-time @app.before_request def before_request(): g.request_start_time = time.time() g.request_time = lambda: "%.4fs" % (time.time() - g.request_start_time) -# prevent cached responses -# https://stackoverflow.com/questions/47376744/how-to-prevent-cached-response-flask-server-using-chrome @app.after_request def add_header(r): - """ - Add headers to both force latest IE rendering engine or Chrome Frame, - and also to cache the rendered page for 10 minutes. - """ r.headers["Cache-Control"] = "no-store, max-age=0" return r @app.route("/", methods=["GET", "POST"]) -def index(): - global gesamtwert, item1, item2, item3, item4, item5, item6, sum, givenfloat, change, given, background, postcounter, version - background = "bg-white" - +def landing(): if request.method == "POST": - postcounter += 1 - # wert = float(request.form["wert"]) - wert = request.form.get('wert', "0", type=float) - given = request.form.get('given', "0", type=float) - app.logger.debug('wert: %s, given: %s', wert, given) - wertfloat = float(wert) - givenfloat = float(given) - - # reset button - if wertfloat == 0: - global gesamtwert - gesamtwert = 0 - change = 0 - sum = "0" - item1 = 0 - item2 = 0 - item3 = 0 - item4 = 0 - item5 = 0 - item6 = 0 + password = request.form.get('password', '') + instance_id = str(uuid.uuid4()) + conn = get_db_connection() + conn.execute('INSERT INTO instances (id, password) VALUES (?, ?)', (instance_id, generate_password_hash(password))) + default_products = [ + (instance_id, 1, "500g Erdbeeren", 4.9, "🍓", "btn-primary"), + (instance_id, 2, "Marmelade groß", 4.8, "🫙🫙", "btn-danger"), + (instance_id, 3, "Marmelade klein", 3.3, "🫙", "btn-danger"), + (instance_id, 4, "500g Kirschen", 5.0, "🍒", "btn-warning"), + (instance_id, 5, "500g Himbeeren", 4.5, "🫐", "btn-warning"), + (instance_id, 6, "Tragetasche", 0.2, "🛍️", "btn-success") + ] + conn.executemany('INSERT INTO products (instance_id, position, name, price, icon, color_class) VALUES (?, ?, ?, ?, ?, ?)', default_products) + conn.commit() + conn.close() + return redirect(url_for('admin', instance_id=instance_id)) + return render_template("landing.html", version=version) + +@app.route("/", methods=["GET", "POST"]) +def index(instance_id): + conn = get_db_connection() + instance = conn.execute('SELECT * FROM instances WHERE id = ?', (instance_id,)).fetchone() + if not instance: + conn.close() + return "Instance not found", 404 + + products_rows = conn.execute('SELECT * FROM products WHERE instance_id = ? ORDER BY position', (instance_id,)).fetchall() + conn.close() + + products = {row['position']: dict(row) for row in products_rows} + + session_prefix = f"kasse_{instance_id}_" + + gesamtwert = session.get(f'{session_prefix}gesamtwert', 0.0) + change = session.get(f'{session_prefix}change', "0.00") + givenfloat = session.get(f'{session_prefix}given', 0.0) + items = {i: session.get(f'{session_prefix}item{i}', 0) for i in range(1, 7)} + + background = "bg-white" + + if request.method == "POST": + action = request.form.get('action') + position = request.form.get('position', type=int) + + if action == "reset": + gesamtwert = 0.0 + change = "0.00" + givenfloat = 0.0 + items = {i: 0 for i in range(1, 7)} background = "bg-white" - # summarize value - elif wertfloat != -2: - gesamtwert += wertfloat - gesamtwert = round(gesamtwert, 2) - if gesamtwert > 0: - sum = str(gesamtwert) + "0" + elif action == "calculate_change": + given = request.form.get('given', "0", type=float) + givenfloat = given + change_val = givenfloat - gesamtwert + change = f"{change_val:.2f}" + background = "bg-danger" if change_val < 0 else "bg-white" + elif position and position in products: + price = products[position]['price'] + gesamtwert += price + items[position] += 1 - # summarize items - if wertfloat == 4.9: - item1 += 1 - if wertfloat == 4.8: - item2 += 1 - if wertfloat == 3.3: - item3 += 1 - if wertfloat == 4.5: - item4 += 1 - if wertfloat == 5: - item5 += 1 - if wertfloat == .2: - item6 += 1 - if givenfloat > 0: - try: - gesamtwert = session['summefloat'] or 0 - sum = str(gesamtwert) + "0" - change = str(round((givenfloat - gesamtwert) * -1, 2)) + "0" - except: - app.logger.warning("Failed to read sum") - if givenfloat - gesamtwert < 0: - background = "bg-danger" - else: - background = "bg-white" - - session['item1'] = item1 - session['item2'] = item2 - session['item3'] = item3 - session['item4'] = item4 - session['item5'] = item5 - session['item6'] = item6 - session['summefloat'] = gesamtwert - session['summestring'] = sum - session['change'] = change - session['given'] = givenfloat - - app.logger.info('*** sum %s, given %s, change %s', sum, givenfloat, change) - app.logger.info('*** postcounter %s', postcounter) - - return render_template("index.html", gesamtwert=session.get('summestring', 0), - change=session.get('change', 0), - given=session.get('given', 0), - item1=session.get('item1', 0), - item2=session.get('item2', 0), - item3=session.get('item3', 0), - item4=session.get('item4', 0), - item5=session.get('item5', 0), - item6=session.get('item6', 0), + session[f'{session_prefix}gesamtwert'] = round(gesamtwert, 2) + session[f'{session_prefix}change'] = change + session[f'{session_prefix}given'] = round(givenfloat, 2) + for i in range(1, 7): + session[f'{session_prefix}item{i}'] = items[i] + + # Update our local variables after processing so they reflect what is rendered + gesamtwert = round(gesamtwert, 2) + + return render_template("index.html", + instance_id=instance_id, + products=products, + gesamtwert=f"{gesamtwert:.2f}", + change=change, + given=f"{givenfloat:.2f}" if givenfloat > 0 else "0", + items=items, background=background, - version=version, - ) + version=version + ) + +@app.route("//admin", methods=["GET", "POST"]) +def admin(instance_id): + conn = get_db_connection() + instance = conn.execute('SELECT * FROM instances WHERE id = ?', (instance_id,)).fetchone() + if not instance: + conn.close() + return "Instance not found", 404 + + auth_key = f'admin_auth_{instance_id}' + + # Handle Login Submission + if request.method == "POST" and 'admin_password' in request.form: + if check_password_hash(instance['password'], request.form['admin_password']): + session[auth_key] = True + conn.close() + return redirect(url_for('admin', instance_id=instance_id)) + else: + conn.close() + return render_template("login.html", instance_id=instance_id, error="Falsches Passwort", version=version) + + # Require Authentication + if not session.get(auth_key): + conn.close() + return render_template("login.html", instance_id=instance_id, error=None, version=version) + + if request.method == "POST": + action = request.form.get('action') + + if action == "delete": + conn.execute('DELETE FROM products WHERE instance_id = ?', (instance_id,)) + conn.execute('DELETE FROM instances WHERE id = ?', (instance_id,)) + conn.commit() + conn.close() + session.pop(auth_key, None) + return redirect(url_for('landing')) + + # Additional safety: Ensure that the post request isn't processed if not authenticated + # (Though we checked above, this is for the product update form submission) + for i in range(1, 7): + name = request.form.get(f'name_{i}') + price = request.form.get(f'price_{i}', type=float) + icon = request.form.get(f'icon_{i}') + color = request.form.get(f'color_{i}') + if name is not None and price is not None: + conn.execute(''' + UPDATE products SET name = ?, price = ?, icon = ?, color_class = ? + WHERE instance_id = ? AND position = ? + ''', (name, price, icon, color, instance_id, i)) + conn.commit() + conn.close() + return redirect(url_for('index', instance_id=instance_id)) + + products_rows = conn.execute('SELECT * FROM products WHERE instance_id = ? ORDER BY position', (instance_id,)).fetchall() + conn.close() + + products = {row['position']: dict(row) for row in products_rows} + return render_template("admin.html", instance_id=instance_id, products=products, version=version) @app.route('/favicon.ico') def favicon(): return send_from_directory(os.path.join(app.root_path, 'static'), 'favicon.ico', mimetype='image/vnd.microsoft.icon') -# @app.route('/about') -# def about(): -# return send_from_directory(os.path.join(app.root_path, 'static'), -# 'about.html') +@app.route('/manifest.json') +def manifest(): + return send_from_directory('static', 'manifest.json') + +@app.route('/sw.js') +def service_worker(): + return send_from_directory('static', 'sw.js', mimetype='application/javascript') if __name__ == "__main__": app.run(debug=True, host='127.0.0.1') \ No newline at end of file diff --git a/gen_icons.py b/gen_icons.py new file mode 100644 index 0000000..40a13f3 --- /dev/null +++ b/gen_icons.py @@ -0,0 +1,36 @@ +from PIL import Image, ImageDraw, ImageFont +import os + +def create_icon(size, filename): + # Create a red square + img = Image.new('RGB', (size, size), color='#dc3545') + draw = ImageDraw.Draw(img) + + # Try to draw a simple 'E' in the center as a fallback if no emoji font + # Calculate approximate font size + font_size = int(size * 0.6) + try: + # standard windows font + font = ImageFont.truetype("arialbd.ttf", font_size) + except IOError: + font = ImageFont.load_default() + + text = "E" + # Get bounding box + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + text_w = right - left + text_h = bottom - top + + x = (size - text_w) / 2 + y = (size - text_h) / 2 + + draw.text((x, y), text, fill=(255, 255, 255), font=font) + + # Save the image + filepath = os.path.join('static', filename) + img.save(filepath) + print(f"Saved {filepath}") + +if __name__ == "__main__": + create_icon(192, 'icon-192x192.png') + create_icon(512, 'icon-512x512.png') diff --git a/kasse.db b/kasse.db new file mode 100644 index 0000000000000000000000000000000000000000..507a6444b255d9dd06e9e60b1d136135cf9792f8 GIT binary patch literal 20480 zcmeI3&u`mg7{~3zNt!N+5vBBis(RX3o2>!={?Q99#T(Vqu1RC)E<~^GH(6Mlbatvy zPPF2LR58J6a46zB1XnmtzyXOfaOV%8(l~H~*IpCiiHi4tP(jZt*?IlGPriAc&(}V@ zd6SJ=wbRE!s~2pezMwEqF)Yh03IfA0S#o5^F=%Ph;RYAvl^yAy*3B|6y!T9zf1cqA zkC?(E{@dcGrC(1AhH)SO1b_e#00KY&2mk>f00e-*KTKeMp3P0o%&-TuedKQ8PB-kM zu7|^6C$(I+mm0RvSbDi?3&UYyZd@2?9bahJn+;*TzOuShzbRa|Z_XzIya1y<_M2o~ z)mT}zHyTT;>%uGc)g`Ce5SE>K-Ck=nhcm;s&I>yz4Da@W$NJ@i8|mEC^fdeKojBc{ zpy%&;{c!NgC6XPCKAG!Djm?v!lZo#1!cM=_>k2Dt4f~p1PtJDHHjXPz4(|jV4+{iT zH96k#h)@J3IqdbedO_3MBBlMs?A04}d*#|%#88+U04K?o$p>7u38-azW7G&TlawDZ zAt69uo)lLe0xrMEWv7;>S*FwV@mt}YEwTV^qTPNkzHSZ>Hl;x?!~e#765lcTGkM39 zrBrsxo;j_c0ne~04ZG1=ek^*fisVR@erEV@$O#${00KY&2mk>f00e*l5C8%|00;nq z|1N?3H&Pi#IjH%nXsJj?6+;*0iYh9oVydcJaXrJ+MNgNJD9WO&SIDfa3X-;<$P1DZ zdcodKpY#pgTrd};i}l;>W%q6ER&`I(*Xy;lixSpMPqpNhiH(*bxt3`to-FxD6Ma*| zD$({$t0fznW~wdCm5`!prs5-4mL(nQnnzObMGGs)&@A7@h9)D+LyBlBx}+JZMc$1K zN%B-z^L!Oy-SCiaD4MBTNY@tFw4*4ZXtX3#AOHk_01yBIKmZ5;0U!VbfB+Bx0%w81T-sr2zQ;(-rky;^ zR|3)gf1z+G#edGf&U2-&O0CjF@nP}J;#A@L!d~IhStuHE00KY&2mk>f00e*l5C8%| z;NK)rE94yZLhiW>G;5D+BS3B3N1=BMzx3VxsC{&N^zq>@U%v15yOnU)BR{qeYv+h$ zIX6iqnbAbCExg*+FfyG?_kO z_OpX$CSi88|6eHn&hS6-pYi+rYy1_SDg97-Pf@P8n1nR8gG4drZsIy7^RI+Poy4o&B&LsMgA&dF1im5W7bR@2!mb!a9-9m;Xk Rq3JYrXeu>F08s7!{{iRd=~@5) literal 0 HcmV?d00001 diff --git a/static/icon-192x192.png b/static/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..4a01b1b1a2c376a2ea7f34810ab073f6724bbaf7 GIT binary patch literal 645 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfyu|y#WAE}&f7cIUd(|K4Hsu0 zymXIsM_a>IffH>k-WxZSDTalWnTf4a{MIx#K{}@SXUl|{#lI>=SF`~M0TComD|SDjM#M<%iR_RG9}Jm0+Betmwj7=e(Ae3iB5cjLcK zPBOwT^3}$9i{my~E@yUA!ygB0%3k|cGk2#E>klGg*6kzr(HiOZFS0#B35~(i)z4*} HQ$iB}IdQie literal 0 HcmV?d00001 diff --git a/static/icon-512x512.png b/static/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..643b973c91234377e308524546372ff1236a432e GIT binary patch literal 2001 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_t&So-U3d6?5KRH`HrM6ln{5 zvi?THJt?)|1ruMqalK-+S9OI;s$i91j^mcg7kw8loUhUA|7p(VCiCr;Mcd=kmq#;v zdDA1ua6p2IK|qy(A#ju$4T7PX3NF83{G8p=N7e1~T&}41e zE64CUjNkusckAv0%YHI2EMj3$@EYt?mXs~SwYPHT_~*U9`!sxy?A+SgIx98%PmByM zoD2?A7zTv8Vp7GhCfe3US6}QM)9h!=3>`uY4H}HYgPP^W-%z@{{d4p4>hhn)@2_u{ zH!tUtdorM&T=Dk$&6h8D8F(HHs56FM6yM1D#rDHZDPCVuaT2hqX7F_Nb6Mw<&;$S% CrAb%- literal 0 HcmV?d00001 diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..e874aaa --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "erdbeerrechner", + "short_name": "erdbeer", + "description": "Eine anpassbare Kassen-App.", + "start_url": "/", + "display": "standalone", + "background_color": "#f8f9fa", + "theme_color": "#dc3545", + "icons": [ + { + "src": "/static/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..bebc2d6 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,56 @@ +const CACHE_NAME = 'erdbeerkasse-v1'; +const ASSETS_TO_CACHE = [ + '/', + '/manifest.json', + '/static/icon-192x192.png', + '/static/icon-512x512.png', + 'https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(ASSETS_TO_CACHE)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }) + ); + self.clients.claim(); +}); + +// Network-first strategy for dynamic instances, fallback to cache +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone the response before caching if it's a valid response + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + + return response; + }) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..9af2609 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,89 @@ + + + + + + + erdbeerhannah 🍓💶 - Admin + + + + + + + +
+

Kassen-Konfiguration

+

Dies ist die Konfiguration für deine Kasse: {{ instance_id }}

+

Speichere dir diese URL ab, um später Änderungen vorzunehmen.

+
+ Deine Kasse ist erreichbar unter: /{{ instance_id + }} +
+ +
+ + + + + + + + + + + + {% for i in range(1, 7) %} + {% set prod = products[i] %} + + + + + + + + {% endfor %} + +
PositionNamePreis (€)Icon/EmojiFarbe (Bootstrap Klasse)
{{ i }} + +
+
+
+ Zurück zur + Kasse + +
+ +
+
+
+ + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index b0694db..574fb24 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,125 +1,176 @@ + - - - - - - - - - - - - - + erdbeerhannah 🍓💶 + + + - +
- + + - - - + {% for i in range(1, 4) %} + {% set prod = products[i] %} + + {% endfor %} - - - + {% for i in range(4, 7) %} + {% set prod = products[i] %} + + {% endfor %} - + - + - + - + - +
erdbeerrechner 🍓💶erdbeerrechner 🍓💶⚙️ Setup
+ +
+ +
🫰 {{ gesamtwert }}€🫰 {{ gesamtwert.replace('.', ',') }}€
- - + +
🪙 {{ change }}€🪙 {{ change.replace('.', ',') }}€ +
+ +
Made with ♥️, marmalade and zero knowledge in Kiel Strawberry City.
- Version: {{ version }} ({{ g.request_time() }}), Infos
Made with ♥️, marmalade and zero knowledge in Kiel Strawberry City.
+ Version: {{ version }}, Instanz: {{ instance_id[:8] }}... + +
+ - - + // Save instance to localStorage for the landing page + const instanceId = "{{ instance_id }}"; + let saved = JSON.parse(localStorage.getItem('erdbeerkassen')) || []; + // Optional: remove if exists so it gets pushed to the end (most recent) + saved = saved.filter(i => i.id !== instanceId); + saved.push({ id: instanceId, date: new Date().toISOString() }); + localStorage.setItem('erdbeerkassen', JSON.stringify(saved)); + + function shareInstance() { + const url = window.location.href; + if (navigator.share) { + navigator.share({ + title: 'erdbeerrechner 🍓💶', + url: url + }).catch(console.error); + } else { + navigator.clipboard.writeText(url); + alert('URL in die Zwischenablage kopiert!'); + } + } + - + + \ No newline at end of file diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000..96c134e --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,117 @@ + + + + + + + erdbeerhannah 🍓💶 - Start + + + + + + + + +
+

Willkommen beim erdbeerrechner 🍓💶

+

Erstelle deine eigene, anpassbare Kassen-Instanz für deinen Verkaufsstand.

+
+
+ + + Dieses Passwort wird für den /admin Bereich benötigt. +
+ +
+ + +
+ Version: {{ version }} +
+
+ + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..dbd8d79 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,42 @@ + + + + + + + erdbeerhannah 🍓💶 - Login + + + + + + + +
+

Admin Login

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ + Zurück zur + Kasse +
+
+ + + + \ No newline at end of file