From 7a2607e777385ca5b4d4e4c0f1cb900c4e793575 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:36:32 +0200 Subject: [PATCH 01/76] Fix gedeelde receipt-state per sessie --- plugins/receipt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/receipt.py b/plugins/receipt.py index 3947b9c..bf1e312 100644 --- a/plugins/receipt.py +++ b/plugins/receipt.py @@ -8,6 +8,8 @@ class receipt: def __init__(self, SID, master): self.master = master self.SID = SID + self.receipt = [] + self.totals = {} def is_empty(self): print("hooi", self.receipt) From 25080f56cae5a1c193d5b0df6d90d0fcb12630c1 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:37:01 +0200 Subject: [PATCH 02/76] Fix prompt logging voor Python 3 strings --- plugins/log.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/log.py b/plugins/log.py index c074c4e..f2293c6 100644 --- a/plugins/log.py +++ b/plugins/log.py @@ -55,5 +55,8 @@ def input(self, text): pass def pre_input(self, text): - self.log("PROMPT", self.master.prompt.decode() + " >> " + text) + prompt = self.master.prompt + if isinstance(prompt, bytes): + prompt = prompt.decode() + self.log("PROMPT", prompt + " >> " + text) # self.master.send_message(False,'log',self.master.prompt+" >> "+text) From 033d1c61da5763b9cca2a3b119f3a0c608d2d9fe Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:37:51 +0200 Subject: [PATCH 03/76] Voorkom alias-mutatie bij wegschrijven producten --- plugins/market.py | 3 +-- plugins/products.py | 3 +-- tests/plugins/test_market.py | 1 + tests/plugins/test_products.py | 2 ++ 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index 78666b0..eb31050 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -51,8 +51,7 @@ def writeproducts(self): f.write("# " + group + "\n") for prod in groupvalue: product = self.products[prod] - names = product["aliases"] - names.insert(0, prod) + names = [prod] + product["aliases"] f.write( "%-58s %7.2f %s\n" % ( diff --git a/plugins/products.py b/plugins/products.py index da87ef9..b0d42f4 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -54,8 +54,7 @@ def writeproducts(self): for group in self.groups: # pylint: disable=consider-using-dict-items f.write("# " + group + "\n") for prod in self.groups[group]: - names = self.products[prod]["aliases"] - names.insert(0, prod) + names = [prod] + self.products[prod]["aliases"] f.write( "%-58s %7.2f %s\n" % ( diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index 27352bb..3422b25 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -64,6 +64,7 @@ def test_writeproducts(self): call("\n"), ] ) + assert self.market.products["product1"]["aliases"] == ["alias1"] def test_lookupprod(self): self.market.products = {"product1": "details1"} diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 5a1fe80..c78aa88 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -46,6 +46,8 @@ def test_writeproducts(self): with patch("builtins.open", mock_open()) as mocked_file: self.products.writeproducts() mocked_file().write.assert_called() + self.assertEqual(self.products.products["product1"]["aliases"], ["alias1"]) + self.assertEqual(self.products.products["product2"]["aliases"], []) def test_lookupprod(self): self.products.products = {"product1": {}} From 5b383a9ed9c4fb8869be223118f3c4cc9aeab1f9 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:39:00 +0200 Subject: [PATCH 04/76] Reset state bij opnieuw inlezen datafiles --- plugins/accounts.py | 3 ++- plugins/market.py | 2 ++ plugins/products.py | 3 +++ plugins/stock.py | 3 ++- tests/plugins/test_accounts.py | 4 ++++ tests/plugins/test_market.py | 4 ++++ tests/plugins/test_products.py | 14 ++++++++++++-- tests/plugins/test_stock.py | 4 ++++ 8 files changed, 33 insertions(+), 4 deletions(-) diff --git a/plugins/accounts.py b/plugins/accounts.py index 1348f94..3b0ef55 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -36,6 +36,8 @@ def get_last_updated_accounts(self): # Internal functions def readaccounts(self): + self.accounts = {} + self.aliases = {} with codecs.open("data/revbank.accounts", "r", "utf-8") as f: lines = f.readlines() for line in lines: @@ -46,7 +48,6 @@ def readaccounts(self): } with codecs.open("data/revbank.aliases", "r", "utf-8") as f: y = f.readlines() - self.aliases = {} for x in y: s = x.split(" ") if len(s) == 2: diff --git a/plugins/market.py b/plugins/market.py index eb31050..ceddcd2 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -23,6 +23,8 @@ def help(self): } def readproducts(self): + self.products = {} + self.aliases = {} with open("data/revbank.market", "r", encoding="utf-8") as f: lines = f.readlines() print("ok", lines) diff --git a/plugins/products.py b/plugins/products.py index b0d42f4..360c604 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -23,6 +23,9 @@ def help(self): } def readproducts(self): + self.products = {} + self.aliases = {} + self.groups = {} groupname = "" with open("data/revbank.products", "r", encoding="utf-8") as f: lines = f.readlines() diff --git a/plugins/stock.py b/plugins/stock.py index 01b0391..697a461 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -20,6 +20,8 @@ def help(self): } def readstock(self): + self.stock = {} + self.stockalias = {} with open("data/revbank.stock", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: @@ -28,7 +30,6 @@ def readstock(self): name = parts[0] self.stock[name] = int(parts[1]) f.close() - self.stockalias = {} with open("data/revbank.stockalias", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index bb788ee..b3a2d87 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -46,6 +46,8 @@ def test_updateaccount_ignores_cash(): def test_readaccounts(): master_mock = Mock() acc = accounts("SID", master_mock) + acc.accounts = {"stale_user": {"amount": 1.0, "lastupdate": "old"}} + acc.aliases = {"stale_alias": "stale_user"} # Correctly formatted mock data mock_accounts_data = "user1 100.0 2021-01-01\nuser2 200.0 2021-01-02" @@ -69,6 +71,8 @@ def custom_mock_open(filename, _bla, _bla2): # Assertions for aliases file assert acc.aliases["alias1"] == "user1" assert acc.aliases["alias2"] == "user2" + assert "stale_user" not in acc.accounts + assert "stale_alias" not in acc.aliases def test_writeaccount(): diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index 3422b25..ad4c13f 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -10,6 +10,8 @@ def setup_method(self, method): self.market = market_module.market("SID", self.master_mock) def test_readproducts(self): + self.market.products = {"stale_product": {}} + self.market.aliases = {"stale_alias": "stale_product"} market_data = "user1 product1 2.50 1.00 description1\n" mo = mock_open(read_data=market_data) with patch("builtins.open", mo): @@ -18,6 +20,8 @@ def test_readproducts(self): assert "product1" in self.market.products assert self.market.products["product1"]["price"] == 2.50 assert self.market.products["product1"]["description"] == "description1" + assert "stale_product" not in self.market.products + assert "stale_alias" not in self.market.aliases def test_instances_do_not_share_state(self): self.market.products["product1"] = {} diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index c78aa88..24fd42b 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -9,6 +9,9 @@ def setUp(self): self.products = ProductsModule.products("SID", self.master_mock) def test_readproducts(self): + self.products.products = {"stale_product": {}} + self.products.aliases = {"stale_alias": "stale_product"} + self.products.groups = {"StaleGroup": ["stale_product"]} product_data = "# Group1\nproduct1,alias1 2.50 Description1\n# Group2\nproduct2 1.50 Description2\n" with patch("builtins.open", mock_open(read_data=product_data)): self.products.readproducts() @@ -16,6 +19,9 @@ def test_readproducts(self): self.assertIn("product2", self.products.products) self.assertIn("Group1", self.products.groups) self.assertIn("Group2", self.products.groups) + self.assertNotIn("stale_product", self.products.products) + self.assertNotIn("stale_alias", self.products.aliases) + self.assertNotIn("StaleGroup", self.products.groups) def test_help(self): self.assertEqual( @@ -178,14 +184,18 @@ def test_savealias_valid_alias(self): "product1": {"aliases": [], "price": 42, "description": "aa"} } self.products.aliasprod = "product1" - with patch("builtins.open", mock_open()): + with patch.object(self.products, "readproducts"), patch.object( + self.products, "writeproducts" + ): assert self.products.savealias("alias2") assert "alias2" in self.products.products["product1"]["aliases"] def test_saveprice_valid_price(self): self.products.products = {"product1": {"price": 2.5}} self.products.priceprod = "product1" - with patch("builtins.open", mock_open()): + with patch.object(self.products, "readproducts"), patch.object( + self.products, "writeproducts" + ): assert self.products.saveprice("3.0") print("hooi", self.products.products) assert self.products.products["product1"]["price"] == 3.0 diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index 458beb5..eeabdd4 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -152,6 +152,8 @@ def test_stock_startup_with_patched_readstock(): def test_stock_readstock(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) + stock.stock = {"stale_product": 99} + stock.stockalias = {"stale_alias": {"prod": "stale_product", "multi": 1}} stock_data = "product1 10\nproduct2 20" stock_alias_data = "alias1 product1 2\nalias2 product2 3" @@ -168,6 +170,8 @@ def custom_mock_open_read(filename, *args, **kwargs): "alias1": {"prod": "product1", "multi": 2}, "alias2": {"prod": "product2", "multi": 3}, } + assert "stale_product" not in stock.stock + assert "stale_alias" not in stock.stockalias def test_stock_writestock(): From 912dfd891c51aeccc22ec4f9be8382fdbe48d2b1 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:39:53 +0200 Subject: [PATCH 05/76] Fix cash-bonnen zonder account lookup --- plugins/POS.py | 5 ++++- tests/plugins/test_POS.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/plugins/POS.py b/plugins/POS.py index fd4ebb2..09a82ad 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -101,7 +101,10 @@ def hook_undo(self, args): def makebon(self, user): BON = PRINTER + LARGE + CENTER + LOGO + is_cash = user == "cash" if ( + not is_cash + and self.master.accounts.accounts[user]["amount"] + self.master.receipt.totals[user] ) < -13.37: @@ -141,7 +144,7 @@ def makebon(self, user): BON += b" " + b"-" * 38 + b"\n" BON += b" %-26s% 12.2f\n" % (b"Totaal", self.master.receipt.totals[user]) BON += b"\nU bent geholpen door: %s\n" % user.encode() - if user != b"cash": + if not is_cash: BON += b"\n Nieuw saldo: %5.2f\n" % ( self.master.accounts.accounts[user]["amount"] ) diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index b39ecb6..30cebc5 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -94,6 +94,28 @@ def test_makebon_low_balance_warning(self): assert b"SALDO TE LAAG" in bon + def test_makebon_cash_does_not_require_account(self): + self.POS.master.receipt = Mock( + receipt=[ + { + "product": "test", + "beni": "cash", + "count": 1, + "total": 1.0, + "description": "cash sale", + } + ], + totals={"cash": -100}, + ) + self.POS.master.transID = 42 + self.POS.master.accounts.accounts = {} + + bon = self.POS.makebon("cash") + + assert b"cash sale" in bon + assert b"Nieuw saldo" not in bon + assert b"SALDO TE LAAG" not in bon + def test_hook_checkout(self): with patch.object(self.POS, "drawer"): self.POS.hook_checkout("cash") From 5dc778e26a54bcc14684bfa8cdb056a72425d6f3 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:40:18 +0200 Subject: [PATCH 06/76] Fix MQTT topic guard voor korte inputtopics --- kassa.py | 2 +- tests/test_kassa.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/kassa.py b/kassa.py index 706f2af..5f26318 100755 --- a/kassa.py +++ b/kassa.py @@ -225,7 +225,7 @@ def on_message(client, _userdata, msg): # print(msg.topic+" "+str(msg.payload)) elms = msg.topic.split("/") msg = msg.payload - if len(elms) < 3: + if len(elms) < 5: return # try: run_session(client, elms[3], elms[4], msg) diff --git a/tests/test_kassa.py b/tests/test_kassa.py index f6a5b29..dd95230 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -428,11 +428,12 @@ def test_run_session_unhandled_action(capsys): def test_on_message_short_topic_is_ignored(): client_mock = Mock() msg_mock = Mock() - msg_mock.topic = "short/topic" msg_mock.payload = b"data" with patch("kassa.run_session") as mock_run_session: - kassa.on_message(client_mock, None, msg_mock) + for topic in ("short/topic", "hack42bar/input/session", "hack42bar/input/session/1234"): + msg_mock.topic = topic + kassa.on_message(client_mock, None, msg_mock) mock_run_session.assert_not_called() From f69dfff11aea03c3fc9ba50f741bea130d740d41 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:40:45 +0200 Subject: [PATCH 07/76] Isoleer Session state per instantie --- kassa.py | 13 +++++++++++++ tests/test_kassa.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/kassa.py b/kassa.py index 5f26318..dd0a5e3 100755 --- a/kassa.py +++ b/kassa.py @@ -40,6 +40,19 @@ def __init__(self, SID, client): self.SID = SID self.client = client self.plugins = {} + self.counter = 0 + self.nextcall = {} + self.prompt = "" + self.help = {} + self.cache = {} + self.iets = 0 + self.buttons = {} + self.stock = None + self.POS = None + self.log = None + self.receipt = None + self.accounts = None + self.products = None def startup(self): print("Startup", self.SID) diff --git a/tests/test_kassa.py b/tests/test_kassa.py index dd95230..3ba8cd3 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -398,6 +398,21 @@ def test_send_message_updates_prompt_buttons_and_skips_cached_long_topic(): assert client_mock.publish.call_count == 3 +def test_session_mutable_state_is_per_instance(): + first = kassa.Session("SID1", Mock()) + second = kassa.Session("SID2", Mock()) + + first.help["command"] = "description" + first.cache["topic"] = "message" + first.nextcall["function"] = "next" + first.buttons["special"] = "custom" + + assert second.help == {} + assert second.cache == {} + assert second.nextcall == {} + assert second.buttons == {} + + def test_get_session_reuses_existing_session(): client_mock = Mock() existing_session = Mock() From f6e68f64961bfed4c4777374d6dab2966e3258c3 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:42:08 +0200 Subject: [PATCH 08/76] Fix market beheercommando's en bestandsformaat --- plugins/market.py | 59 +++++++++++++++++++++++++++--------- tests/plugins/test_market.py | 46 ++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index ceddcd2..faf7ebb 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -48,22 +48,20 @@ def readproducts(self): def writeproducts(self): with open("data/revbank.market", "w", encoding="utf-8") as f: - print(self.groups) - for group, groupvalue in self.groups.items(): - f.write("# " + group + "\n") - for prod in groupvalue: - product = self.products[prod] - names = [prod] + product["aliases"] - f.write( - "%-58s %7.2f %s\n" - % ( - ",".join(names), - product["price"], - product["description"], - ) + f.write("# Price =\n") + f.write("# Seller Barcode Seller + Space Description\n\n") + for prod, product in self.products.items(): + names = [prod] + product["aliases"] + f.write( + "%-10s %-30s %7.2f %7.2f %s\n" + % ( + product.get("user", self.newprodgroup), + ",".join(names), + product["price"], + product.get("space", 0.0), + product["description"], ) - f.write("\n") - f.close() + ) def __init__(self, SID, master): self.master = master @@ -145,6 +143,25 @@ def setprice(self, text): "Unknown product;What product do you want change price?", ) + def delmarket(self, text): + if text == "abort": + return self.master.callhook("abort", None) + self.readproducts() + prod = self.lookupprod(text) + if prod: + del self.products[prod] + self.aliases = {} + for product_name, product in self.products.items(): + for alias in product["aliases"]: + self.aliases[alias] = product_name + self.writeproducts() + return True + return self.messageandbuttons( + "delmarket", + "keyboard", + "Unknown market product;What market product do you want to remove?", + ) + def saveprice(self, text): if text == "abort": return self.master.callhook("abort", None) @@ -330,6 +347,18 @@ def input(self, text): True, "buttons", json.dumps({"special": "custom", "custom": custom}) ) return True + if text == "addmarket": + return self.messageandbuttons( + "addalias", "keyboard", "What market product do you want to alias?" + ) + if text == "changemarket": + return self.messageandbuttons( + "setprice", "keyboard", "What market product to change price for?" + ) + if text == "delmarket": + return self.messageandbuttons( + "delmarket", "keyboard", "What market product do you want to remove?" + ) # elif text=="aliasproduct": # return self.messageandbuttons('addalias','products','What product do you want to alias?') diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index ad4c13f..05b8c7c 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -47,25 +47,28 @@ def test_writeproducts(self): "product1": { "aliases": ["alias1"], "price": 2.50, + "space": 1.00, "description": "description1", + "user": "user1", } } - self.market.groups = {"group1": ["product1"]} mo = mock_open() with patch("builtins.open", mo): self.market.writeproducts() mo.assert_called_with("data/revbank.market", "w", encoding="utf-8") handle = mo() - expected_product = "%-58s %7.2f %s\n" % ( + expected_product = "%-10s %-30s %7.2f %7.2f %s\n" % ( + "user1", "product1,alias1", 2.50, + 1.00, "description1", ) handle.write.assert_has_calls( [ - call("# group1\n"), + call("# Price =\n"), + call("# Seller Barcode Seller + Space Description\n\n"), call(expected_product), - call("\n"), ] ) assert self.market.products["product1"]["aliases"] == ["alias1"] @@ -140,13 +143,13 @@ def test_setprice_abort(self): def test_saveprice(self): self.market.priceprod = "product1" self.market.newprodprice = 3.0 - self.market.products = {"product1": {"price": 2.5}} - market_data = "user1 product1,alias1,alias2 2.50 1.00 description1\n" - mo = mock_open(read_data=market_data) - with patch("builtins.open", mo): + self.market.products = {"product1": {"price": 2.5, "aliases": []}} + with patch.object(self.market, "readproducts"), patch.object( + self.market, "writeproducts" + ): self.market.saveprice("3.0") print(self.market.products) - assert self.market.products["product1"]["price"] == 2.5 + assert self.market.products["product1"]["price"] == 3.0 def test_addproductgroup(self): self.market.newprod = "product1" @@ -236,6 +239,19 @@ def test_addproductgroup_new_group(self): assert self.market.addproductgroup("group1") assert "group1" in self.market.groups + def test_delmarket_removes_product_and_aliases(self): + market_data = ( + "user1 product1,alias1 2.50 1.00 description1\n" + "user2 product2,alias2 3.50 0.50 description2\n" + ) + mo = mock_open(read_data=market_data) + with patch("builtins.open", mo): + assert self.market.delmarket("alias1") + + assert "product1" not in self.market.products + assert "alias1" not in self.market.aliases + assert "product2" in self.market.products + def test_addproductprice_invalid_price(self): self.market.newprod = "product1" self.market.newproddesc = "description" @@ -287,3 +303,15 @@ def test_input_market_lists_products(self): assert json.loads(payload)["custom"] == [ {"text": "product1", "display": "Description", "right": "2.50 (0.50)"} ] + + def test_input_market_admin_commands(self): + assert self.market.input("addmarket") + self.master_mock.donext.assert_called_with(self.market, "addalias") + + self.master_mock.reset_mock() + assert self.market.input("changemarket") + self.master_mock.donext.assert_called_with(self.market, "setprice") + + self.master_mock.reset_mock() + assert self.market.input("delmarket") + self.master_mock.donext.assert_called_with(self.market, "delmarket") From 95a44973197be62296166af59a2157054b564d86 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:42:54 +0200 Subject: [PATCH 09/76] Voorkom samenvoegen van verschillende receipt-producten --- plugins/receipt.py | 1 + tests/plugins/test_receipt.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/plugins/receipt.py b/plugins/receipt.py index bf1e312..8103a7b 100644 --- a/plugins/receipt.py +++ b/plugins/receipt.py @@ -84,6 +84,7 @@ def add( # pylint: disable=too-many-arguments and self.receipt[r]["value"] == Value and self.receipt[r]["beni"] == Beni and self.receipt[r]["Lose"] == Lose + and self.receipt[r]["product"] == Prod ): self.receipt[r]["count"] += Count self.receipt[r]["total"] = ( diff --git a/tests/plugins/test_receipt.py b/tests/plugins/test_receipt.py index 1fda8ff..ae85a3e 100644 --- a/tests/plugins/test_receipt.py +++ b/tests/plugins/test_receipt.py @@ -100,6 +100,7 @@ def test_add_existing_item(self): "beni": "user1", "Lose": True, "count": 1, + "product": "prod1", } ] with patch.object(self.receipt_instance.master, "send_message"): @@ -108,6 +109,21 @@ def test_add_existing_item(self): ) self.assertEqual(self.receipt_instance.receipt[0]["count"], 2) + def test_add_same_description_different_product_keeps_separate_lines(self): + with patch.object(self.receipt_instance.master, "send_message"): + self.assertTrue( + self.receipt_instance.add(True, 5.0, "desc", 1, "user1", "prod1") + ) + self.assertTrue( + self.receipt_instance.add(True, 5.0, "desc", 1, "user1", "prod2") + ) + + self.assertEqual(len(self.receipt_instance.receipt), 2) + self.assertEqual( + [line["product"] for line in self.receipt_instance.receipt], + ["prod1", "prod2"], + ) + def test_input_remove(self): self.receipt_instance.receipt = [ {"description": "item1"}, From 8cdf52028359da167a8534bcd40f3670654a620b Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:43:41 +0200 Subject: [PATCH 10/76] Bewaar undo-transacties als snapshot --- plugins/undo.py | 5 +++-- tests/plugins/test_undo.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/plugins/undo.py b/plugins/undo.py index 42a91a3..d3413f0 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +import copy import pickle import time import traceback @@ -23,8 +24,8 @@ def help(self): def hook_checkout(self, text): self.loadundo() self.undo[self.master.transID] = { - "totals": self.master.receipt.totals, - "receipt": self.master.receipt.receipt, + "totals": copy.deepcopy(self.master.receipt.totals), + "receipt": copy.deepcopy(self.master.receipt.receipt), "beni": text, } self.writeundo() diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index be9a705..6ed9323 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -30,6 +30,34 @@ def test_undo_hook_checkout(): } +def test_undo_hook_checkout_stores_snapshot(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + master_mock.transID = 123 + master_mock.receipt = Mock( + totals={"user": -2.5}, + receipt=[ + { + "Lose": True, + "value": 2.5, + "description": "Product", + "count": 1, + "beni": "user", + "product": "product1", + } + ], + ) + + with patch.object(undo, "loadundo"), patch.object(undo, "writeundo"): + undo.hook_checkout("user") + + master_mock.receipt.totals["user"] = 0 + master_mock.receipt.receipt[0]["count"] = 99 + + assert undo.undo[123]["totals"] == {"user": -2.5} + assert undo.undo[123]["receipt"][0]["count"] == 1 + + def test_undo_hook_undo(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) From a585725ccd88beb2895d866026167f448d106b67 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:44:52 +0200 Subject: [PATCH 11/76] Serialiseer background git commits --- plugins/git.py | 11 +++++++++-- tests/plugins/test_git.py | 14 ++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/plugins/git.py b/plugins/git.py index e908ac3..0e3783a 100644 --- a/plugins/git.py +++ b/plugins/git.py @@ -1,15 +1,22 @@ # -*- coding: utf-8 -*- -import os +import subprocess import threading class git: + lock = threading.Lock() + def __init__(self, SID, master): self.master = master self.SID = SID def background(self): - os.system("cd data && git commit -m " + str(self.master.transID) + " .") + with self.lock: + subprocess.run( + ["git", "commit", "-m", str(self.master.transID), "."], + cwd="data", + check=False, + ) def hook_post_checkout(self, _text): threading.Thread(target=self.background).start() diff --git a/tests/plugins/test_git.py b/tests/plugins/test_git.py index 9ad6f39..a493293 100644 --- a/tests/plugins/test_git.py +++ b/tests/plugins/test_git.py @@ -1,6 +1,5 @@ from unittest.mock import Mock, patch import plugins.git as git_module -import threading class TestGit: @@ -9,12 +8,19 @@ def setup_method(self): self.git = git_module.git("SID", self.master_mock) def test_background(self): - with patch("plugins.git.os.system") as mock_system: + with patch("plugins.git.subprocess.run") as mock_run: self.git.background() - mock_system.assert_called_with( - "cd data && git commit -m " + str(self.master_mock.transID) + " ." + mock_run.assert_called_with( + ["git", "commit", "-m", str(self.master_mock.transID), "."], + cwd="data", + check=False, ) + def test_background_uses_shared_lock(self): + other = git_module.git("SID2", Mock()) + + assert self.git.lock is other.lock + def test_hook_post_checkout(self): with patch("plugins.git.threading.Thread") as mock_thread: self.git.hook_post_checkout(None) From 8730466147720823334fda4a10dfdc9754a945cc Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:46:46 +0200 Subject: [PATCH 12/76] Maak account en stock writes atomair --- plugins/accounts.py | 49 +++++++++++++++++++++++++--------- plugins/stock.py | 34 ++++++++++++++++++++--- tests/plugins/test_accounts.py | 41 +++++++++++----------------- tests/plugins/test_stock.py | 14 ++++++---- 4 files changed, 92 insertions(+), 46 deletions(-) diff --git a/plugins/accounts.py b/plugins/accounts.py index 3b0ef55..36c889a 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -1,14 +1,36 @@ import json +import os +import tempfile +import threading import time import codecs +def _atomic_write(path, lines): + directory = os.path.dirname(path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=os.path.basename(path) + ".", dir=directory, text=True + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + for line in lines: + f.write(line) + os.replace(tmp_path, path) + except: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise + + class accounts: accounts = {} aliases = {} members = [] newaccount = "" adduseralias = "" + write_lock = threading.Lock() def __init__(self, SID, master): self.master = master @@ -64,19 +86,22 @@ def updateaccount(self, usr, value): self.master.callhook("balance", (usr, had, has, self.master.transID)) def writeaccount(self): - with open("data/revbank.accounts", "w", encoding="utf-8") as f: - for usr, account in self.accounts.items(): - f.write( + with self.write_lock: + _atomic_write( + "data/revbank.accounts", + [ "%-18s %+7.2f %s\n" - % ( - usr, - round(account["amount"], 2), - account["lastupdate"], - ) - ) - with open("data/revbank.aliases", "w", encoding="utf-8") as f: - for usr, alias in self.aliases.items(): - f.write("%s %s\n" % (usr, alias)) + % (usr, round(account["amount"], 2), account["lastupdate"]) + for usr, account in self.accounts.items() + ], + ) + _atomic_write( + "data/revbank.aliases", + [ + "%s %s\n" % (usr, alias) + for usr, alias in self.aliases.items() + ], + ) # Hooks def hook_balance(self, args): diff --git a/plugins/stock.py b/plugins/stock.py index 697a461..2ffe366 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -1,10 +1,32 @@ import json +import os +import tempfile +import threading + + +def _atomic_write(path, lines): + directory = os.path.dirname(path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=os.path.basename(path) + ".", dir=directory, text=True + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + for line in lines: + f.write(line) + os.replace(tmp_path, path) + except: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise class stock: stock = {} prod = "" stockalias = "" + write_lock = threading.Lock() def __init__(self, SID, master): self.master = master @@ -74,10 +96,14 @@ def hook_checkout(self, _text): self.writestock() def writestock(self): - with open("data/revbank.stock", "w", encoding="utf-8") as f: - for prod, product in self.stock.items(): - f.write("%-16s %+9d\n" % (prod, product)) - f.close() + with self.write_lock: + _atomic_write( + "data/revbank.stock", + [ + "%-16s %+9d\n" % (prod, product) + for prod, product in self.stock.items() + ], + ) def voorraad_amount(self, text): try: diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index b3a2d87..4da7db5 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -86,33 +86,24 @@ def test_writeaccount(): } acc.aliases = {"alias1": "user1", "alias2": "user2"} - # Mock file handles - mock_file_handles = { - "data/revbank.accounts": mock_open(), - "data/revbank.aliases": mock_open(), - } - - # Custom side effect function for mock_open - def custom_open_mock(file_name, *args, **kwargs): - return mock_file_handles[file_name]() - - with patch("builtins.open", side_effect=custom_open_mock): + with patch("plugins.accounts._atomic_write") as mock_atomic_write: acc.writeaccount() - # Assertions for accounts file write - accounts_file_handle = mock_file_handles["data/revbank.accounts"] - accounts_file_handle().write.assert_has_calls( - [ - call("user1 +100.00 2021-01-01\n"), - call("user2 +200.00 2021-01-02\n"), - ] - ) - - # Assertions for aliases file write - aliases_file_handle = mock_file_handles["data/revbank.aliases"] - aliases_file_handle().write.assert_has_calls( - [call("alias1 user1\n"), call("alias2 user2\n")] - ) + mock_atomic_write.assert_has_calls( + [ + call( + "data/revbank.accounts", + [ + "user1 +100.00 2021-01-01\n", + "user2 +200.00 2021-01-02\n", + ], + ), + call( + "data/revbank.aliases", + ["alias1 user1\n", "alias2 user2\n"], + ), + ] + ) def test_hook_balance(): diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index eeabdd4..0803673 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -179,12 +179,16 @@ def test_stock_writestock(): stock = stock_module.stock("SID", master_mock) stock.stock = {"product1": 10, "product2": 20} - def custom_mock_open_write(filename, *args, **kwargs): - return mock_open()() - - with patch("builtins.open", side_effect=custom_mock_open_write) as mock_file: + with patch.object(stock_module, "_atomic_write") as mock_atomic_write: stock.writestock() - mock_file.assert_called_with("data/revbank.stock", "w", encoding="utf-8") + + mock_atomic_write.assert_called_once_with( + "data/revbank.stock", + [ + "product1 +10\n", + "product2 +20\n", + ], + ) def test_stock_input_voorraad(): From bc68d64221ad80d97162b18afb5ed42c7697189c Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:48:18 +0200 Subject: [PATCH 13/76] Vervang pickle writes door JSON met legacy load --- plugins/POS.py | 41 ++++++++++++++++++++++++++++++++++---- plugins/undo.py | 12 +++++++---- tests/plugins/test_POS.py | 32 +++++++++++++++++++++++------ tests/plugins/test_undo.py | 17 ++++++++++++++-- 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index 09a82ad..c433706 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import base64 import traceback import json import time @@ -237,17 +238,49 @@ def writebons(self): fk = sorted(self.bonnetjes.keys()) del self.bonnetjes[fk[0]] - with open("data/revbank.POS", "wb") as output: - pickle.dump(self.bonnetjes, output) + with open("data/revbank.POS", "w", encoding="utf-8") as output: + json.dump(self.serialize_bons(self.bonnetjes), output) def loadbons(self): try: with open("data/revbank.POS", "rb") as f: - self.bonnetjes = pickle.load(f) - f.close() + data = f.read() + try: + loaded = json.loads(data.decode("utf-8")) + self.bonnetjes = self.deserialize_bons(loaded) + except (UnicodeDecodeError, json.JSONDecodeError): + self.bonnetjes = pickle.loads(data) except: pass + def serialize_bons(self, bonnetjes): + output = {} + for bonID, bon in bonnetjes.items(): + serialized = dict(bon) + bon_data = serialized["bon"] + if isinstance(bon_data, bytes): + serialized["bon"] = { + "encoding": "base64", + "data": base64.b64encode(bon_data).decode("ascii"), + } + else: + serialized["bon"] = {"encoding": "text", "data": bon_data} + output[str(bonID)] = serialized + return output + + def deserialize_bons(self, bonnetjes): + output = {} + for bonID, bon in bonnetjes.items(): + deserialized = dict(bon) + bon_data = deserialized["bon"] + if isinstance(bon_data, dict): + if bon_data.get("encoding") == "base64": + deserialized["bon"] = base64.b64decode(bon_data["data"]) + else: + deserialized["bon"] = bon_data.get("data", "") + output[int(bonID)] = deserialized + return output + def listbons(self): self.loadbons() custom = [] diff --git a/plugins/undo.py b/plugins/undo.py index d3413f0..21f6b9f 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -53,14 +53,18 @@ def writeundo(self): fk = sorted(self.undo.keys()) del self.undo[fk[0]] - with open("data/revbank.UNDO", "wb") as output: - pickle.dump(self.undo, output) + with open("data/revbank.UNDO", "w", encoding="utf-8") as output: + json.dump(self.undo, output) def loadundo(self): try: with open("data/revbank.UNDO", "rb") as f: - self.undo = pickle.load(f) - f.close() + data = f.read() + try: + loaded = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + loaded = pickle.loads(data) + self.undo = {int(transID): value for transID, value in loaded.items()} except: pass diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 30cebc5..c8d94a3 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -192,18 +192,38 @@ def test_selectbon_abort_and_missing_int(self): mock_listbons.assert_called_once() def test_writebons(self): - with patch("builtins.open", new_callable=mock_open): - self.POS.bonnetjes = {123: {"bon": "Test"}} + with patch("builtins.open", mock_open()) as mocked_file: + self.POS.bonnetjes = {123: {"totals": {"user": 1.0}, "bon": b"Test"}} self.POS.writebons() - # Assert file operation - - def test_loadbons(self): + mocked_file.assert_called_with("data/revbank.POS", "w", encoding="utf-8") + written = "".join( + call.args[0] for call in mocked_file().write.call_args_list + ) + loaded = json.loads(written) + assert loaded["123"]["bon"]["encoding"] == "base64" + + def test_loadbons_pickle_backwards_compatible(self): with patch( "builtins.open", mock_open(read_data=pickle.dumps({123: {"bon": "Test"}})) ): self.POS.loadbons() assert 123 in self.POS.bonnetjes + def test_loadbons_json(self): + data = json.dumps( + { + "123": { + "totals": {"user": 1.0}, + "bon": {"encoding": "base64", "data": "VGVzdA=="}, + } + } + ).encode("utf-8") + with patch("builtins.open", mock_open(read_data=data)): + self.POS.loadbons() + assert self.POS.bonnetjes == { + 123: {"totals": {"user": 1.0}, "bon": b"Test"} + } + def test_listbons(self): self.POS.bonnetjes = {123: {"totals": {"user": 1.0}, "bon": "Test"}} with patch.object(self.POS, "loadbons"), patch.object( @@ -292,7 +312,7 @@ def test_selectbon_invalid_bonID(self): mock_traceback.assert_called() def test_writebons_max_receipts(self): - with patch("builtins.open", new_callable=mock_open()): + with patch("builtins.open", mock_open()): self.POS.bonnetjes = {i: {"bon": "Test"} for i in range(60)} self.POS.writebons() assert len(self.POS.bonnetjes) == 50 diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index 6ed9323..f65c481 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -97,11 +97,14 @@ def test_undo_writeundo(): with patch("builtins.open", mock_open()) as mock_file: undo.writeundo() assert len(undo.undo) == 50 - mock_file.assert_called_with("data/revbank.UNDO", "wb") + mock_file.assert_called_with("data/revbank.UNDO", "w", encoding="utf-8") mock_file().write.assert_called() + written = "".join(call.args[0] for call in mock_file().write.call_args_list) + loaded = json.loads(written) + assert len(loaded) == 50 -def test_undo_loadundo(): +def test_undo_loadundo_pickle_backwards_compatible(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) mock_data = pickle.dumps({1: "data"}) @@ -111,6 +114,16 @@ def test_undo_loadundo(): assert undo.undo == {1: "data"} +def test_undo_loadundo_json(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + mock_data = json.dumps({"1": "data"}).encode("utf-8") + + with patch("builtins.open", mock_open(read_data=mock_data)): + undo.loadundo() + assert undo.undo == {1: "data"} + + def test_undo_loadundo_ignores_errors(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) From bf277565f7b36d778358fd5ae0ec14e8fe25ef03 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:49:13 +0200 Subject: [PATCH 14/76] Maak product en market writes atomair --- plugins/market.py | 33 +++++++++++++++++++++++++++++---- plugins/products.py | 33 ++++++++++++++++++++++++++++----- tests/plugins/test_market.py | 34 ++++++++++++++++------------------ tests/plugins/test_products.py | 15 +++++++++++++-- 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index faf7ebb..f695a93 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -1,5 +1,26 @@ import json +import os import re +import tempfile +import threading + + +def _atomic_write(path, lines): + directory = os.path.dirname(path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=os.path.basename(path) + ".", dir=directory, text=True + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + for line in lines: + f.write(line) + os.replace(tmp_path, path) + except: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise class market: @@ -13,6 +34,7 @@ class market: newprodgroup = "" newproddesc = "" newprod = "" + write_lock = threading.Lock() def help(self): return { @@ -47,12 +69,14 @@ def readproducts(self): self.master.send_message(True, "market/" + prod, json.dumps(product)) def writeproducts(self): - with open("data/revbank.market", "w", encoding="utf-8") as f: - f.write("# Price =\n") - f.write("# Seller Barcode Seller + Space Description\n\n") + lines = [ + "# Price =\n", + "# Seller Barcode Seller + Space Description\n\n", + ] + with self.write_lock: for prod, product in self.products.items(): names = [prod] + product["aliases"] - f.write( + lines.append( "%-10s %-30s %7.2f %7.2f %s\n" % ( product.get("user", self.newprodgroup), @@ -62,6 +86,7 @@ def writeproducts(self): product["description"], ) ) + _atomic_write("data/revbank.market", lines) def __init__(self, SID, master): self.master = master diff --git a/plugins/products.py b/plugins/products.py index 360c604..6dc0406 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -1,5 +1,26 @@ import json +import os import re +import tempfile +import threading + + +def _atomic_write(path, lines): + directory = os.path.dirname(path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=os.path.basename(path) + ".", dir=directory, text=True + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + for line in lines: + f.write(line) + os.replace(tmp_path, path) + except: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise class products: @@ -14,6 +35,7 @@ class products: newprodgroup = "" newproddesc = "" newprod = "" + write_lock = threading.Lock() def help(self): return { @@ -53,12 +75,13 @@ def readproducts(self): print("readproducts done") def writeproducts(self): - with open("data/revbank.products", "w", encoding="utf-8") as f: + lines = [] + with self.write_lock: for group in self.groups: # pylint: disable=consider-using-dict-items - f.write("# " + group + "\n") + lines.append("# " + group + "\n") for prod in self.groups[group]: names = [prod] + self.products[prod]["aliases"] - f.write( + lines.append( "%-58s %7.2f %s\n" % ( ",".join(names), @@ -66,8 +89,8 @@ def writeproducts(self): self.products[prod]["description"], ) ) - f.write("\n") - f.close() + lines.append("\n") + _atomic_write("data/revbank.products", lines) def __init__(self, SID, master): self.master = master diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index 05b8c7c..4300b6a 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -52,25 +52,23 @@ def test_writeproducts(self): "user": "user1", } } - mo = mock_open() - with patch("builtins.open", mo): + with patch("plugins.market._atomic_write") as mock_atomic_write: self.market.writeproducts() - mo.assert_called_with("data/revbank.market", "w", encoding="utf-8") - handle = mo() - expected_product = "%-10s %-30s %7.2f %7.2f %s\n" % ( - "user1", - "product1,alias1", - 2.50, - 1.00, - "description1", - ) - handle.write.assert_has_calls( - [ - call("# Price =\n"), - call("# Seller Barcode Seller + Space Description\n\n"), - call(expected_product), - ] - ) + expected_product = "%-10s %-30s %7.2f %7.2f %s\n" % ( + "user1", + "product1,alias1", + 2.50, + 1.00, + "description1", + ) + mock_atomic_write.assert_called_once_with( + "data/revbank.market", + [ + "# Price =\n", + "# Seller Barcode Seller + Space Description\n\n", + expected_product, + ], + ) assert self.market.products["product1"]["aliases"] == ["alias1"] def test_lookupprod(self): diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 24fd42b..8ab807b 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -49,9 +49,20 @@ def test_writeproducts(self): }, } self.products.groups = {"Group1": ["product1"], "Group2": ["product2"]} - with patch("builtins.open", mock_open()) as mocked_file: + with patch("plugins.products._atomic_write") as mock_atomic_write: self.products.writeproducts() - mocked_file().write.assert_called() + mock_atomic_write.assert_called_once_with( + "data/revbank.products", + [ + "# Group1\n", + "%-58s %7.2f %s\n" + % ("product1,alias1", 2.50, "Description1"), + "\n", + "# Group2\n", + "%-58s %7.2f %s\n" % ("product2", 1.50, "Description2"), + "\n", + ], + ) self.assertEqual(self.products.products["product1"]["aliases"], ["alias1"]) self.assertEqual(self.products.products["product2"]["aliases"], []) From 4b054de5ee9888c7e9fec0f4e75eb8b48575dd2e Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:49:50 +0200 Subject: [PATCH 15/76] Isoleer pfand state bij laden --- plugins/pfand.py | 2 ++ tests/plugins/test_pfand.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/plugins/pfand.py b/plugins/pfand.py index 771a128..6773910 100644 --- a/plugins/pfand.py +++ b/plugins/pfand.py @@ -8,6 +8,7 @@ class pfand: def __init__(self, SID, master): self.master = master self.SID = SID + self.products = {} def help(self): return {"pfand": "Return deposit"} @@ -71,6 +72,7 @@ def input(self, text): return None def loadmarket(self): + self.products = {} with open("data/revbank.pfand", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: diff --git a/tests/plugins/test_pfand.py b/tests/plugins/test_pfand.py index 06a20c0..c4b7202 100644 --- a/tests/plugins/test_pfand.py +++ b/tests/plugins/test_pfand.py @@ -55,12 +55,19 @@ def test_input_other(self): assert self.pfand.input("other") is None def test_loadmarket(self): + self.pfand.products = {"stale": 9.0} market_data = "prod1 1.0\nprod2 2.0\n" mo = patch("builtins.open", mock_open(read_data=market_data)) with mo: self.pfand.loadmarket() assert self.pfand.products == {"prod1": 1.0, "prod2": 2.0} + def test_instances_do_not_share_state(self): + self.pfand.products["prod1"] = 1.0 + other = pfand_module.pfand("SID2", Mock()) + + assert other.products == {} + def test_hook_abort(self): with patch.object(self.pfand, "startup"): self.pfand.hook_abort(None) From b2a422280556a73bef9179fbaf6507bd69acb17d Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:50:33 +0200 Subject: [PATCH 16/76] Isoleer POS state per instantie --- plugins/POS.py | 3 +++ tests/plugins/test_POS.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/plugins/POS.py b/plugins/POS.py index c433706..d484f31 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -36,6 +36,9 @@ class POS: def __init__(self, SID, master): self.master = master self.SID = SID + self.bonnetjes = {} + self.ser = None + self.lastbonID = 0 def open(self): if self.ser is not None: diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index c8d94a3..8ab7bf6 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -21,6 +21,17 @@ def test_open_when_already_open(self): self.POS.ser.write.assert_not_called() + def test_instances_do_not_share_state(self): + self.POS.bonnetjes[123] = {"bon": b"Test"} + self.POS.ser = Mock() + self.POS.lastbonID = 123 + + other = POS_module.POS("SID2", Mock()) + + assert other.bonnetjes == {} + assert other.ser is None + assert other.lastbonID == 0 + def test_help_and_hook_addremove(self): assert self.POS.help() == { "bon": "Print Receipt", From 50e827058a3eccc137fe552ab834d786366c46a1 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:51:30 +0200 Subject: [PATCH 17/76] Isoleer transactieflow state per instantie --- plugins/declaratie.py | 7 +++++++ plugins/give.py | 3 +++ plugins/take.py | 4 ++++ tests/plugins/test_declaratie.py | 19 +++++++++++++++++++ tests/plugins/test_give.py | 11 +++++++++++ tests/plugins/test_take.py | 16 ++++++++++++++++ 6 files changed, 60 insertions(+) diff --git a/plugins/declaratie.py b/plugins/declaratie.py index b6447f7..e68ec5a 100644 --- a/plugins/declaratie.py +++ b/plugins/declaratie.py @@ -16,6 +16,13 @@ class declaratie: def __init__(self, SID, master): self.master = master self.SID = SID + self.wie = "" + self.value = 0 + self.reden = "" + self.ascash = 0 + self.asbank = 0 + self.asbar = 0 + self.soort = "" def help(self): return { diff --git a/plugins/give.py b/plugins/give.py index e604d7d..5f518cf 100644 --- a/plugins/give.py +++ b/plugins/give.py @@ -9,6 +9,9 @@ class give: def __init__(self, SID, master): self.master = master self.SID = SID + self.userto = "" + self.value = 0 + self.myreason = "" def help(self): return {"give": "Give Money to other user"} diff --git a/plugins/take.py b/plugins/take.py index 63a4357..9763803 100644 --- a/plugins/take.py +++ b/plugins/take.py @@ -14,6 +14,10 @@ class take: def __init__(self, SID, master): self.master = master self.SID = SID + self.totakefrom = [] + self.peruser = 0 + self.myreason = "" + self.value = 0 def help(self): return {"take": "Take money from other user(s)"} diff --git a/tests/plugins/test_declaratie.py b/tests/plugins/test_declaratie.py index 726b92c..28423a5 100644 --- a/tests/plugins/test_declaratie.py +++ b/tests/plugins/test_declaratie.py @@ -429,3 +429,22 @@ def test_bon_save_startup_and_input_commands(self): assert self.declaratie.input(command) assert self.declaratie.soort == command self.master_mock.donext.assert_called_with(self.declaratie, "who") + + def test_instances_do_not_share_state(self): + self.declaratie.wie = "user1" + self.declaratie.value = 10 + self.declaratie.reden = "reason" + self.declaratie.ascash = 1 + self.declaratie.asbank = 2 + self.declaratie.asbar = 3 + self.declaratie.soort = "declaratie" + + other = declaratie_module.declaratie("SID2", Mock()) + + assert other.wie == "" + assert other.value == 0 + assert other.reden == "" + assert other.ascash == 0 + assert other.asbank == 0 + assert other.asbar == 0 + assert other.soort == "" diff --git a/tests/plugins/test_give.py b/tests/plugins/test_give.py index ab885fa..cfbef61 100644 --- a/tests/plugins/test_give.py +++ b/tests/plugins/test_give.py @@ -93,3 +93,14 @@ def test_amount_large_value(self): assert self.give.amount("1001") self.give.master.donext.assert_called_with(self.give, "amount") self.give.master.send_message.assert_called() + + def test_instances_do_not_share_state(self): + self.give.userto = "user1" + self.give.value = 10 + self.give.myreason = "reason" + + other = give_module.give("SID2", Mock()) + + assert other.userto == "" + assert other.value == 0 + assert other.myreason == "" diff --git a/tests/plugins/test_take.py b/tests/plugins/test_take.py index e93d230..e5e2ccb 100644 --- a/tests/plugins/test_take.py +++ b/tests/plugins/test_take.py @@ -94,3 +94,19 @@ def test_startup(): take = take_module.take("SID", master_mock) take.startup() # No assertion needed, just checking if method exists and runs without error + + +def test_instances_do_not_share_state(): + master_mock = Mock() + take = take_module.take("SID", master_mock) + take.totakefrom.append("user1") + take.peruser = 10 + take.myreason = "reason" + take.value = 20 + + other = take_module.take("SID2", Mock()) + + assert other.totakefrom == [] + assert other.peruser == 0 + assert other.myreason == "" + assert other.value == 0 From 3d9bbd11f6f5195802fe83f675c64e918ebe9a72 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:52:27 +0200 Subject: [PATCH 18/76] Maak account laden toleranter --- plugins/accounts.py | 10 ++++++++-- tests/plugins/test_accounts.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/plugins/accounts.py b/plugins/accounts.py index 36c889a..2aecb8a 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -63,7 +63,11 @@ def readaccounts(self): with codecs.open("data/revbank.accounts", "r", "utf-8") as f: lines = f.readlines() for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue parts = line.split() + if len(parts) < 3: + continue self.accounts[parts[0]] = { "amount": float(parts[1]), "lastupdate": parts[2], @@ -71,9 +75,11 @@ def readaccounts(self): with codecs.open("data/revbank.aliases", "r", "utf-8") as f: y = f.readlines() for x in y: - s = x.split(" ") + if not x.strip() or x.lstrip().startswith("#"): + continue + s = x.split() if len(s) == 2: - self.aliases[s[0]] = s[1].rstrip() + self.aliases[s[0]] = s[1] def updateaccount(self, usr, value): print("Updating account", usr) diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index 4da7db5..50cc438 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -75,6 +75,41 @@ def custom_mock_open(filename, _bla, _bla2): assert "stale_alias" not in acc.aliases +def test_readaccounts_tolerates_comments_blanks_and_whitespace(): + master_mock = Mock() + acc = accounts("SID", master_mock) + + mock_accounts_data = ( + "# comment\n" + "\n" + "user1 +100.00 2021-01-01\n" + "malformed\n" + "user2\t-20.50\t2021-01-02 extra-field\n" + ) + mock_aliases_data = ( + "# comment\n" + "\n" + "alias1 user1\n" + "bad alias line\n" + "alias2\tuser2\n" + ) + + def custom_mock_open(filename, _bla, _bla2): + if filename == "data/revbank.accounts": + return mock_open(read_data=mock_accounts_data)() + if filename == "data/revbank.aliases": + return mock_open(read_data=mock_aliases_data)() + + with patch("plugins.accounts.codecs.open", side_effect=custom_mock_open): + acc.readaccounts() + + assert acc.accounts == { + "user1": {"amount": 100.0, "lastupdate": "2021-01-01"}, + "user2": {"amount": -20.5, "lastupdate": "2021-01-02"}, + } + assert acc.aliases == {"alias1": "user1", "alias2": "user2"} + + def test_writeaccount(): master_mock = Mock() acc = accounts("SID", master_mock) From 547dcf6c400ea005088fa77218e3da3c5353bbe4 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:53:16 +0200 Subject: [PATCH 19/76] Maak stock laden toleranter --- plugins/stock.py | 4 ++++ tests/plugins/test_stock.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/stock.py b/plugins/stock.py index 2ffe366..9720b45 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -47,6 +47,8 @@ def readstock(self): with open("data/revbank.stock", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue parts = " ".join(line.split()).split(" ", 2) if len(parts) == 2: name = parts[0] @@ -55,6 +57,8 @@ def readstock(self): with open("data/revbank.stockalias", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue parts = " ".join(line.split()).split(" ", 3) if len(parts) == 3: name = parts[0] diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index 0803673..5f9d8cc 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -154,8 +154,8 @@ def test_stock_readstock(): stock = stock_module.stock("SID", master_mock) stock.stock = {"stale_product": 99} stock.stockalias = {"stale_alias": {"prod": "stale_product", "multi": 1}} - stock_data = "product1 10\nproduct2 20" - stock_alias_data = "alias1 product1 2\nalias2 product2 3" + stock_data = "# comment\n\nproduct1 10\nproduct2\t20\nmalformed\n" + stock_alias_data = "# comment\n\nalias1 product1 2\nalias2\tproduct2\t3\nbad line" def custom_mock_open_read(filename, *args, **kwargs): if filename == "data/revbank.stock": From 62480781f679afd317eb474e6fbcb8ca4c30af82 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:54:36 +0200 Subject: [PATCH 20/76] Maak product market en pfand laden toleranter --- plugins/market.py | 4 +++- plugins/pfand.py | 2 ++ plugins/products.py | 4 ++++ tests/plugins/test_market.py | 7 ++++++- tests/plugins/test_pfand.py | 2 +- tests/plugins/test_products.py | 11 ++++++++++- 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index f695a93..38127cc 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -51,9 +51,11 @@ def readproducts(self): lines = f.readlines() print("ok", lines) for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue parts = " ".join(line.split()).split(" ", 4) print(parts) - if len(parts) == 5 and not line.startswith("#"): + if len(parts) == 5: aliases = parts[1].split(",") name = aliases.pop(0) self.products[name] = { diff --git a/plugins/pfand.py b/plugins/pfand.py index 6773910..72e2c41 100644 --- a/plugins/pfand.py +++ b/plugins/pfand.py @@ -76,6 +76,8 @@ def loadmarket(self): with open("data/revbank.pfand", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue parts = " ".join(line.split()).split(" ", 2) if len(parts) == 2: name = parts[0] diff --git a/plugins/products.py b/plugins/products.py index 6dc0406..43f20a3 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -52,9 +52,13 @@ def readproducts(self): with open("data/revbank.products", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: + if not line.strip(): + continue parts = " ".join(line.split()).split(" ", 2) if line.startswith("#"): groupname = line.replace("#", "").strip(" \t\n\r") + if not groupname: + continue self.groups[groupname] = [] elif len(parts) == 3: aliases = parts[0].split(",") diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index 4300b6a..d6a01d7 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -12,7 +12,12 @@ def setup_method(self, method): def test_readproducts(self): self.market.products = {"stale_product": {}} self.market.aliases = {"stale_alias": "stale_product"} - market_data = "user1 product1 2.50 1.00 description1\n" + market_data = ( + "# comment\n" + "\n" + "user1 product1 2.50 1.00 description1\n" + "malformed\n" + ) mo = mock_open(read_data=market_data) with patch("builtins.open", mo): self.market.readproducts() diff --git a/tests/plugins/test_pfand.py b/tests/plugins/test_pfand.py index c4b7202..4f29985 100644 --- a/tests/plugins/test_pfand.py +++ b/tests/plugins/test_pfand.py @@ -56,7 +56,7 @@ def test_input_other(self): def test_loadmarket(self): self.pfand.products = {"stale": 9.0} - market_data = "prod1 1.0\nprod2 2.0\n" + market_data = "# comment\n\nprod1 1.0\nprod2\t2.0\nmalformed\n" mo = patch("builtins.open", mock_open(read_data=market_data)) with mo: self.pfand.loadmarket() diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 8ab807b..ec90754 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -12,7 +12,16 @@ def test_readproducts(self): self.products.products = {"stale_product": {}} self.products.aliases = {"stale_alias": "stale_product"} self.products.groups = {"StaleGroup": ["stale_product"]} - product_data = "# Group1\nproduct1,alias1 2.50 Description1\n# Group2\nproduct2 1.50 Description2\n" + product_data = ( + "\n" + "# Group1\n" + "product1,alias1 2.50 Description1\n" + "\n" + "# Group2\n" + "product2\t1.50\tDescription2\n" + "#\n" + "malformed\n" + ) with patch("builtins.open", mock_open(read_data=product_data)): self.products.readproducts() self.assertIn("product1", self.products.products) From 010c49378fdc696ed1652f34889e5dfda6d15f67 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:55:38 +0200 Subject: [PATCH 21/76] Wis stale POS en undo state bij mislukte load --- plugins/POS.py | 5 +++-- plugins/undo.py | 1 + tests/plugins/test_POS.py | 11 +++++++++-- tests/plugins/test_undo.py | 21 +++++++++++++++++---- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index d484f31..d88cd70 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -245,14 +245,15 @@ def writebons(self): json.dump(self.serialize_bons(self.bonnetjes), output) def loadbons(self): + self.bonnetjes = {} try: with open("data/revbank.POS", "rb") as f: data = f.read() try: loaded = json.loads(data.decode("utf-8")) - self.bonnetjes = self.deserialize_bons(loaded) except (UnicodeDecodeError, json.JSONDecodeError): - self.bonnetjes = pickle.loads(data) + loaded = pickle.loads(data) + self.bonnetjes = self.deserialize_bons(loaded) except: pass diff --git a/plugins/undo.py b/plugins/undo.py index 21f6b9f..f373d01 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -57,6 +57,7 @@ def writeundo(self): json.dump(self.undo, output) def loadundo(self): + self.undo = {} try: with open("data/revbank.UNDO", "rb") as f: data = f.read() diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 8ab7bf6..c72fb09 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -329,10 +329,17 @@ def test_writebons_max_receipts(self): assert len(self.POS.bonnetjes) == 50 def test_loadbons_file_error(self): - self.POS.bonnetjes = {} + self.POS.bonnetjes = {123: {"bon": "stale"}} with patch("builtins.open", new_callable=mock_open(), side_effect=Exception): self.POS.loadbons() - assert self.POS.bonnetjes == {} # remains empty + assert self.POS.bonnetjes == {} + + def test_loadbons_invalid_data_clears_stale_bons(self): + self.POS.bonnetjes = {123: {"bon": "stale"}} + with patch("builtins.open", mock_open(read_data=b"not json or pickle")): + self.POS.loadbons() + + assert self.POS.bonnetjes == {} def test_listbons_max_receipts(self): self.POS.bonnetjes = { diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index f65c481..c54d9ad 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -124,7 +124,7 @@ def test_undo_loadundo_json(): assert undo.undo == {1: "data"} -def test_undo_loadundo_ignores_errors(): +def test_undo_loadundo_clears_stale_state_on_errors(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) undo.undo = {1: "old"} @@ -132,7 +132,18 @@ def test_undo_loadundo_ignores_errors(): with patch("builtins.open", side_effect=OSError("missing")): undo.loadundo() - assert undo.undo == {1: "old"} + assert undo.undo == {} + + +def test_undo_loadundo_invalid_data_clears_stale_state(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + undo.undo = {1: "old"} + + with patch("builtins.open", mock_open(read_data=b"not json or pickle")): + undo.loadundo() + + assert undo.undo == {} def test_undo_doundo_abort(): @@ -235,7 +246,8 @@ def test_undo_listundo(): "%Y-%m-%d %H:%M:%S", undo_module.time.localtime(123 + 1300000000) ) - undo.listundo() + with patch.object(undo, "loadundo"): + undo.listundo() calls = [ call( True, @@ -265,7 +277,8 @@ def test_undo_listundo_restore_and_limit(): i: {"totals": {"user": i}, "receipt": [], "beni": "text"} for i in range(60) } - undo.listundo(restore=True) + with patch.object(undo, "loadundo"): + undo.listundo(restore=True) master_mock.send_message.assert_any_call( True, "message", "Select a transaction to restore" From 8f3478fd2b9b53cb4f13361bee8427e53d2cdb7e Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:56:15 +0200 Subject: [PATCH 22/76] Maak members laden toleranter --- plugins/accounts.py | 12 +++++++++--- tests/plugins/test_accounts.py | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/accounts.py b/plugins/accounts.py index 2aecb8a..888872f 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -81,6 +81,14 @@ def readaccounts(self): if len(s) == 2: self.aliases[s[0]] = s[1] + def readmembers(self): + with open("data/revbank.members", encoding="utf-8") as f: + self.members = [ + member.strip() + for member in f.readlines() + if member.strip() and not member.lstrip().startswith("#") + ] + def updateaccount(self, usr, value): print("Updating account", usr) if usr == "cash": @@ -160,9 +168,7 @@ def createnew(self, text): def startup(self): self.readaccounts() - with open("data/revbank.members", encoding="utf-8") as f: - self.members = f.readlines() - self.members = [m.rstrip() for m in self.members] + self.readmembers() self.get_last_updated_accounts() for name, account in self.accounts.items(): self.master.send_message(True, "accounts/" + name, json.dumps(account)) diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index 50cc438..a675a13 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -262,7 +262,11 @@ def test_createnew(_mock_strftime): assert master_mock.send_message.call_args_list == expected_calls -@patch("builtins.open", new_callable=mock_open, read_data="user1\nuser2\n") +@patch( + "builtins.open", + new_callable=mock_open, + read_data="# comment\n\nuser1\n user2 \n", +) def test_startup(mock_file): master_mock = Mock() acc = accounts("SID", master_mock) From e25cdacbc6611b2c6773315cc0cb866fa5f82349 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:57:37 +0200 Subject: [PATCH 23/76] Sla ongeldige numerieke loadregels over --- plugins/market.py | 9 +++++++-- plugins/pfand.py | 5 ++++- plugins/products.py | 6 +++++- plugins/stock.py | 11 +++++++++-- tests/plugins/test_market.py | 4 ++++ tests/plugins/test_pfand.py | 2 +- tests/plugins/test_products.py | 2 ++ tests/plugins/test_stock.py | 10 ++++++++-- 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index 38127cc..5a00252 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -58,9 +58,14 @@ def readproducts(self): if len(parts) == 5: aliases = parts[1].split(",") name = aliases.pop(0) + try: + price = float(parts[2]) + space = float(parts[3]) + except ValueError: + continue self.products[name] = { - "price": float(parts[2]), - "space": float(parts[3]), + "price": price, + "space": space, "description": parts[4], "aliases": aliases, "user": parts[0], diff --git a/plugins/pfand.py b/plugins/pfand.py index 72e2c41..26f6e5e 100644 --- a/plugins/pfand.py +++ b/plugins/pfand.py @@ -81,7 +81,10 @@ def loadmarket(self): parts = " ".join(line.split()).split(" ", 2) if len(parts) == 2: name = parts[0] - self.products[name] = float(parts[1]) + try: + self.products[name] = float(parts[1]) + except ValueError: + continue def hook_abort(self, _void): self.startup() diff --git a/plugins/products.py b/plugins/products.py index 43f20a3..925382a 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -63,11 +63,15 @@ def readproducts(self): elif len(parts) == 3: aliases = parts[0].split(",") name = aliases.pop(0) + try: + price = float(parts[1]) + except ValueError: + continue self.groups[groupname].append(name) for alias in aliases: self.aliases[alias] = name self.products[name] = { - "price": float(parts[1]), + "price": price, "description": parts[2], "group": groupname, "aliases": aliases, diff --git a/plugins/stock.py b/plugins/stock.py index 9720b45..cf10fd2 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -52,7 +52,10 @@ def readstock(self): parts = " ".join(line.split()).split(" ", 2) if len(parts) == 2: name = parts[0] - self.stock[name] = int(parts[1]) + try: + self.stock[name] = int(parts[1]) + except ValueError: + continue f.close() with open("data/revbank.stockalias", "r", encoding="utf-8") as f: lines = f.readlines() @@ -62,7 +65,11 @@ def readstock(self): parts = " ".join(line.split()).split(" ", 3) if len(parts) == 3: name = parts[0] - self.stockalias[name] = {"prod": parts[1], "multi": int(parts[2])} + try: + multiplier = int(parts[2]) + except ValueError: + continue + self.stockalias[name] = {"prod": parts[1], "multi": multiplier} def setstock(self, prod, count): self.readstock() diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index d6a01d7..d5de1f4 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -16,6 +16,8 @@ def test_readproducts(self): "# comment\n" "\n" "user1 product1 2.50 1.00 description1\n" + "user1 badprice nope 1.00 description\n" + "user1 badspace 2.50 nope description\n" "malformed\n" ) mo = mock_open(read_data=market_data) @@ -25,6 +27,8 @@ def test_readproducts(self): assert "product1" in self.market.products assert self.market.products["product1"]["price"] == 2.50 assert self.market.products["product1"]["description"] == "description1" + assert "badprice" not in self.market.products + assert "badspace" not in self.market.products assert "stale_product" not in self.market.products assert "stale_alias" not in self.market.aliases diff --git a/tests/plugins/test_pfand.py b/tests/plugins/test_pfand.py index 4f29985..bd80759 100644 --- a/tests/plugins/test_pfand.py +++ b/tests/plugins/test_pfand.py @@ -56,7 +56,7 @@ def test_input_other(self): def test_loadmarket(self): self.pfand.products = {"stale": 9.0} - market_data = "# comment\n\nprod1 1.0\nprod2\t2.0\nmalformed\n" + market_data = "# comment\n\nprod1 1.0\nprod2\t2.0\nbad nope\nmalformed\n" mo = patch("builtins.open", mock_open(read_data=market_data)) with mo: self.pfand.loadmarket() diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index ec90754..38aa934 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -19,6 +19,7 @@ def test_readproducts(self): "\n" "# Group2\n" "product2\t1.50\tDescription2\n" + "badprice nope Bad description\n" "#\n" "malformed\n" ) @@ -26,6 +27,7 @@ def test_readproducts(self): self.products.readproducts() self.assertIn("product1", self.products.products) self.assertIn("product2", self.products.products) + self.assertNotIn("badprice", self.products.products) self.assertIn("Group1", self.products.groups) self.assertIn("Group2", self.products.groups) self.assertNotIn("stale_product", self.products.products) diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index 5f9d8cc..fa80d95 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -154,8 +154,14 @@ def test_stock_readstock(): stock = stock_module.stock("SID", master_mock) stock.stock = {"stale_product": 99} stock.stockalias = {"stale_alias": {"prod": "stale_product", "multi": 1}} - stock_data = "# comment\n\nproduct1 10\nproduct2\t20\nmalformed\n" - stock_alias_data = "# comment\n\nalias1 product1 2\nalias2\tproduct2\t3\nbad line" + stock_data = "# comment\n\nproduct1 10\nproduct2\t20\nbad nope\nmalformed\n" + stock_alias_data = ( + "# comment\n\n" + "alias1 product1 2\n" + "alias2\tproduct2\t3\n" + "badalias product1 nope\n" + "bad line" + ) def custom_mock_open_read(filename, *args, **kwargs): if filename == "data/revbank.stock": From ab1fe93239686d610fd817bc2fa8f0fab7156758 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 00:59:31 +0200 Subject: [PATCH 24/76] Blokkeer productaliases die met commando's botsen --- plugins/market.py | 32 +++++++++++++++++++++++++ plugins/products.py | 32 +++++++++++++++++++++++++ tests/plugins/test_market.py | 44 ++++++++++++++++++++++++++++++++-- tests/plugins/test_products.py | 43 +++++++++++++++++++++++++++++++-- 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index 5a00252..ffbca06 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -4,6 +4,8 @@ import tempfile import threading +RESERVED_INPUTS = {"abort", "ok"} + def _atomic_write(path, lines): directory = os.path.dirname(path) or "." @@ -44,6 +46,17 @@ def help(self): "market": "Market: Products", } + def reserved_inputs(self): + reserved = set(RESERVED_INPUTS) + reserved.update(self.help().keys()) + master_help = getattr(self.master, "help", {}) + if isinstance(master_help, dict): + reserved.update(master_help.keys()) + return reserved + + def is_reserved_input(self, text): + return text in self.reserved_inputs() + def readproducts(self): self.products = {} self.aliases = {} @@ -58,6 +71,11 @@ def readproducts(self): if len(parts) == 5: aliases = parts[1].split(",") name = aliases.pop(0) + if self.is_reserved_input(name): + continue + aliases = [ + alias for alias in aliases if not self.is_reserved_input(alias) + ] try: price = float(parts[2]) space = float(parts[3]) @@ -110,6 +128,8 @@ def __init__(self, SID, master): self.newprod = "" def lookupprod(self, text): + if self.is_reserved_input(text): + return None prod = None if text in self.products: prod = text @@ -134,6 +154,12 @@ def savealias(self, text): "keyboard", "Already known alias " + text + " for " + prod + "! Try again.", ) + if self.is_reserved_input(text): + return self.messageandbuttons( + "savealias", + "keyboard", + "That alias is a command; choose another alias.", + ) if len(text) < 6 or not re.compile("^[A-z0-9]+$").match(text): return self.messageandbuttons( "savealias", @@ -315,6 +341,12 @@ def addproduct(self, text): return self.messageandbuttons( "addproduct", "keyboard", "Product already exists? What product to add?" ) + if self.is_reserved_input(text): + return self.messageandbuttons( + "addproduct", + "keyboard", + "That product name is a command; choose another name.", + ) if len(text) < 4 or not re.compile("^[A-z0-9]+$").match(text): return self.messageandbuttons( "addproduct", diff --git a/plugins/products.py b/plugins/products.py index 925382a..0da9401 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -4,6 +4,8 @@ import tempfile import threading +RESERVED_INPUTS = {"abort", "ok"} + def _atomic_write(path, lines): directory = os.path.dirname(path) or "." @@ -44,6 +46,17 @@ def help(self): "setprice": "Change the price of a product", } + def reserved_inputs(self): + reserved = set(RESERVED_INPUTS) + reserved.update(self.help().keys()) + master_help = getattr(self.master, "help", {}) + if isinstance(master_help, dict): + reserved.update(master_help.keys()) + return reserved + + def is_reserved_input(self, text): + return text in self.reserved_inputs() + def readproducts(self): self.products = {} self.aliases = {} @@ -63,6 +76,11 @@ def readproducts(self): elif len(parts) == 3: aliases = parts[0].split(",") name = aliases.pop(0) + if self.is_reserved_input(name): + continue + aliases = [ + alias for alias in aliases if not self.is_reserved_input(alias) + ] try: price = float(parts[1]) except ValueError: @@ -115,6 +133,8 @@ def __init__(self, SID, master): self.newprod = "" def lookupprod(self, text): + if self.is_reserved_input(text): + return None prod = None if text in self.products: prod = text @@ -139,6 +159,12 @@ def savealias(self, text): "keyboard", "Already known alias " + text + " for " + prod + "! Try again.", ) + if self.is_reserved_input(text): + return self.messageandbuttons( + "savealias", + "keyboard", + "That alias is a command; choose another alias.", + ) if len(text) < 6 or not re.compile("^[A-z0-9]+$").match(text): return self.messageandbuttons( "savealias", @@ -302,6 +328,12 @@ def addproduct(self, text): return self.messageandbuttons( "addproduct", "keyboard", "Product already exists? What product to add?" ) + if self.is_reserved_input(text): + return self.messageandbuttons( + "addproduct", + "keyboard", + "That product name is a command; choose another name.", + ) if len(text) < 4 or not re.compile("^[A-z0-9]+$").match(text): return self.messageandbuttons( "addproduct", diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index d5de1f4..67d7416 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -10,12 +10,15 @@ def setup_method(self, method): self.market = market_module.market("SID", self.master_mock) def test_readproducts(self): + self.master_mock.help = {"deposit": "Deposit Money"} self.market.products = {"stale_product": {}} self.market.aliases = {"stale_alias": "stale_product"} market_data = ( "# comment\n" "\n" "user1 product1 2.50 1.00 description1\n" + "user1 deposit,oldalias 1.00 0.00 reserved product\n" + "user1 product3,deposit,ok 3.00 0.50 reserved aliases\n" "user1 badprice nope 1.00 description\n" "user1 badspace 2.50 nope description\n" "malformed\n" @@ -25,8 +28,13 @@ def test_readproducts(self): self.market.readproducts() print("hooi", self.market.products) assert "product1" in self.market.products + assert "product3" in self.market.products + assert self.market.products["product3"]["aliases"] == [] assert self.market.products["product1"]["price"] == 2.50 assert self.market.products["product1"]["description"] == "description1" + assert "deposit" not in self.market.products + assert "deposit" not in self.market.aliases + assert "ok" not in self.market.aliases assert "badprice" not in self.market.products assert "badspace" not in self.market.products assert "stale_product" not in self.market.products @@ -81,10 +89,12 @@ def test_writeproducts(self): assert self.market.products["product1"]["aliases"] == ["alias1"] def test_lookupprod(self): + self.master_mock.help = {"deposit": "Deposit Money"} self.market.products = {"product1": "details1"} - self.market.aliases = {"alias1": "product1"} + self.market.aliases = {"alias1": "product1", "deposit": "product1"} assert self.market.lookupprod("product1") == "product1" assert self.market.lookupprod("alias1") == "product1" + assert self.market.lookupprod("deposit") is None assert self.market.lookupprod("unknown") is None def test_messageandbuttons(self): @@ -127,6 +137,20 @@ def test_savealias_valid_new_alias(self): assert self.market.products["product1"]["aliases"] == ["alias123"] + def test_savealias_rejects_command_alias(self): + self.master_mock.help = {"deposit": "Deposit Money"} + self.market.products = {"product1": {"aliases": []}} + self.market.aliasprod = "product1" + + with patch.object(self.market, "readproducts"), patch.object( + self.market, "writeproducts" + ) as mock_writeproducts: + assert self.market.savealias("deposit") is True + + mock_writeproducts.assert_not_called() + assert self.market.products["product1"]["aliases"] == [] + self.master_mock.donext.assert_called_with(self.market, "savealias") + def test_addalias(self): self.market.products = {"product1": {"aliases": []}} assert self.market.addalias("product1") @@ -292,13 +316,29 @@ def test_addproduct_abort_and_invalid_name(self): self.master_mock.callhook.assert_called_with("abort", None) assert self.market.addproduct("bad!") is True + def test_addproduct_rejects_command_name(self): + self.master_mock.help = {"deposit": "Deposit Money"} + + assert self.market.addproduct("deposit") is True + + assert self.market.newprod == "" + self.master_mock.donext.assert_called_with(self.market, "addproduct") + def test_input_unknown_product(self): self.market.products = {} assert not self.market.input("unknownprod") def test_input_market(self): - self.market.products = {} + self.market.products = { + "market": { + "price": 2.0, + "user": "user1", + "description": "reserved name", + "space": 0.5, + } + } assert self.market.input("market") + self.master_mock.receipt.add.assert_not_called() def test_input_market_lists_products(self): self.market.products = { diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 38aa934..295bda3 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -9,6 +9,7 @@ def setUp(self): self.products = ProductsModule.products("SID", self.master_mock) def test_readproducts(self): + self.master_mock.help = {"deposit": "Deposit Money"} self.products.products = {"stale_product": {}} self.products.aliases = {"stale_alias": "stale_product"} self.products.groups = {"StaleGroup": ["stale_product"]} @@ -16,6 +17,8 @@ def test_readproducts(self): "\n" "# Group1\n" "product1,alias1 2.50 Description1\n" + "deposit,oldalias 1.00 Reserved product\n" + "product3,deposit,ok 3.00 Reserved aliases\n" "\n" "# Group2\n" "product2\t1.50\tDescription2\n" @@ -27,7 +30,12 @@ def test_readproducts(self): self.products.readproducts() self.assertIn("product1", self.products.products) self.assertIn("product2", self.products.products) + self.assertIn("product3", self.products.products) + self.assertEqual(self.products.products["product3"]["aliases"], []) self.assertNotIn("badprice", self.products.products) + self.assertNotIn("deposit", self.products.products) + self.assertNotIn("deposit", self.products.aliases) + self.assertNotIn("ok", self.products.aliases) self.assertIn("Group1", self.products.groups) self.assertIn("Group2", self.products.groups) self.assertNotIn("stale_product", self.products.products) @@ -78,10 +86,12 @@ def test_writeproducts(self): self.assertEqual(self.products.products["product2"]["aliases"], []) def test_lookupprod(self): + self.master_mock.help = {"deposit": "Deposit Money"} self.products.products = {"product1": {}} - self.products.aliases = {"alias1": "product1"} + self.products.aliases = {"alias1": "product1", "deposit": "product1"} self.assertEqual(self.products.lookupprod("product1"), "product1") self.assertEqual(self.products.lookupprod("alias1"), "product1") + self.assertIsNone(self.products.lookupprod("deposit")) self.assertIsNone(self.products.lookupprod("nonexistent")) def test_messageandbuttons(self): @@ -246,6 +256,20 @@ def test_savealias_existing_alias(self): assert self.products.savealias("alias1") self.master_mock.donext.assert_called_with(self.products, "savealias") + def test_savealias_rejects_command_alias(self): + self.master_mock.help = {"deposit": "Deposit Money"} + self.products.products = {"product1": {"aliases": []}} + self.products.aliasprod = "product1" + + with patch.object(self.products, "readproducts"), patch.object( + self.products, "writeproducts" + ) as mock_writeproducts: + assert self.products.savealias("deposit") + + mock_writeproducts.assert_not_called() + assert self.products.products["product1"]["aliases"] == [] + self.master_mock.donext.assert_called_with(self.products, "savealias") + def test_saveprice_out_of_range(self): self.products.products = {"product1": {"price": 2.5}} self.products.priceprod = "product1" @@ -307,10 +331,21 @@ def test_addproduct_existing_invalid_and_valid(self): assert self.products.newprod == "product2" self.master_mock.donext.assert_called_with(self.products, "addproductdesc") + def test_addproduct_rejects_command_name(self): + self.master_mock.help = {"deposit": "Deposit Money"} + + assert self.products.addproduct("deposit") + + assert self.products.newprod == "" + self.master_mock.donext.assert_called_with(self.products, "addproduct") + def test_input_product_commands_and_multiplier(self): + self.master_mock.help = {"deposit": "Deposit Money"} self.products.products = { - "product1": {"price": 2.5, "description": "Description1", "aliases": []} + "product1": {"price": 2.5, "description": "Description1", "aliases": []}, + "deposit": {"price": 1.0, "description": "Reserved", "aliases": []}, } + self.products.aliases = {"deposit": "product1"} assert self.products.input("2*") assert self.products.times == 2 @@ -321,6 +356,10 @@ def test_input_product_commands_and_multiplier(self): ) assert self.products.times == 1 + self.master_mock.reset_mock() + assert self.products.input("deposit") is None + self.master_mock.receipt.add.assert_not_called() + for command, nextcall in ( ("aliasproduct", "addalias"), ("addproduct", "addproduct"), From b5dd27720f253068ef85cfb9497b8680293aba4f Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:03:42 +0200 Subject: [PATCH 25/76] Verplaats hardcoded hosts naar config --- .gitignore | 1 + config.py | 68 ++++++++++++++++++++++++++++++++++ config.yaml.example | 27 ++++++++++++++ kassa.py | 8 +++- plugins/POS.py | 6 ++- plugins/door.py | 13 +++++-- plugins/stickers.py | 15 +++++++- requirements.in | 1 + requirements.txt | 4 +- tests/plugins/test_POS.py | 16 ++++++++ tests/plugins/test_door.py | 19 ++++++++++ tests/plugins/test_stickers.py | 28 ++++++++++++++ tests/test_config.py | 45 ++++++++++++++++++++++ tests/test_kassa.py | 15 ++++++++ www/spaceconsole/cmd.php | 6 ++- www/spaceconsole/stream.php | 6 ++- 16 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 .gitignore create mode 100644 config.py create mode 100644 config.yaml.example create mode 100644 tests/test_config.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b6b072 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.yaml diff --git a/config.py b/config.py new file mode 100644 index 0000000..105cc04 --- /dev/null +++ b/config.py @@ -0,0 +1,68 @@ +import copy +import os + +import yaml + + +CONFIG_PATH = "config.yaml" + +DEFAULT_CONFIG = { + "mqtt": { + "host": "localhost", + "port": 1883, + "keepalive": 60, + }, + "door": { + "mqtt": { + "host": "mqtt.space.hack42.nl", + "port": 1883, + "keepalive": 60, + "topic": "hack42/brandhok/deuropen", + }, + }, + "stickers": { + "printer": { + "model": "QL-710W", + "host": "localhost", + "port": 9100, + }, + }, + "pos": { + "serial": { + "port": "/dev/ttyUSB0", + "baudrate": 19200, + }, + }, +} + + +def _merge_config(base, override): + merged = copy.deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _merge_config(merged[key], value) + else: + merged[key] = value + return merged + + +def load_config(path=None): + path = path or os.environ.get("KASSA_CONFIG", CONFIG_PATH) + if not os.path.exists(path): + return copy.deepcopy(DEFAULT_CONFIG) + + with open(path, encoding="utf-8") as config_file: + loaded = yaml.safe_load(config_file) or {} + + if not isinstance(loaded, dict): + return copy.deepcopy(DEFAULT_CONFIG) + return _merge_config(DEFAULT_CONFIG, loaded) + + +def config_get(*keys, default=None): + value = load_config() + for key in keys: + if not isinstance(value, dict) or key not in value: + return default + value = value[key] + return value diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..35a6292 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,27 @@ +# Copy this file to config.yaml and adjust local values. +# config.yaml is intentionally git-ignored so local secrets/device settings are not overwritten. + +mqtt: + host: localhost + port: 1883 + keepalive: 60 + +door: + mqtt: + host: mqtt.space.hack42.nl + port: 1883 + keepalive: 60 + topic: hack42/brandhok/deuropen + +stickers: + printer: + model: QL-710W + host: BRW008092D4A414.space.hack42.nl + port: 9100 + # Optional override when the brother_ql backend needs a full identifier: + # identifier: tcp://BRW008092D4A414.space.hack42.nl:9100 + +pos: + serial: + port: /dev/ttyUSB0 + baudrate: 19200 diff --git a/kassa.py b/kassa.py index dd0a5e3..9169aa2 100755 --- a/kassa.py +++ b/kassa.py @@ -7,6 +7,7 @@ import sys import traceback import paho.mqtt.client as mqtt +from config import config_get sessions = {} @@ -247,11 +248,16 @@ def on_message(client, _userdata, msg): def run(): while 1: try: + mqtt_config = config_get("mqtt", default={}) client = mqtt.Client() client.on_connect = on_connect client.on_message = on_message - client.connect("localhost", 1883, 60) + client.connect( + mqtt_config["host"], + int(mqtt_config["port"]), + int(mqtt_config["keepalive"]), + ) client.loop_start() while True: time.sleep(1) diff --git a/plugins/POS.py b/plugins/POS.py index d88cd70..a9e1aa4 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -5,6 +5,7 @@ import time import pickle import serial +from config import config_get DISPLAY = b"\x1b=\x02\x1b@" PRINTER = b"\x1b=\x01\x1b@" @@ -43,9 +44,10 @@ def __init__(self, SID, master): def open(self): if self.ser is not None: return + serial_config = config_get("pos", "serial", default={}) self.ser = serial.Serial( # pylint: disable=no-member - port="/dev/ttyUSB0", # pylint: disable=no-member - baudrate=19200, # pylint: disable=no-member + port=serial_config["port"], # pylint: disable=no-member + baudrate=int(serial_config["baudrate"]), # pylint: disable=no-member parity=serial.PARITY_NONE, # pylint: disable=no-member stopbits=serial.STOPBITS_ONE, # pylint: disable=no-member bytesize=serial.EIGHTBITS, # pylint: disable=no-member diff --git a/plugins/door.py b/plugins/door.py index 581f7b3..84464d7 100644 --- a/plugins/door.py +++ b/plugins/door.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- import paho.mqtt.client as mqtt +from config import config_get class door: @@ -14,10 +15,16 @@ def help(self): def input(self, text): if text == "dooropen": + mqtt_config = config_get("door", "mqtt", default={}) client = mqtt.Client() - client.connect("mqtt.space.hack42.nl", 1883, 60) - client.publish("hack42/brandhok/deuropen", "closed", 1, True) - client.publish("hack42/brandhok/deuropen", "open", 1, True) + topic = mqtt_config["topic"] + client.connect( + mqtt_config["host"], + int(mqtt_config["port"]), + int(mqtt_config["keepalive"]), + ) + client.publish(topic, "closed", 1, True) + client.publish(topic, "open", 1, True) client.disconnect() return True return None diff --git a/plugins/stickers.py b/plugins/stickers.py index 138a7e4..e6f9dbd 100644 --- a/plugins/stickers.py +++ b/plugins/stickers.py @@ -11,6 +11,7 @@ import brother_ql.conversion import brother_ql.backends.helpers import brother_ql.raster +from config import config_get class stickers: # pylint: disable=too-many-public-methods @@ -21,7 +22,7 @@ class stickers: # pylint: disable=too-many-public-methods LOGOFILE = "images/hack42.png" FONT = "images/arialbd.ttf" printer = "QL710W" - PRINTER = "tcp://192.168.42.167:9100" + PRINTER = "tcp://localhost:9100" MODEL = "QL-710W" SPACE = ( 0 # spacing around qrcode, should be 4 but our printer prints on white labels @@ -38,6 +39,18 @@ class stickers: # pylint: disable=too-many-public-methods def __init__(self, SID, master): self.master = master self.SID = SID + printer_config = config_get("stickers", "printer", default={}) + self.MODEL = printer_config.get("model", self.MODEL) + self.PRINTER = self.printer_identifier(printer_config) + + def printer_identifier(self, printer_config): + if printer_config.get("identifier"): + return printer_config["identifier"] + host = printer_config.get("host") + port = printer_config.get("port", 9100) + if host: + return f"tcp://{host}:{port}" + return self.PRINTER def help(self): return {"stickers": "All sticker commands"} diff --git a/requirements.in b/requirements.in index 49cfe40..713eb51 100644 --- a/requirements.in +++ b/requirements.in @@ -4,3 +4,4 @@ Pillow serial pypng brother_ql +PyYAML diff --git a/requirements.txt b/requirements.txt index c0f45c0..a8651a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,6 +31,8 @@ pyqrcode==1.2.1 pyusb==1.2.1 # via brother-ql pyyaml==6.0.1 - # via serial + # via + # -r requirements.in + # serial serial==0.0.97 # via -r requirements.in diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index c72fb09..7fa24a3 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -14,6 +14,22 @@ def test_open(self): self.POS.open() assert self.POS.ser is not None + def test_open_uses_configured_serial_port(self): + serial_mock = Mock() + with patch("plugins.POS.serial", new=serial_mock), patch( + "plugins.POS.config_get", + return_value={"port": "/dev/test-printer", "baudrate": 9600}, + ): + self.POS.open() + + serial_mock.Serial.assert_called_with( + port="/dev/test-printer", + baudrate=9600, + parity=serial_mock.PARITY_NONE, + stopbits=serial_mock.STOPBITS_ONE, + bytesize=serial_mock.EIGHTBITS, + ) + def test_open_when_already_open(self): self.POS.ser = Mock() diff --git a/tests/plugins/test_door.py b/tests/plugins/test_door.py index 7030095..fe4ec7c 100644 --- a/tests/plugins/test_door.py +++ b/tests/plugins/test_door.py @@ -27,6 +27,25 @@ def test_input_dooropen(self): ) client_instance.disconnect.assert_called() + def test_input_dooropen_uses_config(self): + with patch("paho.mqtt.client.Client") as mock_client, patch( + "plugins.door.config_get", + return_value={ + "host": "door-mqtt.example.test", + "port": 1884, + "keepalive": 30, + "topic": "custom/door", + }, + ): + client_instance = mock_client.return_value + self.assertTrue(self.door_instance.input("dooropen")) + + client_instance.connect.assert_called_with( + "door-mqtt.example.test", 1884, 30 + ) + client_instance.publish.assert_any_call("custom/door", "closed", 1, True) + client_instance.publish.assert_any_call("custom/door", "open", 1, True) + def test_input_invalid_command(self): self.assertIsNone(self.door_instance.input("invalid_command")) diff --git a/tests/plugins/test_stickers.py b/tests/plugins/test_stickers.py index e157e7d..06ae15a 100644 --- a/tests/plugins/test_stickers.py +++ b/tests/plugins/test_stickers.py @@ -7,6 +7,34 @@ from plugins.stickers import stickers +def test_uses_configured_printer_host(): + with patch( + "plugins.stickers.config_get", + return_value={ + "model": "QL-820NWB", + "host": "printer.example.test", + "port": 9101, + }, + ): + sticky = stickers("main", Mock()) + + assert sticky.MODEL == "QL-820NWB" + assert sticky.PRINTER == "tcp://printer.example.test:9101" + + +def test_uses_configured_printer_identifier(): + with patch( + "plugins.stickers.config_get", + return_value={ + "model": "QL-710W", + "identifier": "tcp://printer-id.example.test:9100", + }, + ): + sticky = stickers("main", Mock()) + + assert sticky.PRINTER == "tcp://printer-id.example.test:9100" + + @patch("builtins.open") @patch("plugins.stickers.brother_ql.backends.helpers") def test_eigendom(_cups, _open): diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7fa2dba --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,45 @@ +import os + +from config import config_get, load_config + + +def test_load_config_uses_defaults_when_missing(tmp_path): + config = load_config(tmp_path / "missing.yaml") + + assert config["mqtt"]["host"] == "localhost" + assert config["stickers"]["printer"]["port"] == 9100 + + +def test_load_config_merges_yaml_with_defaults(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + "mqtt:\n" + " host: mqtt.example.test\n" + "stickers:\n" + " printer:\n" + " host: printer.example.test\n", + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config["mqtt"]["host"] == "mqtt.example.test" + assert config["mqtt"]["port"] == 1883 + assert config["stickers"]["printer"]["host"] == "printer.example.test" + assert config["stickers"]["printer"]["port"] == 9100 + + +def test_config_get_uses_env_path(tmp_path, monkeypatch): + config_path = tmp_path / "custom.yaml" + config_path.write_text("mqtt:\n port: 1884\n", encoding="utf-8") + monkeypatch.setenv("KASSA_CONFIG", str(config_path)) + + assert config_get("mqtt", "port") == 1884 + assert config_get("missing", default="fallback") == "fallback" + + +def test_example_config_loads(): + config = load_config("config.yaml.example") + + assert config["stickers"]["printer"]["host"] == "BRW008092D4A414.space.hack42.nl" + assert os.path.basename("config.yaml.example") == "config.yaml.example" diff --git a/tests/test_kassa.py b/tests/test_kassa.py index 3ba8cd3..d76f60c 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -468,6 +468,21 @@ def test_run_sets_up_client_and_stops_on_keyboard_interrupt(): client_mock.loop_start.assert_called_once() +def test_run_uses_configured_mqtt(): + client_mock = Mock() + + with patch("kassa.mqtt.Client", return_value=client_mock), patch( + "kassa.config_get", + return_value={"host": "mqtt.example.test", "port": 1884, "keepalive": 30}, + ), patch("kassa.time.sleep", side_effect=[KeyboardInterrupt, KeyboardInterrupt]): + try: + kassa.run() + except KeyboardInterrupt: + pass + + client_mock.connect.assert_called_with("mqtt.example.test", 1884, 30) + + def test_startup_removes_module_in_second_import_pass(): client_mock = Mock() session = kassa.Session("SID", client_mock) diff --git a/www/spaceconsole/cmd.php b/www/spaceconsole/cmd.php index 8c2f446..dc857a4 100644 --- a/www/spaceconsole/cmd.php +++ b/www/spaceconsole/cmd.php @@ -1,7 +1,11 @@ connect()){ exit(1); } From 209070f6ca8f90131adbae9a2b024163cd96bb8c Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:04:56 +0200 Subject: [PATCH 26/76] Onderdruk third-party deprecation warnings --- plugins/POS.py | 10 +++++++++- plugins/stickers.py | 14 +++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index a9e1aa4..bcc2a94 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -4,9 +4,17 @@ import json import time import pickle -import serial +import warnings from config import config_get +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="the imp module is deprecated in favour of importlib.*", + category=DeprecationWarning, + ) + import serial + DISPLAY = b"\x1b=\x02\x1b@" PRINTER = b"\x1b=\x01\x1b@" LARGE = b"\x1d!\x11" diff --git a/plugins/stickers.py b/plugins/stickers.py index e6f9dbd..e4f7023 100644 --- a/plugins/stickers.py +++ b/plugins/stickers.py @@ -6,13 +6,21 @@ import time import base64 import urllib.parse +import warnings from PIL import Image, ImageDraw, ImageFont import pyqrcode -import brother_ql.conversion -import brother_ql.backends.helpers -import brother_ql.raster from config import config_get +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="The 'warn' method is deprecated, use 'warning' instead", + category=DeprecationWarning, + ) + import brother_ql.conversion + import brother_ql.backends.helpers + import brother_ql.raster + class stickers: # pylint: disable=too-many-public-methods SMALL = (696, 271) From acdff5702805553a781904e8d918451205543787 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:10:24 +0200 Subject: [PATCH 27/76] Vervang serial dependency door pyserial --- plugins/POS.py | 10 +--------- requirements.in | 2 +- requirements.txt | 12 +++--------- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index bcc2a94..a9e1aa4 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -4,17 +4,9 @@ import json import time import pickle -import warnings +import serial from config import config_get -with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message="the imp module is deprecated in favour of importlib.*", - category=DeprecationWarning, - ) - import serial - DISPLAY = b"\x1b=\x02\x1b@" PRINTER = b"\x1b=\x01\x1b@" LARGE = b"\x1d!\x11" diff --git a/requirements.in b/requirements.in index 713eb51..8dbb176 100644 --- a/requirements.in +++ b/requirements.in @@ -1,7 +1,7 @@ paho-mqtt PyQRCode Pillow -serial +pyserial pypng brother_ql PyYAML diff --git a/requirements.txt b/requirements.txt index a8651a1..cf2ef4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,11 +11,7 @@ brother-ql==0.9.4 click==8.1.7 # via brother-ql future==0.18.3 - # via - # brother-ql - # serial -iso8601==2.1.0 - # via serial + # via brother-ql packbits==0.6 # via brother-ql paho-mqtt==1.6.1 @@ -28,11 +24,9 @@ pypng==0.20220715.0 # via -r requirements.in pyqrcode==1.2.1 # via -r requirements.in +pyserial==3.5 + # via -r requirements.in pyusb==1.2.1 # via brother-ql pyyaml==6.0.1 - # via - # -r requirements.in - # serial -serial==0.0.97 # via -r requirements.in From 134129259d046ba27aa83babef8567a74b333936 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:11:37 +0200 Subject: [PATCH 28/76] Centraliseer validatie van gereserveerde input --- input_validation.py | 23 +++++++++++++++++++++++ plugins/market.py | 19 +++++-------------- plugins/products.py | 19 +++++-------------- tests/test_input_validation.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 input_validation.py create mode 100644 tests/test_input_validation.py diff --git a/input_validation.py b/input_validation.py new file mode 100644 index 0000000..91de91a --- /dev/null +++ b/input_validation.py @@ -0,0 +1,23 @@ +RESERVED_INPUTS = {"abort", "ok"} + + +def reserved_inputs(master=None, plugin_help=None): + reserved = set(RESERVED_INPUTS) + if isinstance(plugin_help, dict): + reserved.update(plugin_help.keys()) + master_help = getattr(master, "help", {}) + if isinstance(master_help, dict): + reserved.update(master_help.keys()) + return reserved + + +def is_reserved_input(text, master=None, plugin_help=None): + return text in reserved_inputs(master=master, plugin_help=plugin_help) + + +def filter_reserved_aliases(aliases, master=None, plugin_help=None): + return [ + alias + for alias in aliases + if not is_reserved_input(alias, master=master, plugin_help=plugin_help) + ] diff --git a/plugins/market.py b/plugins/market.py index ffbca06..b2738e3 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -3,8 +3,7 @@ import re import tempfile import threading - -RESERVED_INPUTS = {"abort", "ok"} +from input_validation import filter_reserved_aliases, is_reserved_input def _atomic_write(path, lines): @@ -46,16 +45,8 @@ def help(self): "market": "Market: Products", } - def reserved_inputs(self): - reserved = set(RESERVED_INPUTS) - reserved.update(self.help().keys()) - master_help = getattr(self.master, "help", {}) - if isinstance(master_help, dict): - reserved.update(master_help.keys()) - return reserved - def is_reserved_input(self, text): - return text in self.reserved_inputs() + return is_reserved_input(text, master=self.master, plugin_help=self.help()) def readproducts(self): self.products = {} @@ -73,9 +64,9 @@ def readproducts(self): name = aliases.pop(0) if self.is_reserved_input(name): continue - aliases = [ - alias for alias in aliases if not self.is_reserved_input(alias) - ] + aliases = filter_reserved_aliases( + aliases, master=self.master, plugin_help=self.help() + ) try: price = float(parts[2]) space = float(parts[3]) diff --git a/plugins/products.py b/plugins/products.py index 0da9401..17adccc 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -3,8 +3,7 @@ import re import tempfile import threading - -RESERVED_INPUTS = {"abort", "ok"} +from input_validation import filter_reserved_aliases, is_reserved_input def _atomic_write(path, lines): @@ -46,16 +45,8 @@ def help(self): "setprice": "Change the price of a product", } - def reserved_inputs(self): - reserved = set(RESERVED_INPUTS) - reserved.update(self.help().keys()) - master_help = getattr(self.master, "help", {}) - if isinstance(master_help, dict): - reserved.update(master_help.keys()) - return reserved - def is_reserved_input(self, text): - return text in self.reserved_inputs() + return is_reserved_input(text, master=self.master, plugin_help=self.help()) def readproducts(self): self.products = {} @@ -78,9 +69,9 @@ def readproducts(self): name = aliases.pop(0) if self.is_reserved_input(name): continue - aliases = [ - alias for alias in aliases if not self.is_reserved_input(alias) - ] + aliases = filter_reserved_aliases( + aliases, master=self.master, plugin_help=self.help() + ) try: price = float(parts[1]) except ValueError: diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py new file mode 100644 index 0000000..1150b20 --- /dev/null +++ b/tests/test_input_validation.py @@ -0,0 +1,30 @@ +from unittest.mock import Mock + +from input_validation import filter_reserved_aliases, is_reserved_input, reserved_inputs + + +def test_reserved_inputs_include_core_plugin_and_master_commands(): + master = Mock(help={"deposit": "Deposit Money"}) + + assert reserved_inputs(master=master, plugin_help={"addproduct": "Add"}) == { + "abort", + "ok", + "addproduct", + "deposit", + } + + +def test_is_reserved_input(): + master = Mock(help={"deposit": "Deposit Money"}) + + assert is_reserved_input("deposit", master=master) + assert is_reserved_input("abort", master=master) + assert not is_reserved_input("cola", master=master) + + +def test_filter_reserved_aliases_preserves_non_reserved_order(): + master = Mock(help={"deposit": "Deposit Money"}) + + assert filter_reserved_aliases( + ["alias1", "deposit", "ok", "alias2"], master=master + ) == ["alias1", "alias2"] From 77f6c4c65fc8a88c6b0b3336ac4aecabb572c467 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:14:27 +0200 Subject: [PATCH 29/76] Valideer PHP input voor MQTT endpoints --- www/post.php | 32 +++++++++++++++++-- www/spaceconsole/cmd.php | 62 +++++++++++++++++++++++++++++++------ www/spaceconsole/stream.php | 5 +-- www/stream.php | 26 +++++++++++++--- 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/www/post.php b/www/post.php index 63d96cc..f204ebd 100644 --- a/www/post.php +++ b/www/post.php @@ -1,16 +1,42 @@ 0, "error" => $message)); + exit; +} + +function request_string($key, $max_length) { + if (!isset($_REQUEST[$key]) || !is_string($_REQUEST[$key])) { + fail_request("Missing ".$key); + } + $value = $_REQUEST[$key]; + if ($value === "" || strlen($value) > $max_length) { + fail_request("Invalid ".$key); + } + return $value; +} + +$mqtt_host = getenv("MQTT_HOST"); if (empty($mqtt_host)) { $mqtt_host = "localhost"; } +$topic = request_string("topic", 128); +if (!isset($_REQUEST["msg"]) || !is_string($_REQUEST["msg"]) || strlen($_REQUEST["msg"]) > 512) { + fail_request("Invalid msg"); +} +$msg = $_REQUEST["msg"]; +if (!preg_match('/^session\/[A-Za-z0-9_-]{1,64}\/input$/', $topic)) { + fail_request("Invalid topic"); +} + +require("phpMQTT.php"); use Bluerhinos\phpMQTT; $mqtt = new phpMQTT($mqtt_host, 1883, "barclient".rand()); if($mqtt->connect()){ - $mqtt->publish("hack42bar/input/".$_REQUEST['topic'],$_REQUEST['msg'],1); + $mqtt->publish("hack42bar/input/".$topic, $msg, 1); $mqtt->close(); echo json_encode(array("done" => 1)); return ; diff --git a/www/spaceconsole/cmd.php b/www/spaceconsole/cmd.php index dc857a4..30a83b6 100644 --- a/www/spaceconsole/cmd.php +++ b/www/spaceconsole/cmd.php @@ -1,25 +1,70 @@ $message)); + exit; +} + +function request_string($key, $max_length) { + if (!isset($_REQUEST[$key]) || !is_string($_REQUEST[$key])) { + fail_request("Missing ".$key); + } + $value = $_REQUEST[$key]; + if ($value === "" || strlen($value) > $max_length) { + fail_request("Invalid ".$key); + } + return $value; +} + +$mqtt_host = getenv("MQTT_HOST"); if (empty($mqtt_host)) { $mqtt_host = "localhost"; } -$mqtt = new phpMQTT($mqtt_host, 1883, "barcmnd".rand()); - +$action = request_string("action", 16); +$device = ""; +$value = ""; +switch ($action) { + case 'toggle': + $device = request_string("device", 64); + if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $device)) { + fail_request("Invalid device"); + } + break; + case 'volume': + $value = request_string("value", 4); + if (!preg_match('/^[0-9]{1,3}$/', $value) || intval($value) > 100) { + fail_request("Invalid value"); + } + break; + case 'go': + case 'gdd': + case 'gd': + case 'bo': + case 'bd': + case 'ko': + case 'kd': + break; + default: + fail_request("Invalid action"); +} +require("../phpMQTT.php"); +use Bluerhinos\phpMQTT; +$mqtt = new phpMQTT($mqtt_host, 1883, "barcmnd".rand()); if(!$mqtt->connect()){ echo json_encode(array("message"=>"MQTT error")); exit(1); } - -switch($_REQUEST["action"]) { +switch($action) { case 'toggle': - $mqtt->publish("hack42/cmnd/".$_REQUEST["device"]."/POWER","toggle"); + $mqtt->publish("hack42/cmnd/".$device."/POWER","toggle"); + break; case 'volume': - $mqtt->publish("hack42/cmnd/sound/volume",$_REQUEST["value"]); + $mqtt->publish("hack42/cmnd/sound/volume",$value); + break; case 'go': $mqtt->publish("hack42/stookkelder/gebouw","open"); $mqtt->publish("hack42/touser/m1","Gebouw gaat open ".date("d/M H:i"),0,1); @@ -49,7 +94,6 @@ $mqtt->publish("hack42/touser/m1","Kapel gaat uit ".date("d/M H:i"),0,1); break; default: - echo json_encode(array("message"=>"Wrong password")); break; } ?> diff --git a/www/spaceconsole/stream.php b/www/spaceconsole/stream.php index 3beada7..674cc86 100644 --- a/www/spaceconsole/stream.php +++ b/www/spaceconsole/stream.php @@ -13,11 +13,12 @@ function procmsg($topic,$msg){ } ## -require("phpMQTT.php"); -$mqtt_host = $_ENV["MQTT_HOST"]; +require("../phpMQTT.php"); +$mqtt_host = getenv("MQTT_HOST"); if (empty($mqtt_host)) { $mqtt_host = "localhost"; } +use Bluerhinos\phpMQTT; $mqtt = new phpMQTT($mqtt_host, 1883, "barclient".rand()); if(!$mqtt->connect()){ exit(1); diff --git a/www/stream.php b/www/stream.php index 3fe91c1..4d278d1 100644 --- a/www/stream.php +++ b/www/stream.php @@ -6,6 +6,24 @@ @ini_set('implicit_flush',1); @ob_end_clean(); set_time_limit(0); +function fail_request($message) { + http_response_code(400); + echo "event: error\n"; + echo "data: ".json_encode(array("error" => $message))."\n\n"; + exit; +} + +function request_session() { + if (!isset($_REQUEST['session']) || !is_string($_REQUEST['session'])) { + fail_request("Missing session"); + } + $session = $_REQUEST['session']; + if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $session)) { + fail_request("Invalid session"); + } + return $session; +} + function procmsg($topic,$msg){ // $msg=substr($msg,2); echo "data: ".json_encode(array($topic,$msg))."\n\n"; @@ -14,18 +32,18 @@ function procmsg($topic,$msg){ } ## -require("phpMQTT.php"); - -$mqtt_host = $_ENV["MQTT_HOST"]; +$mqtt_host = getenv("MQTT_HOST"); if (empty($mqtt_host)) { $mqtt_host = "localhost"; } +$session = request_session(); +require("phpMQTT.php"); use Bluerhinos\phpMQTT; $mqtt = new phpMQTT($mqtt_host, 1883, "barclient".rand()); if(!$mqtt->connect()){ exit(1); } -$topics['hack42bar/output/session/'.$_REQUEST['session'].'/#'] = array("qos"=>0, "function"=>"procmsg"); +$topics['hack42bar/output/session/'.$session.'/#'] = array("qos"=>0, "function"=>"procmsg"); $mqtt->subscribe($topics,0); echo "retry: 1000\n"; echo "data: ".json_encode(array("startup","1"))."\n\n"; From 1f4ffb7b9992d77ef249e853bfaa3784a2508df0 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:15:54 +0200 Subject: [PATCH 30/76] Log centrale kassa exceptions expliciet --- kassa.py | 52 +++++++++++++++++++++++++-------------------- tests/test_kassa.py | 35 +++++++++++++++++++----------- 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/kassa.py b/kassa.py index 9169aa2..72b96c3 100755 --- a/kassa.py +++ b/kassa.py @@ -1,14 +1,15 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- import glob +import logging import os import time import json import sys -import traceback import paho.mqtt.client as mqtt from config import config_get +logger = logging.getLogger(__name__) sessions = {} @@ -76,8 +77,8 @@ def startup(self): self.send_message(True, "message", "loaded plugin " + plugname) try: self.help.update(self.plugins[plugname].help()) - except: - print(traceback.format_exc()) + except Exception: + logger.exception("Plugin %s help failed", plugname) print(self.plugins) self.receipt = self.plugins["receipt"] self.accounts = self.plugins["accounts"] @@ -90,8 +91,8 @@ def startup(self): for _plug, plugin in self.plugins.items(): try: plugin.startup() - except: - print(traceback.format_exc()) + except Exception: + logger.exception("Plugin %s startup failed", _plug) self.send_message(True, "commands", json.dumps(self.help)) self.send_message(True, "message", "Enter product, command or username") print(self.plugins) @@ -99,13 +100,13 @@ def startup(self): def realcallhook(self, hook, arg): for _plug, plugin in self.plugins.items(): try: - getattr(plugin, "hook_" + hook) - try: - getattr(plugin, "hook_" + hook)(arg) - except: - print(traceback.format_exc()) + hook_func = getattr(plugin, "hook_" + hook) except AttributeError: - pass + continue + try: + hook_func(arg) + except Exception: + logger.exception("Plugin %s hook_%s failed", _plug, hook) def callhook(self, hook, arg): self.realcallhook("pre_" + hook, arg) @@ -119,11 +120,13 @@ def donext(self, plug, function): def pre_input(self, text): for _plug, plugin in self.plugins.items(): try: - plugin.pre_input(text) + pre_input = getattr(plugin, "pre_input") except AttributeError: - pass - except: - print(traceback.format_exc()) + continue + try: + pre_input(text) + except Exception: + logger.exception("Plugin %s pre_input failed", _plug) def handle_nextcall(self, text): if not self.nextcall: @@ -136,8 +139,8 @@ def handle_nextcall(self, text): print(self.nextcall) print(getattr(plug, func)) return bool(getattr(plug, func)(text)) - except: - print(traceback.format_exc()) + except Exception: + logger.exception("Nextcall failed") return False def input(self, text): @@ -173,13 +176,15 @@ def handle_part(self, part): if not done: for _plug, plugin in self.plugins.items(): try: - if plugin.input(part): + plugin_input = getattr(plugin, "input") + except AttributeError: + continue + try: + if plugin_input(part): done = 1 break - except AttributeError: - print(traceback.format_exc()) - except: - print(traceback.format_exc()) + except Exception: + logger.exception("Plugin %s input failed", _plug) if not done: if self.plugins.get("withdraw") and self.plugins["withdraw"].withdraw(part): @@ -262,7 +267,8 @@ def run(): while True: time.sleep(1) # client.loop_forever() - except: + except Exception: + logger.exception("Kassa MQTT loop failed") time.sleep(5) diff --git a/tests/test_kassa.py b/tests/test_kassa.py index d76f60c..9ce6273 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -221,7 +221,7 @@ def test_input_with_nextcall_successful(): assert session.nextcall == {} -def test_startup_handles_help_and_plugin_startup_errors(): +def test_startup_handles_help_and_plugin_startup_errors(caplog): client_mock = Mock() session = kassa.Session("SID", client_mock) @@ -258,15 +258,18 @@ def startup(self): def import_from(_module, name): return plugin_classes[name] - with patch( - "glob.glob", return_value=[f"plugins/{name}.py" for name in plugin_classes] - ): - session.import_from = Mock(side_effect=import_from) - session.startup() + with caplog.at_level("ERROR", logger="kassa"): + with patch( + "glob.glob", return_value=[f"plugins/{name}.py" for name in plugin_classes] + ): + session.import_from = Mock(side_effect=import_from) + session.startup() assert "badhelp" in session.plugins assert "badstartup" in session.plugins assert session.help == {"good": "Good command"} + assert "Plugin badhelp help failed" in caplog.text + assert "Plugin badstartup startup failed" in caplog.text def test_startup_removes_existing_plugin_module(): @@ -286,15 +289,17 @@ def test_startup_removes_existing_plugin_module(): assert sys_modules_name not in kassa.sys.modules -def test_realcallhook_handles_plugin_hook_exception(): +def test_realcallhook_handles_plugin_hook_exception(caplog): session = kassa.Session("SID", Mock()) plugin_mock = Mock() plugin_mock.hook_test_hook.side_effect = RuntimeError("boom") session.plugins = {"test_plugin": plugin_mock} - session.realcallhook("test_hook", "test_arg") + with caplog.at_level("ERROR", logger="kassa"): + session.realcallhook("test_hook", "test_arg") plugin_mock.hook_test_hook.assert_called_with("test_arg") + assert "Plugin test_plugin hook_test_hook failed" in caplog.text def test_realcallhook_ignores_plugins_without_hook(): @@ -304,7 +309,7 @@ def test_realcallhook_ignores_plugins_without_hook(): session.realcallhook("missing", "arg") -def test_handle_nextcall_missing_and_exception(): +def test_handle_nextcall_missing_and_exception(caplog): session = kassa.Session("SID", Mock()) assert session.handle_nextcall("text") is False @@ -312,8 +317,11 @@ def test_handle_nextcall_missing_and_exception(): plugin_mock.fail.side_effect = RuntimeError("boom") session.nextcall = {"plug": plugin_mock, "function": "fail"} - assert session.handle_nextcall("text") is False + with caplog.at_level("ERROR", logger="kassa"): + assert session.handle_nextcall("text") is False + assert session.nextcall == {} + assert "Nextcall failed" in caplog.text def test_input_unknown_sets_message_and_calls_wrong_hook(): @@ -345,7 +353,7 @@ def test_input_unknown_sets_message_and_calls_wrong_hook(): ) -def test_handle_part_plugin_attribute_and_generic_exceptions_then_withdraw(): +def test_handle_part_plugin_attribute_and_generic_exceptions_then_withdraw(caplog): session = kassa.Session("SID", Mock()) missing_input_plugin = Mock() del missing_input_plugin.input @@ -360,8 +368,11 @@ def test_handle_part_plugin_attribute_and_generic_exceptions_then_withdraw(): "withdraw": withdraw_mock, } - assert session.handle_part("10") == 1 + with caplog.at_level("ERROR", logger="kassa"): + assert session.handle_part("10") == 1 + withdraw_mock.withdraw.assert_called_with("10") + assert "Plugin failing input failed" in caplog.text def test_handle_part_falls_back_to_newuser(): From 78b99dc2cfc833cb1d545dba6781cb16b7633a47 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:18:28 +0200 Subject: [PATCH 31/76] Maak transactiebedrag parsing expliciet --- plugins/declaratie.py | 59 +++++++++++++++----------------- plugins/deposit.py | 27 +++++++-------- plugins/give.py | 33 +++++++++--------- plugins/take.py | 6 ++-- tests/plugins/test_declaratie.py | 17 +++------ tests/plugins/test_deposit.py | 26 +++++++------- 6 files changed, 77 insertions(+), 91 deletions(-) diff --git a/plugins/declaratie.py b/plugins/declaratie.py index e68ec5a..0849e43 100644 --- a/plugins/declaratie.py +++ b/plugins/declaratie.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import json import time -import traceback class declaratie: @@ -63,6 +62,23 @@ def amount(self, text): return True try: value = float(text) + except ValueError: + self.master.donext(self, "amount") + if self.soort == "verkoop": + self.master.send_message( + True, "message", "Not a valid number! How much money did you get?" + ) + else: + self.master.send_message( + True, + "message", + "Not a valid number! How much money do you want back?", + ) + self.master.send_message( + True, "buttons", json.dumps({"special": "numbers"}) + ) + return True + else: if -5000 < value < 5000: self.value = value if self.soort == "verkoop": @@ -92,27 +108,6 @@ def amount(self, text): True, "buttons", json.dumps({"special": "numbers"}) ) return True - except: - traceback.print_exc() - - if text == "abort": - self.master.callhook("abort", None) - return True - self.master.donext(self, "amount") - if self.soort == "verkoop": - self.master.send_message( - True, "message", "Not a valid number! How much money did you get?" - ) - else: - self.master.send_message( - True, - "message", - "Not a valid number! How much money do you want back?", - ) - self.master.send_message( - True, "buttons", json.dumps({"special": "numbers"}) - ) - return True def reason(self, text): self.reden = text @@ -146,6 +141,10 @@ def runasbar(self, text): return True try: value = float(text) + except ValueError: + self.askbar("") + return True + else: if -5000 < value < 5000: if value > self.value: return self.askbar( @@ -156,10 +155,6 @@ def runasbar(self, text): return self.final() return self.askcash("") return self.askbar("Not between 0.01 and 4999.99; ") - except: - traceback.print_exc() - self.askbar("") - return True def askcash(self, error): self.master.donext(self, "runascash") @@ -191,6 +186,9 @@ def runascash(self, text): return True try: value = float(text) + except ValueError: + return self.askcash("") + else: if 0 <= value < 5000: if value > (self.value - self.asbar): return self.askcash( @@ -202,9 +200,6 @@ def runascash(self, text): return self.final() return self.askbank("") return self.askcash("Not between 0 and 4999.99; ") - except: - traceback.print_exc() - return self.askcash("") def askbank(self, error): self.master.donext(self, "runasbank") @@ -237,6 +232,9 @@ def runasbank(self, text): return True try: value = float(text) + except ValueError: + return self.askbank("") + else: if -5000 < value < 5000: if value > (self.value - self.asbar - self.ascash): return self.askbank( @@ -248,9 +246,6 @@ def runasbank(self, text): return self.final() return self.askbank("The numbers do not match; ") return self.askbank("Not between 0.01 and 4999.99; ") - except: - traceback.print_exc() - return self.askbank("") def bon(self): self.master.POS.printdeclaratie( diff --git a/plugins/deposit.py b/plugins/deposit.py index 850b9dd..68b68db 100644 --- a/plugins/deposit.py +++ b/plugins/deposit.py @@ -1,5 +1,4 @@ import json -import traceback class deposit: @@ -23,8 +22,21 @@ def input(self, text): return None def value(self, text): + if text == "abort": + self.master.callhook("abort", None) + return True try: value = float(text) + except ValueError: + self.master.donext(self, "value") + self.master.send_message( + True, "message", "Not a valid number! How much do you want to deposit?" + ) + self.master.send_message( + True, "buttons", json.dumps({"special": "numbers"}) + ) + return True + else: if 0 < value < 1000: self.master.receipt.add(False, value, "Deposit", 1, None, "deposit") else: @@ -36,19 +48,6 @@ def value(self, text): True, "buttons", json.dumps({"special": "numbers"}) ) return True - except: - traceback.print_exc() - if text == "abort": - self.master.callhook("abort", None) - return True - self.master.donext(self, "value") - self.master.send_message( - True, "message", "Not a valid number! How much do you want to deposit?" - ) - self.master.send_message( - True, "buttons", json.dumps({"special": "numbers"}) - ) - return True def startup(self): pass diff --git a/plugins/give.py b/plugins/give.py index 5f518cf..effcde4 100644 --- a/plugins/give.py +++ b/plugins/give.py @@ -36,8 +36,25 @@ def who(self, text): return True def amount(self, text): + if text == "abort": + self.master.callhook("abort", None) + return True try: value = float(text) + except ValueError: + self.master.donext(self, "amount") + self.master.send_message( + True, + "message", + "Not a valid number! How much do you want to give to " + + self.userto + + "?", + ) + self.master.send_message( + True, "buttons", json.dumps({"special": "numbers"}) + ) + return True + else: if 0 < value < 1000: self.value = value self.master.donext(self, "reason") @@ -62,22 +79,6 @@ def amount(self, text): True, "buttons", json.dumps({"special": "numbers"}) ) return True - except: - if text == "abort": - self.master.callhook("abort", None) - return True - self.master.donext(self, "amount") - self.master.send_message( - True, - "message", - "Not a valid number! How much do you want to give to " - + self.userto - + "?", - ) - self.master.send_message( - True, "buttons", json.dumps({"special": "numbers"}) - ) - return True def reason(self, text): if text == "abort": diff --git a/plugins/take.py b/plugins/take.py index 9763803..603d167 100644 --- a/plugins/take.py +++ b/plugins/take.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- import json import math -import traceback class take: @@ -43,6 +42,9 @@ def who(self, text): if len(self.totakefrom) > 0: try: value = round(float(text), 2) + except ValueError: + pass + else: if 0 < value < 1000: self.value = value self.peruser = ( @@ -72,8 +74,6 @@ def who(self, text): True, "buttons", json.dumps({"special": "numbers"}) ) return True - except: - traceback.print_exc() self.master.donext(self, "who") self.master.send_message( True, diff --git a/tests/plugins/test_declaratie.py b/tests/plugins/test_declaratie.py index 28423a5..5c77483 100644 --- a/tests/plugins/test_declaratie.py +++ b/tests/plugins/test_declaratie.py @@ -70,20 +70,13 @@ def test_amount_abort_original(self): assert self.declaratie.amount("abort") mock_callhook.assert_called_with("abort", None) - def test_amount_abort_inside_exception_branch(self): - class DelayedAbort: - def __init__(self): - self.calls = 0 - - def __eq__(self, other): - self.calls += 1 - return self.calls > 1 and other == "abort" + def test_amount_invalid_does_not_abort(self): + self.declaratie.soort = "declaratie" - def __float__(self): - raise ValueError("not numeric") + assert self.declaratie.amount("not numeric") is True - assert self.declaratie.amount(DelayedAbort()) is True - self.master_mock.callhook.assert_called_with("abort", None) + self.master_mock.callhook.assert_not_called() + self.master_mock.donext.assert_called_with(self.declaratie, "amount") def test_reason(self): self.declaratie.master.donext = Mock() diff --git a/tests/plugins/test_deposit.py b/tests/plugins/test_deposit.py index 03762c5..d36dba3 100644 --- a/tests/plugins/test_deposit.py +++ b/tests/plugins/test_deposit.py @@ -68,20 +68,18 @@ def test_deposit_value_non_numeric(): master_mock = Mock() deposit = deposit_module.deposit("SID", master_mock) - with patch("plugins.deposit.traceback.print_exc") as mock_traceback: - assert deposit.value("not_a_number") == True - mock_traceback.assert_called() - master_mock.donext.assert_called_with(deposit, "value") - master_mock.send_message.assert_has_calls( - [ - call( - True, - "message", - "Not a valid number! How much do you want to deposit?", - ), - call(True, "buttons", json.dumps({"special": "numbers"})), - ] - ) + assert deposit.value("not_a_number") == True + master_mock.donext.assert_called_with(deposit, "value") + master_mock.send_message.assert_has_calls( + [ + call( + True, + "message", + "Not a valid number! How much do you want to deposit?", + ), + call(True, "buttons", json.dumps({"special": "numbers"})), + ] + ) def test_deposit_startup(): From d718cb83f284d1ade2e2b18ce9130a0c86e123f8 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:21:12 +0200 Subject: [PATCH 32/76] Maak POS undo en datafile exceptions expliciet --- plugins/POS.py | 31 ++++++++--- plugins/accounts.py | 2 +- plugins/market.py | 2 +- plugins/products.py | 2 +- plugins/stock.py | 2 +- plugins/undo.py | 114 +++++++++++++++++++++++--------------- tests/plugins/test_POS.py | 11 ++-- 7 files changed, 101 insertions(+), 63 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index a9e1aa4..4c568dc 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import base64 -import traceback +import binascii import json import time import pickle @@ -228,16 +228,17 @@ def selectbon(self, text): return True try: bonID = int(text) - if bonID in self.bonnetjes: - self.bon(bonID) - return True + except (TypeError, ValueError): self.listbons() return True - except: - traceback.print_exc() - self.listbons() + + if bonID in self.bonnetjes: + self.bon(bonID) return True + self.listbons() + return True + def writebons(self): while len(self.bonnetjes) > 50: fk = sorted(self.bonnetjes.keys()) @@ -251,13 +252,25 @@ def loadbons(self): try: with open("data/revbank.POS", "rb") as f: data = f.read() + except OSError: + return + + try: try: loaded = json.loads(data.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): loaded = pickle.loads(data) self.bonnetjes = self.deserialize_bons(loaded) - except: - pass + except ( + AttributeError, + EOFError, + KeyError, + TypeError, + ValueError, + binascii.Error, + pickle.PickleError, + ): + return def serialize_bons(self, bonnetjes): output = {} diff --git a/plugins/accounts.py b/plugins/accounts.py index 888872f..6279aa6 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -16,7 +16,7 @@ def _atomic_write(path, lines): for line in lines: f.write(line) os.replace(tmp_path, path) - except: + except Exception: try: os.unlink(tmp_path) except FileNotFoundError: diff --git a/plugins/market.py b/plugins/market.py index b2738e3..8bfdd9d 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -16,7 +16,7 @@ def _atomic_write(path, lines): for line in lines: f.write(line) os.replace(tmp_path, path) - except: + except Exception: try: os.unlink(tmp_path) except FileNotFoundError: diff --git a/plugins/products.py b/plugins/products.py index 17adccc..bf29293 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -16,7 +16,7 @@ def _atomic_write(path, lines): for line in lines: f.write(line) os.replace(tmp_path, path) - except: + except Exception: try: os.unlink(tmp_path) except FileNotFoundError: diff --git a/plugins/stock.py b/plugins/stock.py index cf10fd2..7fb1b75 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -14,7 +14,7 @@ def _atomic_write(path, lines): for line in lines: f.write(line) os.replace(tmp_path, path) - except: + except Exception: try: os.unlink(tmp_path) except FileNotFoundError: diff --git a/plugins/undo.py b/plugins/undo.py index f373d01..262a83f 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -3,7 +3,6 @@ import copy import pickle import time -import traceback class undo: @@ -61,13 +60,23 @@ def loadundo(self): try: with open("data/revbank.UNDO", "rb") as f: data = f.read() + except OSError: + return + + try: try: loaded = json.loads(data.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): loaded = pickle.loads(data) self.undo = {int(transID): value for transID, value in loaded.items()} - except: - pass + except ( + AttributeError, + EOFError, + TypeError, + ValueError, + pickle.PickleError, + ): + return def doundo(self, text): if text == "abort": @@ -75,25 +84,55 @@ def doundo(self, text): return True try: transID = int(text) - if transID in self.undo: - self.master.callhook( - "undo", - ( - transID, - self.undo[transID]["totals"], - self.undo[transID]["receipt"], - self.undo[transID]["beni"], - ), - ) - return True - print(self.undo.keys()) - print(f"transID not in undo: {transID}") + except (TypeError, ValueError): self.listundo() return True - except: - traceback.print_exc() + + if transID in self.undo: + entry = self._get_undo_entry(transID) + if entry is None: + self.listundo() + return True + totals, receipt, beni = entry + self.master.callhook( + "undo", + (transID, totals, receipt, beni), + ) + return True + + print(self.undo.keys()) + print(f"transID not in undo: {transID}") + self.listundo() + return True + + def _get_undo_entry(self, transID): + try: + return ( + self.undo[transID]["totals"], + self.undo[transID]["receipt"], + self.undo[transID]["beni"], + ) + except (KeyError, TypeError): + return None + + def _restore_receipt(self, receipt, beni): + try: + for rr in receipt: + nowbeni = None + if rr["beni"] != beni: + nowbeni = rr["beni"] + self.master.receipt.add( + rr["Lose"], + rr["value"], + rr["description"], + rr["count"], + nowbeni, + rr["product"], + ) + except (KeyError, TypeError): self.listundo() return True + return True def dorestore(self, text): if text == "abort": @@ -101,38 +140,23 @@ def dorestore(self, text): return True try: transID = int(text) - if transID in self.undo: - receipt = self.undo[transID]["receipt"] - beni = self.undo[transID]["beni"] - self.master.callhook( - "undo", - ( - transID, - self.undo[transID]["totals"], - self.undo[transID]["receipt"], - self.undo[transID]["beni"], - ), - ) - for rr in receipt: - nowbeni = None - if rr["beni"] != beni: - nowbeni = rr["beni"] - self.master.receipt.add( - rr["Lose"], - rr["value"], - rr["description"], - rr["count"], - nowbeni, - rr["product"], - ) - return True + except (TypeError, ValueError): + self.listundo() + return True + + if transID not in self.undo: self.listundo() return True - except: - traceback.print_exc() + + entry = self._get_undo_entry(transID) + if entry is None: self.listundo() return True + totals, receipt, beni = entry + self.master.callhook("undo", (transID, totals, receipt, beni)) + return self._restore_receipt(receipt, beni) + def listundo(self, restore=False): self.loadundo() custom = [] diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 7fa24a3..87d272c 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -332,11 +332,12 @@ def test_bon_nonexistent_bonID(self): def test_selectbon_invalid_bonID(self): self.setup_method(None) self.POS.bonnetjes = {} - with patch.object(self.POS, "bon"), patch( - "plugins.POS.traceback.print_exc" - ) as mock_traceback: + with patch.object(self.POS, "bon"), patch.object( + self.POS, "listbons" + ) as mock_listbons: assert self.POS.selectbon("invalid") - mock_traceback.assert_called() + self.POS.bon.assert_not_called() + mock_listbons.assert_called_once() def test_writebons_max_receipts(self): with patch("builtins.open", mock_open()): @@ -346,7 +347,7 @@ def test_writebons_max_receipts(self): def test_loadbons_file_error(self): self.POS.bonnetjes = {123: {"bon": "stale"}} - with patch("builtins.open", new_callable=mock_open(), side_effect=Exception): + with patch("builtins.open", new_callable=mock_open(), side_effect=OSError): self.POS.loadbons() assert self.POS.bonnetjes == {} From 8065f6f829750c4b3b1d1f38c315211090f2b45f Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:23:25 +0200 Subject: [PATCH 33/76] Maak product markt en voorraad parsing expliciet --- plugins/market.py | 82 +++++++++++++++--------------------- plugins/products.py | 70 +++++++++++++++---------------- plugins/stock.py | 84 ++++++++++++++++++------------------- tests/plugins/test_stock.py | 24 +++++------ 4 files changed, 119 insertions(+), 141 deletions(-) diff --git a/plugins/market.py b/plugins/market.py index 8bfdd9d..1ac64f2 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -216,21 +216,21 @@ def saveprice(self, text): return self.master.callhook("abort", None) try: price = float(text) - if not 0 < price < 1000: - return self.messageandbuttons( - "saveprice", "numbers", "Price should be between 0 and 1000" - ) - self.newprodprice = price - self.products[self.priceprod]["price"] = price - self.writeproducts() - self.readproducts() - return True - except: + except (TypeError, ValueError): return self.messageandbuttons( "saveprice", "numbers", "Not a valid number; What is the price for" + self.newprod + "?", ) + if not 0 < price < 1000: + return self.messageandbuttons( + "saveprice", "numbers", "Price should be between 0 and 1000" + ) + self.newprodprice = price + self.products[self.priceprod]["price"] = price + self.writeproducts() + self.readproducts() + return True def addproductgroup(self, text): if text == "abort": @@ -279,34 +279,34 @@ def addproductprice(self, text): return self.master.callhook("abort", None) try: price = float(text) - if not 0 < price < 1000: - return self.messageandbuttons( - "addproductprice", - "numbers", - "Price should be between 0 and 1000", - ) - self.newprodprice = price - self.master.donext(self, "addproductgroup") - self.master.send_message( - True, "message", "what productgroup to add the product to?" - ) - self.master.send_message( - True, - "buttons", - json.dumps( - { - "special": "custom", - "custom": [{"text": n, "display": n} for n in self.groups], - } - ), - ) - return True - except: + except (TypeError, ValueError): return self.messageandbuttons( "addproductprice", "numbers", "Not a valid number; What is the price for" + self.newprod + "?", ) + if not 0 < price < 1000: + return self.messageandbuttons( + "addproductprice", + "numbers", + "Price should be between 0 and 1000", + ) + self.newprodprice = price + self.master.donext(self, "addproductgroup") + self.master.send_message( + True, "message", "what productgroup to add the product to?" + ) + self.master.send_message( + True, + "buttons", + json.dumps( + { + "special": "custom", + "custom": [{"text": n, "display": n} for n in self.groups], + } + ), + ) + return True def addproductdesc(self, text): if text == "abort": @@ -415,22 +415,6 @@ def input(self, text): "delmarket", "keyboard", "What market product do you want to remove?" ) - # elif text=="aliasproduct": - # return self.messageandbuttons('addalias','products','What product do you want to alias?') - # elif text=="addproduct": - # return self.messageandbuttons('addproduct','keyboard','What is the name of the product you want to add?') - # elif text=="setprice": - # return self.messageandbuttons('setprice','products','What product to change the price for?') - # elif text.endswith('*'): - # try: - # value=float(text[:-1]) - # if value>0 and value<100: - # self.times=value - # self.master.send_message(True,'message',"What are you buying %d from?" % self.times) - # self.master.send_message(True,'buttons',json.dumps({'special':'products'})) - # return True - # except: - # pass return None def hook_abort(self, _void): diff --git a/plugins/products.py b/plugins/products.py index bf29293..fe8cac7 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -202,22 +202,22 @@ def saveprice(self, text): return self.master.callhook("abort", None) try: price = float(text) - print(price) - if not 0 < price < 1000: - return self.messageandbuttons( - "saveprice", "numbers", "Price should be between 0 and 1000" - ) - self.newprodprice = price - self.products[self.priceprod]["price"] = self.newprodprice - self.writeproducts() - self.readproducts() - return True - except: + except (TypeError, ValueError): return self.messageandbuttons( "saveprice", "numbers", "Not a valid number; What is the price for" + self.newprod + "?", ) + print(price) + if not 0 < price < 1000: + return self.messageandbuttons( + "saveprice", "numbers", "Price should be between 0 and 1000" + ) + self.newprodprice = price + self.products[self.priceprod]["price"] = self.newprodprice + self.writeproducts() + self.readproducts() + return True def addproductgroup(self, text): if text == "abort": @@ -266,34 +266,34 @@ def addproductprice(self, text): return self.master.callhook("abort", None) try: price = float(text) - if not 0 < price < 1000: - return self.messageandbuttons( - "addproductprice", - "numbers", - "Price should be between 0 and 1000", - ) - self.newprodprice = price - self.master.donext(self, "addproductgroup") - self.master.send_message( - True, "message", "what productgroup to add the product to?" - ) - self.master.send_message( - True, - "buttons", - json.dumps( - { - "special": "custom", - "custom": [{"text": n, "display": n} for n in self.groups], - } - ), - ) - return True - except: + except (TypeError, ValueError): return self.messageandbuttons( "addproductprice", "numbers", "Not a valid number; What is the price for" + self.newprod + "?", ) + if not 0 < price < 1000: + return self.messageandbuttons( + "addproductprice", + "numbers", + "Price should be between 0 and 1000", + ) + self.newprodprice = price + self.master.donext(self, "addproductgroup") + self.master.send_message( + True, "message", "what productgroup to add the product to?" + ) + self.master.send_message( + True, + "buttons", + json.dumps( + { + "special": "custom", + "custom": [{"text": n, "display": n} for n in self.groups], + } + ), + ) + return True def addproductdesc(self, text): if text == "abort": @@ -377,7 +377,7 @@ def input(self, text): True, "buttons", json.dumps({"special": "products"}) ) return True - except: + except (TypeError, ValueError): pass return None diff --git a/plugins/stock.py b/plugins/stock.py index 7fb1b75..42e7ffb 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -117,42 +117,40 @@ def writestock(self): ) def voorraad_amount(self, text): + if text == "abort": + self.master.callhook("abort", None) + return True try: aantal = int(text) - if not -1 < aantal < 5000: - self.master.donext(self, "voorraad_amount") - self.master.send_message( - True, - "message", - "Please enter a number between 0 and 4999, how much " - + self.prod - + " is in stock?", - ) - self.master.send_message( - True, "buttons", json.dumps({"special": "numbers"}) - ) - return True - self.setstock(self.prod, aantal) - self.master.donext(self, "voorraad") - self.master.send_message(True, "message", "What product to set the stock?") + except (TypeError, ValueError): + self.master.donext(self, "voorraad_amount") self.master.send_message( - True, "buttons", json.dumps({"special": "products"}) + True, + "message", + "Not a number, how much " + self.prod + " is in stock", + ) + self.master.send_message( + True, "buttons", json.dumps({"special": "numbers"}) ) return True - except: - if text == "abort": - self.master.callhook("abort", None) - return True + if not -1 < aantal < 5000: self.master.donext(self, "voorraad_amount") self.master.send_message( True, "message", - "Not a number, how much " + self.prod + " is in stock", + "Please enter a number between 0 and 4999, how much " + + self.prod + + " is in stock?", ) self.master.send_message( True, "buttons", json.dumps({"special": "numbers"}) ) return True + self.setstock(self.prod, aantal) + self.master.donext(self, "voorraad") + self.master.send_message(True, "message", "What product to set the stock?") + self.master.send_message(True, "buttons", json.dumps({"special": "products"})) + return True def voorraad(self, text): prod = self.master.products.lookupprod(text) @@ -177,42 +175,40 @@ def voorraad(self, text): return True def inkoop_amount(self, text): + if text == "abort": + self.master.callhook("abort", None) + return True try: aantal = int(text) - if not 0 < aantal < 5000: - self.master.donext(self, "inkoop_amount") - self.master.send_message( - True, - "message", - "Please enter a number between 1 and 4999, how much " - + self.prod - + " did you buy?", - ) - self.master.send_message( - True, "buttons", json.dumps({"special": "numbers"}) - ) - return True - self.addstock(self.prod, aantal) - self.master.donext(self, "inkoop") - self.master.send_message(True, "message", "What product did you buy?") + except (TypeError, ValueError): + self.master.donext(self, "inkoop_amount") self.master.send_message( - True, "buttons", json.dumps({"special": "products"}) + True, + "message", + "Not a number, how much " + self.prod + " did you buy?", + ) + self.master.send_message( + True, "buttons", json.dumps({"special": "numbers"}) ) return True - except: - if text == "abort": - self.master.callhook("abort", None) - return True + if not 0 < aantal < 5000: self.master.donext(self, "inkoop_amount") self.master.send_message( True, "message", - "Not a number, how much " + self.prod + " did you buy?", + "Please enter a number between 1 and 4999, how much " + + self.prod + + " did you buy?", ) self.master.send_message( True, "buttons", json.dumps({"special": "numbers"}) ) return True + self.addstock(self.prod, aantal) + self.master.donext(self, "inkoop") + self.master.send_message(True, "message", "What product did you buy?") + self.master.send_message(True, "buttons", json.dumps({"special": "products"})) + return True def inkoop(self, text): prod = self.master.products.lookupprod(text) diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index fa80d95..a7d70d6 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -1,6 +1,7 @@ from unittest.mock import Mock, patch, mock_open, call import plugins.stock as stock_module import json +import pytest def test_stock_constructor(): @@ -340,12 +341,15 @@ def test_stock_voorraad_amount_valid(): stock = stock_module.stock("SID", master_mock) stock.prod = "product1" - assert stock.voorraad_amount("10") == True - master_mock.donext.assert_called_with(stock, "voorraad_amount") + with patch.object(stock, "setstock") as mock_setstock: + assert stock.voorraad_amount("10") == True + + mock_setstock.assert_called_with("product1", 10) + master_mock.donext.assert_called_with(stock, "voorraad") master_mock.send_message.assert_has_calls( [ - call(True, "message", "Not a number, how much product1 is in stock"), - call(True, "buttons", '{"special": "numbers"}'), + call(True, "message", "What product to set the stock?"), + call(True, "buttons", '{"special": "products"}'), ] ) @@ -471,12 +475,6 @@ def test_stock_voorraad_amount_error_handling(): stock = stock_module.stock("SID", master_mock) stock.prod = "product1" - with patch.object(stock, "setstock", side_effect=Exception("Set stock error")): - assert stock.voorraad_amount("1000") == True - # Check that error handling branches are covered - master_mock.send_message.assert_has_calls( - [ - call(True, "message", "Not a number, how much product1 is in stock"), - call(True, "buttons", '{"special": "numbers"}'), - ] - ) + with patch.object(stock, "setstock", side_effect=RuntimeError("Set stock error")): + with pytest.raises(RuntimeError, match="Set stock error"): + stock.voorraad_amount("1000") From 50443ec993be5d6a53385c2a90407e2988376ab8 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:25:18 +0200 Subject: [PATCH 34/76] Maak resterende plugin exceptions expliciet --- plugins/receipt.py | 10 +-- plugins/stickers.py | 111 ++++++++++++--------------------- plugins/withdraw.py | 23 ++++--- tests/plugins/test_receipt.py | 12 ++++ tests/plugins/test_stickers.py | 9 +++ tests/plugins/test_withdraw.py | 10 +++ 6 files changed, 88 insertions(+), 87 deletions(-) diff --git a/plugins/receipt.py b/plugins/receipt.py index 8103a7b..b4e1c61 100644 --- a/plugins/receipt.py +++ b/plugins/receipt.py @@ -129,12 +129,12 @@ def remove(self, text): try: num = int(text) self.receipt.pop(num) - self.master.send_message(True, "receipt", json.dumps(self.receipt)) - self.updatetotals() - self.master.callhook("addremove", ()) - return True - except: + except (TypeError, ValueError, IndexError): return True + self.master.send_message(True, "receipt", json.dumps(self.receipt)) + self.updatetotals() + self.master.callhook("addremove", ()) + return True def startup(self): self.updatetotals() diff --git a/plugins/stickers.py b/plugins/stickers.py index e4f7023..cb094a2 100644 --- a/plugins/stickers.py +++ b/plugins/stickers.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import traceback import json import re import io @@ -273,21 +272,11 @@ def eigendomprint(self): def barcodenum(self, text): if text == "abort": return self.master.callhook("abort", None) - try: - self.copies = int(text) - if not 0 < self.copies < 100: - return self.messageandbuttons( - "barcodenum", - "numbers", - "Only 1 <> 99 allowed; How many do you want?", - ) - self.barcodeprint() - return True - except: - traceback.print_exc() - return self.messageandbuttons( - "barcodenum", "numbers", "NaN ; How many do you want?" - ) + result = self.read_copy_count(text, "barcodenum") + if result is not None: + return result + self.barcodeprint() + return True def barcodecount(self, text): prod = self.master.products.lookupprod(text) @@ -318,24 +307,30 @@ def messageandbuttons(self, donext, buttons, msg): self.master.send_message(True, "buttons", json.dumps({"special": buttons})) return True - def eigendomnum(self, text): - if text == "abort": - return self.master.callhook("abort", None) + def read_copy_count(self, text, nextcall): try: - self.copies = int(text) - if not 0 < self.copies < 100: - return self.messageandbuttons( - "eigendomnum", - "numbers", - "Only 1 <> 99 allowed; How many do you want?", - ) - self.eigendomprint() - return True - except: - traceback.print_exc() + copies = int(text) + except (TypeError, ValueError): + return self.messageandbuttons( + nextcall, "numbers", "NaN ; How many do you want?" + ) + if not 0 < copies < 100: return self.messageandbuttons( - "eigendomnum", "numbers", "NaN ; How many do you want?" + nextcall, + "numbers", + "Only 1 <> 99 allowed; How many do you want?", ) + self.copies = copies + return None + + def eigendomnum(self, text): + if text == "abort": + return self.master.callhook("abort", None) + result = self.read_copy_count(text, "eigendomnum") + if result is not None: + return result + self.eigendomprint() + return True def eigendomcount(self, text): if text == "abort": @@ -346,53 +341,29 @@ def eigendomcount(self, text): def foodnum(self, text): if text == "abort": return self.master.callhook("abort", None) - try: - self.copies = int(text) - if not 0 < self.copies < 100: - return self.messageandbuttons( - "foodnum", "numbers", "Only 1 <> 99 allowed; How many do you want?" - ) - self.foodprint() - return True - except: - traceback.print_exc() - return self.messageandbuttons( - "foodnum", "numbers", "NaN ; How many do you want?" - ) + result = self.read_copy_count(text, "foodnum") + if result is not None: + return result + self.foodprint() + return True def thtnum(self, text): if text == "abort": return self.master.callhook("abort", None) - try: - self.copies = int(text) - if not 0 < self.copies < 100: - return self.messageandbuttons( - "thtnum", "numbers", "Only 1 <> 99 allowed; How many do you want?" - ) - self.thtprint() - return True - except: - traceback.print_exc() - return self.messageandbuttons( - "thtnum", "numbers", "NaN ; How many do you want?" - ) + result = self.read_copy_count(text, "thtnum") + if result is not None: + return result + self.thtprint() + return True def toolnum(self, text): if text == "abort": return self.master.callhook("abort", None) - try: - self.copies = int(text) - if not 0 < self.copies < 100: - return self.messageandbuttons( - "toolnum", "numbers", "Only 1 <> 99 allowed; How many do you want?" - ) - self.toolprint() - return True - except: - traceback.print_exc() - return self.messageandbuttons( - "toolnum", "numbers", "NaN ; How many do you want?" - ) + result = self.read_copy_count(text, "toolnum") + if result is not None: + return result + self.toolprint() + return True def foodname(self, text): if text == "abort": diff --git a/plugins/withdraw.py b/plugins/withdraw.py index e8ba504..becb850 100644 --- a/plugins/withdraw.py +++ b/plugins/withdraw.py @@ -9,20 +9,19 @@ def __init__(self, SID, master): def withdraw(self, text): try: value = float(text) - if 0 < value < 1000: - self.master.receipt.add( - True, value, "Withdrawal or unlisted product", 1, None, "withdraw" - ) - return True - self.master.send_message( - True, - "message", - "Enter an amount between 0.01 and 999.99 or scan a product", + except (TypeError, ValueError): + return None + if 0 < value < 1000: + self.master.receipt.add( + True, value, "Withdrawal or unlisted product", 1, None, "withdraw" ) return True - except: - pass - return None + self.master.send_message( + True, + "message", + "Enter an amount between 0.01 and 999.99 or scan a product", + ) + return True def input(self, text): pass diff --git a/tests/plugins/test_receipt.py b/tests/plugins/test_receipt.py index ae85a3e..8b3e5d9 100644 --- a/tests/plugins/test_receipt.py +++ b/tests/plugins/test_receipt.py @@ -166,6 +166,18 @@ def test_remove_invalid_index(self): with patch.object(self.receipt_instance.master, "send_message"): self.assertTrue(self.receipt_instance.remove("invalid")) + def test_remove_out_of_range_does_not_update_receipt(self): + self.receipt_instance.receipt = [ + {"description": "item1"}, + {"description": "item2"}, + ] + + with patch.object(self.receipt_instance.master, "send_message") as send_message: + self.assertTrue(self.receipt_instance.remove("99")) + + send_message.assert_not_called() + self.receipt_instance.master.callhook.assert_not_called() + def test_startup(self): with patch.object(self.receipt_instance, "updatetotals") as mock_updatetotals: self.receipt_instance.startup() diff --git a/tests/plugins/test_stickers.py b/tests/plugins/test_stickers.py index 06ae15a..2cbc661 100644 --- a/tests/plugins/test_stickers.py +++ b/tests/plugins/test_stickers.py @@ -1,5 +1,6 @@ import io import base64 +import pytest from unittest.mock import Mock, call from unittest.mock import patch @@ -294,6 +295,14 @@ def test_toolnum_prints_label(_cups): assert sticky.copies == 1 +def test_number_print_errors_are_not_swallowed(): + sticky = stickers("main", Mock()) + + with patch.object(sticky, "barcodeprint", side_effect=RuntimeError("print failed")): + with pytest.raises(RuntimeError, match="print failed"): + sticky.barcodenum("1") + + @patch("plugins.stickers.brother_ql.backends.helpers") def test_toolprint_binary_qrcode_path(_cups): sticky = stickers("main", Mock()) diff --git a/tests/plugins/test_withdraw.py b/tests/plugins/test_withdraw.py index 0e3d17c..57b3ae3 100644 --- a/tests/plugins/test_withdraw.py +++ b/tests/plugins/test_withdraw.py @@ -1,5 +1,6 @@ from unittest.mock import Mock, patch import plugins.withdraw as withdraw_module +import pytest def test_withdraw_constructor(): @@ -39,6 +40,15 @@ def test_withdraw_non_numeric_input(): assert withdraw.withdraw("not_a_number") is None +def test_withdraw_receipt_errors_are_not_swallowed(): + master_mock = Mock() + withdraw = withdraw_module.withdraw("SID", master_mock) + master_mock.receipt.add.side_effect = RuntimeError("receipt failed") + + with pytest.raises(RuntimeError, match="receipt failed"): + withdraw.withdraw("5") + + def test_withdraw_input(): master_mock = Mock() withdraw = withdraw_module.withdraw("SID", master_mock) From f0ca43e9c496ba4925efa4c084eaf8ef7b6ed012 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:28:01 +0200 Subject: [PATCH 35/76] Centraliseer product input validatie --- input_validation.py | 20 ++++++++++++++++++++ plugins/market.py | 12 ++++++++---- plugins/products.py | 12 ++++++++---- tests/plugins/test_market.py | 16 ++++++++++++++++ tests/plugins/test_products.py | 17 +++++++++++++++++ tests/test_input_validation.py | 24 +++++++++++++++++++++++- tests/test_kassa.py | 31 +++++++++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 9 deletions(-) diff --git a/input_validation.py b/input_validation.py index 91de91a..03ce179 100644 --- a/input_validation.py +++ b/input_validation.py @@ -1,4 +1,8 @@ +import re + + RESERVED_INPUTS = {"abort", "ok"} +INPUT_TOKEN_RE = re.compile(r"^[A-Za-z0-9]+$") def reserved_inputs(master=None, plugin_help=None): @@ -21,3 +25,19 @@ def filter_reserved_aliases(aliases, master=None, plugin_help=None): for alias in aliases if not is_reserved_input(alias, master=master, plugin_help=plugin_help) ] + + +def is_valid_input_token(text, min_length): + return ( + isinstance(text, str) + and len(text) >= min_length + and INPUT_TOKEN_RE.fullmatch(text) is not None + ) + + +def is_valid_alias(text): + return is_valid_input_token(text, min_length=6) + + +def is_valid_product_name(text): + return is_valid_input_token(text, min_length=4) diff --git a/plugins/market.py b/plugins/market.py index 1ac64f2..6acfaa6 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -1,9 +1,13 @@ import json import os -import re import tempfile import threading -from input_validation import filter_reserved_aliases, is_reserved_input +from input_validation import ( + filter_reserved_aliases, + is_reserved_input, + is_valid_alias, + is_valid_product_name, +) def _atomic_write(path, lines): @@ -151,7 +155,7 @@ def savealias(self, text): "keyboard", "That alias is a command; choose another alias.", ) - if len(text) < 6 or not re.compile("^[A-z0-9]+$").match(text): + if not is_valid_alias(text): return self.messageandbuttons( "savealias", "keyboard", @@ -338,7 +342,7 @@ def addproduct(self, text): "keyboard", "That product name is a command; choose another name.", ) - if len(text) < 4 or not re.compile("^[A-z0-9]+$").match(text): + if not is_valid_product_name(text): return self.messageandbuttons( "addproduct", "keyboard", diff --git a/plugins/products.py b/plugins/products.py index fe8cac7..61e3273 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -1,9 +1,13 @@ import json import os -import re import tempfile import threading -from input_validation import filter_reserved_aliases, is_reserved_input +from input_validation import ( + filter_reserved_aliases, + is_reserved_input, + is_valid_alias, + is_valid_product_name, +) def _atomic_write(path, lines): @@ -156,7 +160,7 @@ def savealias(self, text): "keyboard", "That alias is a command; choose another alias.", ) - if len(text) < 6 or not re.compile("^[A-z0-9]+$").match(text): + if not is_valid_alias(text): return self.messageandbuttons( "savealias", "keyboard", @@ -325,7 +329,7 @@ def addproduct(self, text): "keyboard", "That product name is a command; choose another name.", ) - if len(text) < 4 or not re.compile("^[A-z0-9]+$").match(text): + if not is_valid_product_name(text): return self.messageandbuttons( "addproduct", "keyboard", diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index 67d7416..4c427aa 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -151,6 +151,19 @@ def test_savealias_rejects_command_alias(self): assert self.market.products["product1"]["aliases"] == [] self.master_mock.donext.assert_called_with(self.market, "savealias") + def test_savealias_rejects_non_alnum_alias(self): + self.market.products = {"product1": {"aliases": []}} + self.market.aliasprod = "product1" + + with patch.object(self.market, "readproducts"), patch.object( + self.market, "writeproducts" + ) as mock_writeproducts: + assert self.market.savealias("bad_alias") is True + + mock_writeproducts.assert_not_called() + assert self.market.products["product1"]["aliases"] == [] + self.master_mock.donext.assert_called_with(self.market, "savealias") + def test_addalias(self): self.market.products = {"product1": {"aliases": []}} assert self.market.addalias("product1") @@ -315,6 +328,9 @@ def test_addproduct_abort_and_invalid_name(self): assert self.market.addproduct("abort") is self.master_mock.callhook.return_value self.master_mock.callhook.assert_called_with("abort", None) assert self.market.addproduct("bad!") is True + self.master_mock.reset_mock() + assert self.market.addproduct("bad_name") is True + self.master_mock.donext.assert_called_with(self.market, "addproduct") def test_addproduct_rejects_command_name(self): self.master_mock.help = {"deposit": "Deposit Money"} diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 295bda3..93baebd 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -270,6 +270,19 @@ def test_savealias_rejects_command_alias(self): assert self.products.products["product1"]["aliases"] == [] self.master_mock.donext.assert_called_with(self.products, "savealias") + def test_savealias_rejects_non_alnum_alias(self): + self.products.products = {"product1": {"aliases": []}} + self.products.aliasprod = "product1" + + with patch.object(self.products, "readproducts"), patch.object( + self.products, "writeproducts" + ) as mock_writeproducts: + assert self.products.savealias("bad_alias") + + mock_writeproducts.assert_not_called() + assert self.products.products["product1"]["aliases"] == [] + self.master_mock.donext.assert_called_with(self.products, "savealias") + def test_saveprice_out_of_range(self): self.products.products = {"product1": {"price": 2.5}} self.products.priceprod = "product1" @@ -326,6 +339,10 @@ def test_addproduct_existing_invalid_and_valid(self): assert self.products.addproduct("bad name") self.master_mock.donext.assert_called_with(self.products, "addproduct") + self.master_mock.reset_mock() + assert self.products.addproduct("bad_name") + self.master_mock.donext.assert_called_with(self.products, "addproduct") + self.master_mock.reset_mock() assert self.products.addproduct("product2") assert self.products.newprod == "product2" diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py index 1150b20..2cc9408 100644 --- a/tests/test_input_validation.py +++ b/tests/test_input_validation.py @@ -1,6 +1,13 @@ from unittest.mock import Mock -from input_validation import filter_reserved_aliases, is_reserved_input, reserved_inputs +from input_validation import ( + filter_reserved_aliases, + is_reserved_input, + is_valid_alias, + is_valid_input_token, + is_valid_product_name, + reserved_inputs, +) def test_reserved_inputs_include_core_plugin_and_master_commands(): @@ -28,3 +35,18 @@ def test_filter_reserved_aliases_preserves_non_reserved_order(): assert filter_reserved_aliases( ["alias1", "deposit", "ok", "alias2"], master=master ) == ["alias1", "alias2"] + + +def test_input_token_validation_is_ascii_alnum_only(): + assert is_valid_input_token("abc123", min_length=4) + assert not is_valid_input_token("abc_123", min_length=4) + assert not is_valid_input_token("abc-123", min_length=4) + assert not is_valid_input_token("abc", min_length=4) + assert not is_valid_input_token(None, min_length=4) + + +def test_product_and_alias_validation_lengths(): + assert is_valid_product_name("prod") + assert not is_valid_product_name("pro") + assert is_valid_alias("alias1") + assert not is_valid_alias("abcde") diff --git a/tests/test_kassa.py b/tests/test_kassa.py index 9ce6273..dcae00a 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -2,6 +2,7 @@ from pathlib import Path from unittest.mock import Mock, patch, call import kassa +from plugins.products import products def test_session_startup(): @@ -395,6 +396,36 @@ def test_handle_part_falls_back_to_newuser(): accounts_mock.newuser.assert_called_with("newuser") +def test_session_product_add_flow_rejects_invalid_name(): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + products_plugin = products("SID", session) + withdraw_mock = Mock() + withdraw_mock.input.return_value = False + withdraw_mock.withdraw.return_value = False + accounts_mock = Mock() + accounts_mock.input.return_value = False + accounts_mock.newuser.return_value = False + session.plugins = { + "products": products_plugin, + "withdraw": withdraw_mock, + "accounts": accounts_mock, + } + session.products = products_plugin + + session.input("addproduct") + session.input("bad_name") + + assert products_plugin.newprod == "" + assert session.nextcall == {"plug": products_plugin, "function": "addproduct"} + client_mock.publish.assert_any_call( + "hack42bar/output/session/SID/message", + "only [A-z0-9] is allowed as product name, what name do you want to add?", + 1, + True, + ) + + def test_send_message_updates_prompt_buttons_and_skips_cached_long_topic(): client_mock = Mock() session = kassa.Session("SID", client_mock) From b6441db843c657bb8b912c2cc2534ebc80c98f5d Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:29:41 +0200 Subject: [PATCH 36/76] Test checkout undo en restore integratie --- tests/test_checkout_undo_integration.py | 104 ++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/test_checkout_undo_integration.py diff --git a/tests/test_checkout_undo_integration.py b/tests/test_checkout_undo_integration.py new file mode 100644 index 0000000..8b9f3c5 --- /dev/null +++ b/tests/test_checkout_undo_integration.py @@ -0,0 +1,104 @@ +from unittest.mock import Mock, patch + +import kassa +from plugins.receipt import receipt +from plugins.undo import undo + + +def make_session(): + session = kassa.Session("SID", Mock()) + receipt_plugin = receipt("SID", session) + undo_plugin = undo("SID", session) + session.plugins = {"receipt": receipt_plugin, "undo": undo_plugin} + session.receipt = receipt_plugin + session.transID = 123 + return session, receipt_plugin, undo_plugin + + +def test_checkout_stores_undo_snapshot_after_receipt_checkout(): + session, receipt_plugin, undo_plugin = make_session() + + receipt_plugin.add(True, 2.5, "Product -you-", 2, None, "product1") + + with patch.object(undo_plugin, "loadundo"), patch.object(undo_plugin, "writeundo"): + session.callhook("checkout", "alice") + + assert undo_plugin.undo[123]["beni"] == "alice" + assert undo_plugin.undo[123]["totals"] == {"alice": -5.0} + assert undo_plugin.undo[123]["receipt"] == [ + { + "Lose": True, + "description": "Product alice", + "value": 2.5, + "count": 2, + "beni": "alice", + "total": 5.0, + "product": "product1", + } + ] + + +def test_doundo_runs_through_session_hooks_and_closes_receipt(): + session, receipt_plugin, undo_plugin = make_session() + undo_plugin.undo = { + 123: { + "totals": {"alice": -5.0}, + "receipt": [ + { + "Lose": True, + "description": "Product alice", + "value": 2.5, + "count": 2, + "beni": "alice", + "total": 5.0, + "product": "product1", + } + ], + "beni": "alice", + } + } + session.transID = 124 + + with patch.object(undo_plugin, "loadundo"), patch.object(undo_plugin, "writeundo"): + assert undo_plugin.doundo("123") + + assert 123 not in undo_plugin.undo + assert undo_plugin.undo[124]["totals"] == {"alice": 5.0} + assert receipt_plugin.receipt == [] + + +def test_restore_runs_undo_then_restores_original_receipt_lines(): + session, receipt_plugin, undo_plugin = make_session() + undo_plugin.undo = { + 123: { + "totals": {"alice": -5.0}, + "receipt": [ + { + "Lose": True, + "description": "Product alice", + "value": 2.5, + "count": 2, + "beni": "alice", + "total": 5.0, + "product": "product1", + } + ], + "beni": "alice", + } + } + session.transID = 124 + + with patch.object(undo_plugin, "loadundo"), patch.object(undo_plugin, "writeundo"): + assert undo_plugin.dorestore("123") + + assert receipt_plugin.receipt == [ + { + "Lose": True, + "description": "Product alice", + "value": 2.5, + "count": 2, + "beni": None, + "total": 5.0, + "product": "product1", + } + ] From a819a6797551fa75344f2e3385372fdccee55be7 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:33:38 +0200 Subject: [PATCH 37/76] Structureer runtime logging --- config.py | 3 +++ config.yaml.example | 3 +++ kassa.py | 52 ++++++++++++++++++++++++++------------ plugins/POS.py | 5 +++- plugins/accounts.py | 8 ++++-- plugins/door.py | 7 ++++- plugins/market.py | 8 ++++-- plugins/products.py | 19 ++++++++++++-- plugins/receipt.py | 6 ++++- plugins/stickers.py | 10 +++++--- plugins/undo.py | 12 +++++++-- tests/plugins/test_door.py | 4 +-- tests/test_config.py | 2 ++ tests/test_kassa.py | 18 ++++++++++--- 14 files changed, 122 insertions(+), 35 deletions(-) diff --git a/config.py b/config.py index 105cc04..8cb4909 100644 --- a/config.py +++ b/config.py @@ -33,6 +33,9 @@ "baudrate": 19200, }, }, + "logging": { + "level": "INFO", + }, } diff --git a/config.yaml.example b/config.yaml.example index 35a6292..4be949e 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -25,3 +25,6 @@ pos: serial: port: /dev/ttyUSB0 baudrate: 19200 + +logging: + level: INFO diff --git a/kassa.py b/kassa.py index 72b96c3..505c99e 100755 --- a/kassa.py +++ b/kassa.py @@ -57,29 +57,38 @@ def __init__(self, SID, client): self.products = None def startup(self): - print("Startup", self.SID) + logger.info("session_startup sid=%s", self.SID) for fname in glob.glob("plugins/*.py"): plugname = os.path.splitext(os.path.basename(fname))[0] if plugname != "__init__" and not plugname in self.plugins: if plugname in sys.modules: del sys.modules[plugname] - print(self.plugins) + logger.debug( + "session_plugins_before_load sid=%s plugins=%s", self.SID, self.plugins + ) for fname in glob.glob("plugins/*.py"): plugname = os.path.splitext(os.path.basename(fname))[0] - print(plugname) + logger.debug("plugin_discovered sid=%s plugin=%s", self.SID, plugname) if plugname != "__init__" and not plugname in self.plugins: if plugname in sys.modules: del sys.modules[plugname] self.plugins[plugname] = self.import_from( "plugins." + plugname, plugname )(self.SID, self) - print(plugname, self.import_from("plugins." + plugname, plugname)) + logger.debug( + "plugin_loaded sid=%s plugin=%s class=%r", + self.SID, + plugname, + self.import_from("plugins." + plugname, plugname), + ) self.send_message(True, "message", "loaded plugin " + plugname) try: self.help.update(self.plugins[plugname].help()) except Exception: logger.exception("Plugin %s help failed", plugname) - print(self.plugins) + logger.debug( + "session_plugins_loaded sid=%s plugins=%s", self.SID, self.plugins + ) self.receipt = self.plugins["receipt"] self.accounts = self.plugins["accounts"] self.products = self.plugins["products"] @@ -95,7 +104,7 @@ def startup(self): logger.exception("Plugin %s startup failed", _plug) self.send_message(True, "commands", json.dumps(self.help)) self.send_message(True, "message", "Enter product, command or username") - print(self.plugins) + logger.info("session_ready sid=%s plugins=%s", self.SID, sorted(self.plugins)) def realcallhook(self, hook, arg): for _plug, plugin in self.plugins.items(): @@ -135,9 +144,13 @@ def handle_nextcall(self, text): plug = self.nextcall["plug"] func = self.nextcall["function"] self.nextcall = {} - print(text) - print(self.nextcall) - print(getattr(plug, func)) + logger.debug( + "nextcall sid=%s plugin=%s function=%s input=%r", + self.SID, + plug.__class__.__name__, + func, + text, + ) return bool(getattr(plug, func)(text)) except Exception: logger.exception("Nextcall failed") @@ -201,10 +214,11 @@ def send_message(self, retain, topic, message): != message or len(topic) < 8 ): - print( + logger.debug( + "send_message sid=%s retain=%s topic=%s message=%r", + self.SID, retain, - "hack42bar/output/session/" + self.SID + "/" + topic, - ":", + topic, message, ) self.cache["hack42bar/output/session/" + self.SID + "/" + topic] = message @@ -220,7 +234,7 @@ def send_message(self, retain, topic, message): def get_session(SID, client): global sessions # pylint: disable=global-variable-not-assigned if not SID in sessions: - print("Starting new session", SID) + logger.info("starting_new_session sid=%s", SID) sessions[SID] = Session(SID, client) sessions[SID].startup() client.publish("hack42bar/output/sessions", json.dumps(list(sessions.keys()))) @@ -232,16 +246,15 @@ def run_session(client, SID, action, data): session = get_session(SID, client) session.input(data.decode()) else: - print("unhandled", action) + logger.warning("unhandled_session_action sid=%s action=%s", SID, action) def on_connect(client, _userdata, _flags, rc): - print("Connected with result code " + str(rc)) + logger.info("mqtt_connected rc=%s", rc) client.subscribe("hack42bar/input/#") def on_message(client, _userdata, msg): - # print(msg.topic+" "+str(msg.payload)) elms = msg.topic.split("/") msg = msg.payload if len(elms) < 5: @@ -251,6 +264,13 @@ def on_message(client, _userdata, msg): def run(): + logging_level = getattr( + logging, str(config_get("logging", "level", default="INFO")).upper(), logging.INFO + ) + logging.basicConfig( + level=logging_level, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) while 1: try: mqtt_config = config_get("mqtt", default={}) diff --git a/plugins/POS.py b/plugins/POS.py index 4c568dc..92c841b 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -5,8 +5,11 @@ import time import pickle import serial +import logging from config import config_get +logger = logging.getLogger(__name__) + DISPLAY = b"\x1b=\x02\x1b@" PRINTER = b"\x1b=\x01\x1b@" LARGE = b"\x1d!\x11" @@ -52,7 +55,7 @@ def open(self): stopbits=serial.STOPBITS_ONE, # pylint: disable=no-member bytesize=serial.EIGHTBITS, # pylint: disable=no-member ) - print("Serial open") + logger.info("pos_serial_open sid=%s port=%s", self.SID, serial_config["port"]) def help(self): return { diff --git a/plugins/accounts.py b/plugins/accounts.py index 6279aa6..b7ccf15 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -4,6 +4,10 @@ import threading import time import codecs +import logging + + +logger = logging.getLogger(__name__) def _atomic_write(path, lines): @@ -45,7 +49,7 @@ def help(self): return {"adduseralias": "Add user key alias"} def get_last_updated_accounts(self): - print(self.accounts) + logger.debug("accounts_state sid=%s accounts=%s", self.SID, self.accounts) # Sort the accounts based on last update time, in descending order sorted_accounts = sorted( self.accounts.items(), key=lambda x: x[1]["lastupdate"], reverse=True @@ -90,7 +94,7 @@ def readmembers(self): ] def updateaccount(self, usr, value): - print("Updating account", usr) + logger.debug("update_account sid=%s user=%s value=%s", self.SID, usr, value) if usr == "cash": return had = self.accounts[usr]["amount"] diff --git a/plugins/door.py b/plugins/door.py index 84464d7..9c51410 100644 --- a/plugins/door.py +++ b/plugins/door.py @@ -1,16 +1,21 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +import logging + import paho.mqtt.client as mqtt from config import config_get +logger = logging.getLogger(__name__) + + class door: def __init__(self, SID, master): self.master = master self.SID = SID def help(self): - print("Help") + logger.debug("door_help sid=%s", self.SID) return {"dooropen": "Door open"} def input(self, text): diff --git a/plugins/market.py b/plugins/market.py index 6acfaa6..6a48f7f 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -2,6 +2,7 @@ import os import tempfile import threading +import logging from input_validation import ( filter_reserved_aliases, is_reserved_input, @@ -10,6 +11,9 @@ ) +logger = logging.getLogger(__name__) + + def _atomic_write(path, lines): directory = os.path.dirname(path) or "." fd, tmp_path = tempfile.mkstemp( @@ -57,12 +61,12 @@ def readproducts(self): self.aliases = {} with open("data/revbank.market", "r", encoding="utf-8") as f: lines = f.readlines() - print("ok", lines) + logger.debug("read_market_file sid=%s lines=%d", self.SID, len(lines)) for line in lines: if not line.strip() or line.lstrip().startswith("#"): continue parts = " ".join(line.split()).split(" ", 4) - print(parts) + logger.debug("read_market_parts sid=%s parts=%s", self.SID, parts) if len(parts) == 5: aliases = parts[1].split(",") name = aliases.pop(0) diff --git a/plugins/products.py b/plugins/products.py index 61e3273..2965ebb 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -2,6 +2,7 @@ import os import tempfile import threading +import logging from input_validation import ( filter_reserved_aliases, is_reserved_input, @@ -10,6 +11,9 @@ ) +logger = logging.getLogger(__name__) + + def _atomic_write(path, lines): directory = os.path.dirname(path) or "." fd, tmp_path = tempfile.mkstemp( @@ -93,7 +97,13 @@ def readproducts(self): self.master.send_message( True, "products/" + prod, json.dumps(self.products[prod]) ) - print("readproducts done") + logger.debug( + "read_products_done sid=%s products=%d aliases=%d groups=%d", + self.SID, + len(self.products), + len(self.aliases), + len(self.groups), + ) def writeproducts(self): lines = [] @@ -212,7 +222,12 @@ def saveprice(self, text): "numbers", "Not a valid number; What is the price for" + self.newprod + "?", ) - print(price) + logger.debug( + "save_product_price sid=%s product=%s price=%s", + self.SID, + self.priceprod, + price, + ) if not 0 < price < 1000: return self.messageandbuttons( "saveprice", "numbers", "Price should be between 0 and 1000" diff --git a/plugins/receipt.py b/plugins/receipt.py index b4e1c61..7e0c562 100644 --- a/plugins/receipt.py +++ b/plugins/receipt.py @@ -1,4 +1,8 @@ import json +import logging + + +logger = logging.getLogger(__name__) class receipt: @@ -12,7 +16,7 @@ def __init__(self, SID, master): self.totals = {} def is_empty(self): - print("hooi", self.receipt) + logger.debug("receipt_state sid=%s receipt=%s", self.SID, self.receipt) if self.receipt: return False return True diff --git a/plugins/stickers.py b/plugins/stickers.py index cb094a2..80e7135 100644 --- a/plugins/stickers.py +++ b/plugins/stickers.py @@ -6,6 +6,7 @@ import base64 import urllib.parse import warnings +import logging from PIL import Image, ImageDraw, ImageFont import pyqrcode from config import config_get @@ -21,6 +22,9 @@ import brother_ql.raster +logger = logging.getLogger(__name__) + + class stickers: # pylint: disable=too-many-public-methods SMALL = (696, 271) LOGOSMALLSIZE = (309, 200) @@ -64,12 +68,12 @@ def help(self): def barcodeprint(self): if re.compile("^[0-9A-Z]+$").match(self.barcode): - print("Qrcode: alphanum") + logger.debug("qrcode_mode sid=%s mode=alphanumeric", self.SID) qrcode_image = pyqrcode.create( self.barcode, error="L", version=1, mode="alphanumeric" ).png_as_base64_str(scale=5) else: - print("Qrcode: binary") + logger.debug("qrcode_mode sid=%s mode=binary", self.SID) qrcode_image = pyqrcode.create( self.barcode, error="L", version=2, mode="binary" ).png_as_base64_str(scale=5) @@ -189,7 +193,7 @@ def toolprint(self): # pylint: disable=too-many-locals qrname = "https://hack42.nl/wiki/Tool:" + urllib.parse.quote( self.name.replace(" ", "_") ) - print("Qrcode: binary") + logger.debug("qrcode_mode sid=%s mode=binary", self.SID) qrcode_image = pyqrcode.create(qrname, error="L", mode="binary") qrcode_image = qrcode_image.png_as_base64_str( scale=int((label_size - margin) / qrcode_image.get_png_size()) diff --git a/plugins/undo.py b/plugins/undo.py index 262a83f..173ae37 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -3,6 +3,10 @@ import copy import pickle import time +import logging + + +logger = logging.getLogger(__name__) class undo: @@ -100,8 +104,12 @@ def doundo(self, text): ) return True - print(self.undo.keys()) - print(f"transID not in undo: {transID}") + logger.warning( + "undo_transaction_not_found sid=%s trans_id=%s available=%s", + self.SID, + transID, + sorted(self.undo.keys()), + ) self.listundo() return True diff --git a/tests/plugins/test_door.py b/tests/plugins/test_door.py index fe4ec7c..d640260 100644 --- a/tests/plugins/test_door.py +++ b/tests/plugins/test_door.py @@ -9,10 +9,10 @@ def setUp(self): self.door_instance = door("SID", self.master_mock) def test_help(self): - with patch("builtins.print") as mock_print: + with patch("plugins.door.logger") as logger: result = self.door_instance.help() - mock_print.assert_called_with("Help") self.assertEqual(result, {"dooropen": "Door open"}) + logger.debug.assert_called_with("door_help sid=%s", "SID") def test_input_dooropen(self): with patch("paho.mqtt.client.Client") as mock_client: diff --git a/tests/test_config.py b/tests/test_config.py index 7fa2dba..efec94c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,7 @@ def test_load_config_uses_defaults_when_missing(tmp_path): assert config["mqtt"]["host"] == "localhost" assert config["stickers"]["printer"]["port"] == 9100 + assert config["logging"]["level"] == "INFO" def test_load_config_merges_yaml_with_defaults(tmp_path): @@ -27,6 +28,7 @@ def test_load_config_merges_yaml_with_defaults(tmp_path): assert config["mqtt"]["port"] == 1883 assert config["stickers"]["printer"]["host"] == "printer.example.test" assert config["stickers"]["printer"]["port"] == 9100 + assert config["logging"]["level"] == "INFO" def test_config_get_uses_env_path(tmp_path, monkeypatch): diff --git a/tests/test_kassa.py b/tests/test_kassa.py index dcae00a..a54bc4e 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -440,6 +440,16 @@ def test_send_message_updates_prompt_buttons_and_skips_cached_long_topic(): assert client_mock.publish.call_count == 3 +def test_send_message_logs_debug(caplog): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + + with caplog.at_level("DEBUG", logger="kassa"): + session.send_message(True, "message", "hello") + + assert "send_message sid=SID retain=True topic=message message='hello'" in caplog.text + + def test_session_mutable_state_is_per_instance(): first = kassa.Session("SID1", Mock()) second = kassa.Session("SID2", Mock()) @@ -477,9 +487,11 @@ def test_get_session_starts_new_session(): client_mock.publish.assert_called_with("hack42bar/output/sessions", '["SID"]') -def test_run_session_unhandled_action(capsys): - kassa.run_session(Mock(), "SID", "unknown", b"data") - assert "unhandled unknown" in capsys.readouterr().out +def test_run_session_unhandled_action(caplog): + with caplog.at_level("WARNING", logger="kassa"): + kassa.run_session(Mock(), "SID", "unknown", b"data") + + assert "unhandled_session_action sid=SID action=unknown" in caplog.text def test_on_message_short_topic_is_ignored(): From 2c08db9bc04810584a3352ddb3bc76e4f0233a08 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:34:46 +0200 Subject: [PATCH 38/76] Verwijder test debug output --- tests/plugins/test_market.py | 2 -- tests/plugins/test_products.py | 1 - tests/plugins/test_stock.py | 1 - 3 files changed, 4 deletions(-) diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index 4c427aa..17d6a9e 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -26,7 +26,6 @@ def test_readproducts(self): mo = mock_open(read_data=market_data) with patch("builtins.open", mo): self.market.readproducts() - print("hooi", self.market.products) assert "product1" in self.market.products assert "product3" in self.market.products assert self.market.products["product3"]["aliases"] == [] @@ -192,7 +191,6 @@ def test_saveprice(self): self.market, "writeproducts" ): self.market.saveprice("3.0") - print(self.market.products) assert self.market.products["product1"]["price"] == 3.0 def test_addproductgroup(self): diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 93baebd..5c41374 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -229,7 +229,6 @@ def test_saveprice_valid_price(self): self.products, "writeproducts" ): assert self.products.saveprice("3.0") - print("hooi", self.products.products) assert self.products.products["product1"]["price"] == 3.0 def test_abort_paths_call_abort_hook(self): diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index a7d70d6..00f8500 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -268,7 +268,6 @@ def test_stock_inkoop_amount_too_large_number(): assert stock.inkoop_amount("5000") == True master_mock.donext.assert_called_with(stock, "inkoop_amount") - print(master_mock.send_message.call_args_list) master_mock.send_message.assert_has_calls( [ call( From c4c572c37748f3d76039b816c9392dd93d4b785a Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:36:19 +0200 Subject: [PATCH 39/76] Test checkout met accounts stock en POS --- tests/test_checkout_undo_integration.py | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/tests/test_checkout_undo_integration.py b/tests/test_checkout_undo_integration.py index 8b9f3c5..429bd39 100644 --- a/tests/test_checkout_undo_integration.py +++ b/tests/test_checkout_undo_integration.py @@ -1,7 +1,11 @@ from unittest.mock import Mock, patch import kassa +from plugins.POS import POS +from plugins.accounts import accounts +from plugins.products import products from plugins.receipt import receipt +from plugins.stock import stock from plugins.undo import undo @@ -15,6 +19,28 @@ def make_session(): return session, receipt_plugin, undo_plugin +def make_checkout_session(): + session = kassa.Session("SID", Mock()) + receipt_plugin = receipt("SID", session) + accounts_plugin = accounts("SID", session) + products_plugin = products("SID", session) + stock_plugin = stock("SID", session) + pos_plugin = POS("SID", session) + session.plugins = { + "receipt": receipt_plugin, + "accounts": accounts_plugin, + "products": products_plugin, + "stock": stock_plugin, + "POS": pos_plugin, + } + session.receipt = receipt_plugin + session.accounts = accounts_plugin + session.products = products_plugin + session.stock = stock_plugin + session.POS = pos_plugin + return session, receipt_plugin, accounts_plugin, products_plugin, stock_plugin, pos_plugin + + def test_checkout_stores_undo_snapshot_after_receipt_checkout(): session, receipt_plugin, undo_plugin = make_session() @@ -102,3 +128,90 @@ def test_restore_runs_undo_then_restores_original_receipt_lines(): "product": "product1", } ] + + +def test_account_checkout_updates_accounts_stock_and_pos_receipt(): + ( + session, + receipt_plugin, + accounts_plugin, + products_plugin, + stock_plugin, + pos_plugin, + ) = make_checkout_session() + accounts_plugin.accounts = {"alice": {"amount": 20.0, "lastupdate": "old"}} + products_plugin.products = { + "cola": {"price": 2.5, "description": "Cola", "aliases": []} + } + stock_plugin.stock = {"cola": 10} + receipt_plugin.add(True, 2.5, "Cola -you-", 2, None, "cola") + + with patch("plugins.accounts.time.time", return_value=1300000123), patch.object( + accounts_plugin, "readaccounts" + ), patch.object(accounts_plugin, "writeaccount"), patch.object( + accounts_plugin, "get_last_updated_accounts" + ), patch.object( + stock_plugin, "readstock" + ), patch.object( + stock_plugin, "writestock" + ), patch.object( + pos_plugin, "loadbons" + ), patch.object( + pos_plugin, "writebons" + ), patch.object( + pos_plugin, "drawer" + ) as drawer: + assert accounts_plugin.input("alice") + + assert session.transID == 123 + assert accounts_plugin.accounts["alice"]["amount"] == 15.0 + assert stock_plugin.stock["cola"] == 8 + assert pos_plugin.lastbonID == 123 + assert pos_plugin.bonnetjes[123]["totals"] == {"alice": -5.0} + assert b"Cola alice" in pos_plugin.bonnetjes[123]["bon"] + assert receipt_plugin.receipt == [] + drawer.assert_not_called() + + +def test_cash_checkout_updates_stock_opens_drawer_and_skips_account(): + ( + session, + receipt_plugin, + accounts_plugin, + products_plugin, + stock_plugin, + pos_plugin, + ) = make_checkout_session() + accounts_plugin.accounts = {} + products_plugin.products = { + "cola": {"price": 2.5, "description": "Cola", "aliases": []} + } + stock_plugin.stock = {"cola": 10} + receipt_plugin.add(True, 2.5, "Cola", 1, "cash", "cola") + + with patch("plugins.accounts.time.time", return_value=1300000124), patch.object( + accounts_plugin, "readaccounts" + ), patch.object(accounts_plugin, "writeaccount"), patch.object( + accounts_plugin, "get_last_updated_accounts" + ), patch.object( + stock_plugin, "readstock" + ), patch.object( + stock_plugin, "writestock" + ), patch.object( + pos_plugin, "loadbons" + ), patch.object( + pos_plugin, "writebons" + ), patch.object( + pos_plugin, "drawer" + ) as drawer: + session.callhook("checkout", "cash") + session.callhook("endsession", "cash") + + assert session.transID == 124 + assert accounts_plugin.accounts == {} + assert stock_plugin.stock["cola"] == 9 + assert pos_plugin.lastbonID == 124 + assert pos_plugin.bonnetjes[124]["totals"] == {"cash": -2.5} + assert b"Nieuw saldo" not in pos_plugin.bonnetjes[124]["bon"] + assert receipt_plugin.receipt == [] + assert drawer.call_count == 2 From f95fa4640d8adf48b271bab017b0c97ad02907b9 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:37:37 +0200 Subject: [PATCH 40/76] Open kassalade een keer bij cash checkout --- plugins/POS.py | 3 +-- tests/plugins/test_POS.py | 6 +++--- tests/test_checkout_undo_integration.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index 92c841b..c1931ab 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -85,8 +85,7 @@ def printdisplay(self, desc, amount, som): self.ser.write(out) def hook_checkout(self, user): - if user == "cash": - self.drawer() + pass def hook_addremove(self, args): # Update display diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 87d272c..ef02069 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -144,9 +144,9 @@ def test_makebon_cash_does_not_require_account(self): assert b"SALDO TE LAAG" not in bon def test_hook_checkout(self): - with patch.object(self.POS, "drawer"): + with patch.object(self.POS, "drawer") as mock_drawer: self.POS.hook_checkout("cash") - self.POS.drawer.assert_called() + mock_drawer.assert_not_called() def test_printdeclaratie(self): with patch.object(self.POS, "open"), patch.object(self.POS, "slowwrite"): @@ -164,7 +164,7 @@ def test_hook_post_checkout(self): self.POS.hook_post_checkout("cash") self.POS.loadbons.assert_called() self.POS.writebons.assert_called() - self.POS.drawer.assert_called() + self.POS.drawer.assert_called_once() def test_hook_post_checkout_deposit_opens_drawer_for_non_cash(self): self.POS.master.receipt = Mock( diff --git a/tests/test_checkout_undo_integration.py b/tests/test_checkout_undo_integration.py index 429bd39..34e77b7 100644 --- a/tests/test_checkout_undo_integration.py +++ b/tests/test_checkout_undo_integration.py @@ -214,4 +214,4 @@ def test_cash_checkout_updates_stock_opens_drawer_and_skips_account(): assert pos_plugin.bonnetjes[124]["totals"] == {"cash": -2.5} assert b"Nieuw saldo" not in pos_plugin.bonnetjes[124]["bon"] assert receipt_plugin.receipt == [] - assert drawer.call_count == 2 + drawer.assert_called_once() From 34d9049528736e4c308d67b4c4b663df5168c927 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:40:02 +0200 Subject: [PATCH 41/76] Maak checkout hooks minder volgordegevoelig --- plugins/POS.py | 24 ++++++++++++++---------- plugins/accounts.py | 5 +++++ plugins/undo.py | 2 +- tests/plugins/test_POS.py | 22 ++++++++++++++++++++++ tests/plugins/test_accounts.py | 2 ++ tests/plugins/test_undo.py | 8 ++++---- tests/test_checkout_undo_integration.py | 7 ++++--- 7 files changed, 52 insertions(+), 18 deletions(-) diff --git a/plugins/POS.py b/plugins/POS.py index c1931ab..1b3681a 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -107,16 +107,22 @@ def hook_undo(self, args): ) self.writebons() + def projected_balance(self, user): + checkout_balances = getattr(self.master.accounts, "checkout_balances", {}) + if not isinstance(checkout_balances, dict): + checkout_balances = {} + account = self.master.accounts.accounts[user] + balance_before_checkout = checkout_balances.get(user, account["amount"]) + return balance_before_checkout + self.master.receipt.totals[user] + def makebon(self, user): BON = PRINTER + LARGE + CENTER + LOGO is_cash = user == "cash" - if ( - not is_cash - and - self.master.accounts.accounts[user]["amount"] - + self.master.receipt.totals[user] - ) < -13.37: - BON += b"SALDO TE LAAG\n" + projected_balance = None + if not is_cash: + projected_balance = self.projected_balance(user) + if projected_balance < -13.37: + BON += b"SALDO TE LAAG\n" BON += NORMAL + b"Bon transactie %d\n" % self.master.transID BON += ( BARCODE_T @@ -153,9 +159,7 @@ def makebon(self, user): BON += b" %-26s% 12.2f\n" % (b"Totaal", self.master.receipt.totals[user]) BON += b"\nU bent geholpen door: %s\n" % user.encode() if not is_cash: - BON += b"\n Nieuw saldo: %5.2f\n" % ( - self.master.accounts.accounts[user]["amount"] - ) + BON += b"\n Nieuw saldo: %5.2f\n" % projected_balance BON += ( b"\n" + CENTER diff --git a/plugins/accounts.py b/plugins/accounts.py index b7ccf15..8b30358 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -34,6 +34,7 @@ class accounts: members = [] newaccount = "" adduseralias = "" + checkout_balances = {} write_lock = threading.Lock() def __init__(self, SID, master): @@ -44,6 +45,7 @@ def __init__(self, SID, master): self.members = [] self.newaccount = "" self.adduseralias = "" + self.checkout_balances = {} def help(self): return {"adduseralias": "Add user key alias"} @@ -180,6 +182,9 @@ def startup(self): def hook_pre_checkout(self, _text): self.readaccounts() + self.checkout_balances = { + usr: account["amount"] for usr, account in self.accounts.items() + } self.master.transID = int(time.time() - 1300000000) def hook_post_checkout(self, _text): diff --git a/plugins/undo.py b/plugins/undo.py index 173ae37..6a5368a 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -24,7 +24,7 @@ def help(self): "restore": "Restore a transaction", } - def hook_checkout(self, text): + def hook_post_checkout(self, text): self.loadundo() self.undo[self.master.transID] = { "totals": copy.deepcopy(self.master.receipt.totals), diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index ef02069..65456e3 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -121,6 +121,28 @@ def test_makebon_low_balance_warning(self): assert b"SALDO TE LAAG" in bon + def test_makebon_uses_checkout_balance_snapshot_for_new_balance(self): + self.POS.master.receipt = Mock( + receipt=[ + { + "product": "test", + "beni": "user", + "count": 1, + "total": 5.0, + "description": "test", + } + ], + totals={"user": -5}, + ) + self.POS.master.transID = 42 + self.POS.master.accounts.accounts = {"user": {"amount": 15}} + self.POS.master.accounts.checkout_balances = {"user": 20} + + bon = self.POS.makebon("user") + + assert b"Nieuw saldo: 15.00" in bon + assert b"Nieuw saldo: 10.00" not in bon + def test_makebon_cash_does_not_require_account(self): self.POS.master.receipt = Mock( receipt=[ diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index a675a13..2c03336 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -196,11 +196,13 @@ def test_hook_pre_checkout(mock_readaccounts, mock_time): mock_time.return_value = 1300000100.0 master_mock = Mock() acc = accounts("SID", master_mock) + acc.accounts = {"user1": {"amount": 12.5, "lastupdate": "old"}} acc.hook_pre_checkout("some text") mock_readaccounts.assert_called_once() assert acc.master.transID == 100 + assert acc.checkout_balances == {"user1": 12.5} @patch("plugins.accounts.accounts.updateaccount") diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index c54d9ad..b586254 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -15,13 +15,13 @@ def test_undo_help(): assert undo.help() == expected_help -def test_undo_hook_checkout(): +def test_undo_hook_post_checkout(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) master_mock.receipt = Mock(totals={}, receipt=[]) with patch.object(undo, "loadundo"), patch.object(undo, "writeundo"): - undo.hook_checkout("text") + undo.hook_post_checkout("text") undo.writeundo.assert_called() assert undo.undo[master_mock.transID] == { "totals": master_mock.receipt.totals, @@ -30,7 +30,7 @@ def test_undo_hook_checkout(): } -def test_undo_hook_checkout_stores_snapshot(): +def test_undo_hook_post_checkout_stores_snapshot(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) master_mock.transID = 123 @@ -49,7 +49,7 @@ def test_undo_hook_checkout_stores_snapshot(): ) with patch.object(undo, "loadundo"), patch.object(undo, "writeundo"): - undo.hook_checkout("user") + undo.hook_post_checkout("user") master_mock.receipt.totals["user"] = 0 master_mock.receipt.receipt[0]["count"] = 99 diff --git a/tests/test_checkout_undo_integration.py b/tests/test_checkout_undo_integration.py index 34e77b7..e58cd69 100644 --- a/tests/test_checkout_undo_integration.py +++ b/tests/test_checkout_undo_integration.py @@ -13,7 +13,7 @@ def make_session(): session = kassa.Session("SID", Mock()) receipt_plugin = receipt("SID", session) undo_plugin = undo("SID", session) - session.plugins = {"receipt": receipt_plugin, "undo": undo_plugin} + session.plugins = {"undo": undo_plugin, "receipt": receipt_plugin} session.receipt = receipt_plugin session.transID = 123 return session, receipt_plugin, undo_plugin @@ -27,11 +27,11 @@ def make_checkout_session(): stock_plugin = stock("SID", session) pos_plugin = POS("SID", session) session.plugins = { - "receipt": receipt_plugin, + "POS": pos_plugin, "accounts": accounts_plugin, "products": products_plugin, + "receipt": receipt_plugin, "stock": stock_plugin, - "POS": pos_plugin, } session.receipt = receipt_plugin session.accounts = accounts_plugin @@ -169,6 +169,7 @@ def test_account_checkout_updates_accounts_stock_and_pos_receipt(): assert pos_plugin.lastbonID == 123 assert pos_plugin.bonnetjes[123]["totals"] == {"alice": -5.0} assert b"Cola alice" in pos_plugin.bonnetjes[123]["bon"] + assert b"Nieuw saldo: 15.00" in pos_plugin.bonnetjes[123]["bon"] assert receipt_plugin.receipt == [] drawer.assert_not_called() From fca6feb53ba9590e5cdfbe7ae9cab5f08f7c9d32 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:42:07 +0200 Subject: [PATCH 42/76] Moderniseer spaceconsole command javascript --- www/spaceconsole/all.js | 91 ++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/www/spaceconsole/all.js b/www/spaceconsole/all.js index da3271e..45d7a12 100644 --- a/www/spaceconsole/all.js +++ b/www/spaceconsole/all.js @@ -1,9 +1,8 @@ -var cachepixels={}; -var cachetop={}; -;(function($) { +var cachepixels = {}; +var cachetop = {}; +(function($) { $.fn.textfill = function(options) { var fontSize = options.maxFontPixels; - var ourText = $('span:visible:first', this); var maxHeight = $(this).parent().height(); var maxWidth = $(this).parent().width(); var textHeight; @@ -21,7 +20,7 @@ var cachetop={}; fontSize = fontSize - 0.9; } while ((textHeight > maxHeight || textWidth > maxWidth) && fontSize > 0.5); textHeight = this.height(); - var mytop=(maxHeight-textHeight)/2 + var mytop = (maxHeight - textHeight) / 2; this.css('top',mytop); cachetop[this.text()]=mytop; return this; @@ -29,77 +28,83 @@ var cachetop={}; })(jQuery); $( document ).ready(function() { + function postCommand(data) { + fetch("cmd.php", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(data), + }).catch(function(error) { + console.error("Command failed", error); + }); + } + function runmsg(path, msg) { if(path.endsWith("/POWER")) { - device = path.split("/")[2]; - $("#"+device).css("background-color",msg == "ON" ? "red" : "green"); + var device = path.split("/")[2]; + $("#"+device).css("background-color",msg === "ON" ? "red" : "green"); } if(path.endsWith("/LWT")) { - device = path.split("/")[2]; - msg == "Online" ? $("#"+device+" > span.dot").hide() : $("#"+device+" > span.dot").show(); + var lwtDevice = path.split("/")[2]; + msg === "Online" ? $("#"+lwtDevice+" > span.dot").hide() : $("#"+lwtDevice+" > span.dot").show(); } - if(path.endsWith("/status") & path.startsWith("hack42/tele/")) { + if(path.endsWith("/status") && path.startsWith("hack42/tele/")) { console.log(path); - device = path.split("/")[2]; - msg == "online" ? $("#"+device+" > span.dot").hide() : $("#"+device+" > span.dot").show(); + var statusDevice = path.split("/")[2]; + msg === "online" ? $("#"+statusDevice+" > span.dot").hide() : $("#"+statusDevice+" > span.dot").show(); } - if(path == "hack42/sound/volume") { - val = JSON.parse(msg)[0]; - $('.input-range').val( val / 10); + if(path === "hack42/sound/volume") { + var val = JSON.parse(msg)[0]; + $('.input-range').val(val / 10); } if(path.endsWith("/GBpower") || path.endsWith("/GBvolt")) { - device = path.split("/")[2]; - $("#"+device).text(msg); + var gbDevice = path.split("/")[2]; + $("#"+gbDevice).text(msg); } if(path.endsWith("/co")) { - device = path.split("/")[3]; - $("#"+device+"co").text(msg); + var coDevice = path.split("/")[3]; + $("#"+coDevice+"co").text(msg); } if(path.endsWith("/humid")) { - device = path.split("/")[3]; - $("#"+device+"humid").text(msg); + var humidDevice = path.split("/")[3]; + $("#"+humidDevice+"humid").text(msg); } if(path.startsWith("hack42/sensors/1wire/")) { - device = path.split("/")[3]; - $("#"+device).text(msg); + var sensorDevice = path.split("/")[3]; + $("#"+sensorDevice).text(msg); } - if(path == 'hack42/stookkelder/hoofdgebouwvalve' ) { - $("#gebouw").css("background-color",msg != "closed" ? "red" : "green"); + if(path === 'hack42/stookkelder/hoofdgebouwvalve' ) { + $("#gebouw").css("background-color",msg !== "closed" ? "red" : "green"); } - if(path == 'hack42/stookkelder/barrakkenvalve' ) { - $("#barakken").css("background-color",msg != "closed" ? "red" : "green"); + if(path === 'hack42/stookkelder/barrakkenvalve' ) { + $("#barakken").css("background-color",msg !== "closed" ? "red" : "green"); } } - $( ".Knopjetext:visible" ).each(function( index, element ) { + $( ".Knopjetext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); var source = new EventSource('stream.php'); source.onmessage = function(event) { - var msg=JSON.parse(event.data); - var path=msg[0]; - var msg=msg[1]; + var data = JSON.parse(event.data); + var path = data[0]; + var msg = data[1]; //if(path=="startup") postmsg('startup',1); runmsg(path,msg); } $("body" ).on( "click",'div.powerswitch', function() { - $.ajax({ - type: "POST", - url: "cmd.php", - data: {"action": "toggle", "device": this.id}, - success: function(data) { - } + postCommand({ + "action": "toggle", + "device": this.id, }); }); $('.input-range').on('input', function(){ - $.ajax({ - type: "POST", - url: "cmd.php", - data: {"action": "volume", "value": this.value}, - success: function(data) { - } + postCommand({ + "action": "volume", + "value": this.value, }); }); From 113ee57e9d9b38430b120dabb359f6349c0dcaba Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 27 May 2026 01:44:48 +0200 Subject: [PATCH 43/76] Maak kassa frontend javascript strikter --- www/all.js | 250 +++++++++++++++++++++++++---------------------------- 1 file changed, 119 insertions(+), 131 deletions(-) diff --git a/www/all.js b/www/all.js index 216b98e..84d5357 100644 --- a/www/all.js +++ b/www/all.js @@ -1,9 +1,9 @@ +/* global dolog, runtext, showusers */ var cachepixels={}; var cachetop={}; -;(function($) { +(function($) { $.fn.textfill = function(options) { var fontSize = options.maxFontPixels; - var ourText = $('span:visible:first', this); var maxHeight = $(this).parent().height(); var maxWidth = $(this).parent().width(); var textHeight; @@ -21,16 +21,16 @@ var cachetop={}; fontSize = fontSize - 0.9; } while ((textHeight > maxHeight || textWidth > maxWidth) && fontSize > 0.5); textHeight = this.height(); - var mytop=(maxHeight-textHeight)/2 + var mytop=(maxHeight-textHeight)/2; this.css('top',mytop); cachetop[this.text()]=mytop; return this; - } + }; })(jQuery); $( document ).ready(function() { var session='main'; - if (window.location.hash!='') { + if (window.location.hash !== '') { session=window.location.hash.replace('#',''); } var prods={}; @@ -56,23 +56,23 @@ $( document ).ready(function() { $.each(parts, function(idx,stuff) { counter++; $('#Receipt').append( - $('
',{class: 'Itemline'}) - .append($('',{class: 'Product', text: stuff.description})) - .append($('',{class: 'Times', text: stuff.count})) - .append($('',{class: 'LoseOrGain', text: stuff.Lose ? "LOSE" : "GAIN"})) - .append($('',{class: 'ItemEuro'})) - .append($('
',{class: 'ItemAmount', text: stuff.total.toFixed(2)})) - ); + $('
',{class: 'Itemline'}) + .append($('',{class: 'Product', text: stuff.description})) + .append($('',{class: 'Times', text: stuff.count})) + .append($('',{class: 'LoseOrGain', text: stuff.Lose ? "LOSE" : "GAIN"})) + .append($('',{class: 'ItemEuro'})) + .append($('
',{class: 'ItemAmount', text: stuff.total.toFixed(2)})) + ); }); $('#Receipt').scrollTop($('#Receipt')[0].scrollHeight); - if(counter==0) { + if(counter === 0) { $('#Receipt').append($('
',{class: 'welcome', html: "Welcome to Hack42 Bank HTML5 Interface"})); } $('#Receipt2').empty(); $('.Itemline').clone().appendTo('#Receipt2'); $('#Receipt2').scrollTop($('#Receipt2')[0].scrollHeight); if ($('#Receipt2').is(':empty')){ - $('#Receipt2').append($('',{src: 'images/Hack42.png', width: '100%'}).css({'top': '12vh','position': 'absolute'})) + $('#Receipt2').append($('',{src: 'images/Hack42.png', width: '100%'}).css({'top': '12vh','position': 'absolute'})); } } function build_totals(msg) { @@ -82,21 +82,21 @@ $( document ).ready(function() { $.each(parts, function(idx,stuff) { counter++; $('#Totals').append( - $('
',{class: 'Userline Total_'+stuff.user}) - .append($('',{class: 'UserName', text: stuff.user ? stuff.user : '-$you-' })) - .append($('',{class: 'GainOrLose',text: (stuff.amount<0) ? 'LOSE' : 'GAIN'})) - .append($('',{class: 'TotalEuro'})) - .append($('',{class: 'Totalamount',text: (stuff.amount ? stuff.amount : 0-stuff.amount).toFixed(2) })) - ); + $('
',{class: 'Userline Total_'+stuff.user}) + .append($('',{class: 'UserName', text: stuff.user ? stuff.user : '-$you-' })) + .append($('',{class: 'GainOrLose',text: (stuff.amount<0) ? 'LOSE' : 'GAIN'})) + .append($('',{class: 'TotalEuro'})) + .append($('',{class: 'Totalamount',text: (stuff.amount ? stuff.amount : 0-stuff.amount).toFixed(2) })) + ); }); $('#Totals').scrollTop($('#Totals')[0].scrollHeight); - if(counter==0) { + if(counter === 0) { $('#Totals').append($('
',{class: 'welcome', html: "Scan a product or choose a button from below"})); } } function run_infobox(name,msg) { - json=JSON.parse(msg); - newsaldo=json.amount; + var json=JSON.parse(msg); + var newsaldo=json.amount; $('.infoboxes .Total_'+name).append($('',{class: 'NowHas',text: "Now Has"})); $('.infoboxes .Total_'+name).append($('',{class: 'NewSaldo',text: newsaldo.toFixed(2)})); } @@ -107,32 +107,32 @@ $( document ).ready(function() { commands=JSON.parse(msg); } function setupstock(name,msg) { - stock[name]=msg + stock[name]=msg; } function setupproducts(name,msg) { prods[name]=JSON.parse(msg); groups={}; $.each(prods, function(idx,stuff) { - groups[stuff.group]++; - barcode=0; + groups[stuff.group]=(groups[stuff.group] || 0) + 1; + var barcode=0; $.each(stuff.aliases, function(idx2,stuff2) { - if(stuff2>9999999) barcode=1; - }) - if(barcode==0) { + if(stuff2 > 9999999) barcode=1; + }); + if(barcode === 0) { nobcgroup[name]=prods[name]; } }); } function productstobuttons(products) { var buttons=[]; - Object.keys(products).sort().forEach(function(v, i) { + Object.keys(products).sort().forEach(function(v) { buttons.push({'text': v,'display': v, class: v}); }); return buttons; } function commandstobuttons() { var buttons=[]; - Object.keys(commands).sort().forEach(function(v, i) { + Object.keys(commands).sort().forEach(function(v) { buttons.push({'text': v,'display': commands[v], class: v}); }); return buttons; @@ -141,14 +141,6 @@ $( document ).ready(function() { var alc = a.toLowerCase(), blc = b.toLowerCase(); return alc > blc ? 1 : alc < blc ? -1 : 0; } - function compare_text(a,b) { - if (a.text < b.text) - return -1; - else if (a.text > b.text) - return 1; - else - return 0; - } function compare_text_rev(a,b) { if (a.text > b.text) return -1; @@ -165,18 +157,12 @@ $( document ).ready(function() { else return 0; } - function compare_display_rev(a,b) { - if (a.display > b.display) - return -1; - else if (a.display < b.display) - return 1; - else - return 0; - } function accountstobuttons(accounts,type) { var buttons=[]; - Object.keys(accounts).sort(mycomparator).forEach(function(v, i) { - if($.inArray(v,members)!=-1 & type=='m' || ( $.inArray(v,nonmembers)!=-1 & type=='o' || ($.inArray(v,nonmembers)==-1 & $.inArray(v,members)==-1 & type=='x'))) { + Object.keys(accounts).sort(mycomparator).forEach(function(v) { + var isMember = $.inArray(v,members) !== -1; + var isNonMember = $.inArray(v,nonmembers) !== -1; + if((isMember && type === 'm') || (isNonMember && type === 'o') || (!isMember && !isNonMember && type === 'x')) { var extraclass=v; if(accounts[v].amount < -13.37) { extraclass=v+" rood"; @@ -191,9 +177,9 @@ $( document ).ready(function() { } function allproductstobuttons() { var buttons=[]; - $.each(prods,function(v,i) { - button={'text': v,'display': prods[v].description,'right': prods[v].price.toFixed(2), rightclass:"green", class: v, aliases: prods[v].aliases} - if(stock[v]!=undefined && stock[v]!=0) { + $.each(prods,function(v) { + var button={'text': v,'display': prods[v].description,'right': prods[v].price.toFixed(2), rightclass:"green", class: v, aliases: prods[v].aliases}; + if(stock[v] !== undefined && Number(stock[v]) !== 0) { button['left']=stock[v]; button['leftclass']='orange'; } @@ -205,11 +191,11 @@ $( document ).ready(function() { var buttons=[]; var thisprods={}; $.each(prods, function(idx,stuff) { - if(stuff.group==group) thisprods[idx]=1; + if(stuff.group === group) thisprods[idx]=1; }); - Object.keys(thisprods).sort().forEach(function(v,i) { - button={'text': v,'display': prods[v].description,'right': prods[v].price.toFixed(2), rightclass:"green", class: v} - if(stock[v]!=undefined && stock[v]!=0) { + Object.keys(thisprods).sort().forEach(function(v) { + var button={'text': v,'display': prods[v].description,'right': prods[v].price.toFixed(2), rightclass:"green", class: v}; + if(stock[v] !== undefined && Number(stock[v]) !== 0) { button['left']=stock[v]; button['leftclass']='orange'; } @@ -228,13 +214,13 @@ $( document ).ready(function() { $('#TopButtons').append($('
',{class: "Knopje Button normal restore",id: 'restore'}).append($('',{class: "Knopjetext",text: "Undo + Restore"}))); $('#TopButtons').append($('
',{class: "Knopje Button normal bon",id: 'bon'}).append($('',{class: "Knopjetext",text: "Bon"}))); $('#TopButtons').append($('
',{class: "Knopje Button normal kassala",id: 'kassala'}).append($('',{class: "Knopjetext",text: "Kassala"}))); - $( "#TopButtons .Knopjetext:visible" ).each(function( index, element ) { + $( "#TopButtons .Knopjetext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); } function dokeys(keys) { $.each(keys, function(idx,val) { - $('#keys') .append($('
',{class: "Knopje Knop invoer small",id: val ,text: val})); + $('#keys').append($('
',{class: "Knopje Knop invoer small",id: val ,text: val})); }); } function makepage_keyboard() { @@ -242,40 +228,40 @@ $( document ).ready(function() { $('#TopButtons').empty(); $('#MainButtons').append($('
',{class: "keys", id: "keys"})); - keys=['!','@','#','$','%','^','&','*','(',')']; + var keys=['!','@','#','$','%','^','&','*','(',')']; dokeys(keys); - $('#keys').append($('
')) + $('#keys').append($('
')); keys=['1','2','3','4','5','6','7','8','9','0']; dokeys(keys); - $('#keys').append($('
')) + $('#keys').append($('
')); keys=['q','w','e','r','t','y','u','i','o','p']; dokeys(keys); $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "backspace" ,text: "←"})); - $('#keys').append($('
')) + $('#keys').append($('
')); keys=['a','s','d','f','g','h','j','k','l']; dokeys(keys); $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "leeg" ,text: " "})); $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "enter" ,text: "⏎"})); - $('#keys').append($('
')) + $('#keys').append($('
')); keys=['z','x','c','v','b','n','m']; $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "leeg" ,text: " "})); dokeys(keys); - $('#keys').append($('
')) + $('#keys').append($('
')); $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "leeg" ,text: " "})); - $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "space" ,text: " "})) - $('#keys').append($('
')) + $('#keys').append($('
',{class: "Knopje Knop invoer enter small",id: "space" ,text: " "})); + $('#keys').append($('
')); keys=['-','=','+','_','`','~',',','.','/','<','>']; dokeys(keys); - $('#keys').append($('
')) + $('#keys').append($('
')); keys=["?",";","'",'\\',':','"','|','[',']','{','}']; dokeys(keys); - $('#keys').append($('
')) + $('#keys').append($('
')); showquestion(); } @@ -322,8 +308,8 @@ $( document ).ready(function() { var first=1; var lastchar=""; $.each(buttons, function(idx, stuff) { - if(donewpage==1) { - $('#'+pagecount+' .Paginatext').html($('#'+pagecount+' .Paginatext').html()+' - '+lastchar) + if(donewpage === 1) { + $('#'+pagecount+' .Paginatext').html($('#'+pagecount+' .Paginatext').html()+' - '+lastchar); pagecount++; $('#MainButtons').append($('
',{id: 'Page'+pagecount, class: 'Pagina'}).hide()); $('#TopButtons').append($('
',{class: "Knopje Button page",id: pagecount}).append($('',{class: "Paginatext",text: "Page "+pagecount}))); @@ -331,14 +317,14 @@ $( document ).ready(function() { donewpage=0; first=1; } - if(first==1) { - $('#'+pagecount+' .Paginatext').html($('#'+pagecount+' .Paginatext').html()+'
'+stuff.display.charAt(0).toUpperCase()) + if(first === 1) { + $('#'+pagecount+' .Paginatext').html($('#'+pagecount+' .Paginatext').html()+'
'+stuff.display.charAt(0).toUpperCase()); first=0; } - txt=$('',{class: "Buttontext ",text: stuff.display}) - knopje=$('
',{class: "Knopje Knop "+stuff.text+" "+extraclass + " " + stuff.class,id: stuff.text }).append(txt); + var txt=$('',{class: "Buttontext ",text: stuff.display}); + var knopje=$('
',{class: "Knopje Knop "+stuff.text+" "+extraclass + " " + stuff.class,id: stuff.text }).append(txt); if(stuff.right) { - fullwidth='' + var fullwidth=''; if(!stuff.left && stuff.fill) { fullwidth=' fullwidthButton '; } @@ -348,29 +334,29 @@ $( document ).ready(function() { knopje.append($('',{class: "left extra "+stuff.leftclass,text: stuff.left})); } lastchar=stuff.display.charAt(0).toUpperCase(); - $('#Page'+pagecount).append(knopje) + $('#Page'+pagecount).append(knopje); counter++; - w=5; - h=5; + var w=5; + var h=5; if(counter>=w*h) { donewpage=1; } }); - if(pagecount==1) { + if(pagecount === 1) { $(".page").remove(); - $( ".Buttontext:visible" ).each(function( index, element ) { + $( ".Buttontext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); showquestion(); } else { - $('#'+pagecount+' .Paginatext').html($('#'+pagecount+' .Paginatext').html()+' - '+lastchar) - $( ".Paginatext:visible" ).each(function( index, element ) { + $('#'+pagecount+' .Paginatext').html($('#'+pagecount+' .Paginatext').html()+' - '+lastchar); + $( ".Paginatext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); - $(".Pagina").each(function(index,element) { + $(".Pagina").each(function() { $('.Pagina').hide(); $(this).show(); - $( ".Buttontext:visible" ).each(function( index, element ) { + $( ".Buttontext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); $('.Pagina').hide(); @@ -379,21 +365,21 @@ $( document ).ready(function() { } } function dobuttons(msg) { - buttons=JSON.parse(msg); - if(msg=='{}' && locked==0) { + var buttons=JSON.parse(msg); + if(msg === '{}' && locked === 0) { $('.topknop').removeClass('activetop'); $('#members').addClass('activetop'); $('#Secondscreen').show(); makepages('normal',accountstobuttons(accounts,'m')); $('#TopButtons').prepend($('
',{class: "Knopje Button normal cash",id: 'cash'}).append($('',{class: "Paginatext",text: "cash"}))); - $( ".Paginatext:visible" ).each(function( index, element ) { + $( ".Paginatext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); focus(); tabenable=1; - } else if (buttons['special']=='custom') { + } else if (buttons['special'] === 'custom') { $('.topknop').removeClass('activetop'); $('#commands').addClass('activetop'); $('#Secondscreen').show(); - if(buttons['sort']=="text") { + if(buttons['sort'] === "text") { buttons['custom'].sort(compare_text_rev); } else { buttons['custom'].sort(compare_display); @@ -401,56 +387,56 @@ $( document ).ready(function() { makepages('normal',buttons['custom']); focus(); tabenable=0; - } else if (buttons['special']=='accounts') { + } else if (buttons['special'] === 'accounts') { $('.topknop').removeClass('activetop'); $('#members').addClass('activetop'); $('#Secondscreen').show(); makepages('normal',accountstobuttons(accounts,'m')); $('#TopButtons').prepend($('
',{class: "Knopje Button normal cash",id: 'cash'}).append($('',{class: "Paginatext",text: "cash"}))); - $( ".Paginatext:visible" ).each(function( index, element ) { + $( ".Paginatext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); focus(); tabenable=2; - } else if (buttons['special']=='accountsamount') { + } else if (buttons['special'] === 'accountsamount') { $('.topknop').removeClass('activetop'); $('#members').addClass('activetop'); $('#Secondscreen').show(); makepages('normal',accountstobuttons(accounts,'m')); $('#TopButtons').prepend($('
',{class: "Knopje Button shownumbers",id: 'shownumbers'}).append($('',{class: "Paginatext",text: "Enter amount"}))); $('#TopButtons').prepend($('
',{class: "Knopje Button normal cash",id: 'cash'}).append($('',{class: "Paginatext",text: "cash"}))); - $( ".Paginatext:visible" ).each(function( index, element ) { + $( ".Paginatext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); focus(); tabenable=2; - } else if (buttons['special']=='numbers') { + } else if (buttons['special'] === 'numbers') { $('.topknop').removeClass('activetop'); $('#Secondscreen').show(); makepage_numbers(); focus(); tabenable=0; - } else if (buttons['special']=='keyboard') { + } else if (buttons['special'] === 'keyboard') { $('.topknop').removeClass('activetop'); $('#Secondscreen').show(); $('#Secondscreen').show(); makepage_keyboard(); focus(); tabenable=0; - } else if (buttons['special']=='infobox') { + } else if (buttons['special'] === 'infobox') { $('.topknop').removeClass('activetop'); $('#Secondscreen').show(); $('#Secondscreen').show(); makepage_infobox(); focus(); tabenable=0; - } else if (buttons['special']=='history') { + } else if (buttons['special'] === 'history') { $('.topknop').removeClass('activetop'); $('#commands').addClass('activetop'); $('#Secondscreen').show(); $('#Secondscreen').show(); makepage_history(); focus(); tabenable=0; - } else if (buttons['special']=='products') { + } else if (buttons['special'] === 'products') { $('.topknop').removeClass('activetop'); $('#products').addClass('activetop'); $('#Secondscreen').show(); $('#Secondscreen').show(); @@ -471,12 +457,12 @@ $( document ).ready(function() { function showquestion() { if ($('#TopButtons').is(':empty')){ - $('#TopButtons').append($('
',{class: "Question",id: 'Question', text: question})) + $('#TopButtons').append($('
',{class: "Question",id: 'Question', text: question})); } } - function runsession(SID,action,msg) { - if(SID!=session) return; + function runsession(SID,action,msg,pathParts) { + if(SID !== session) return; switch(action) { case 'message': $("#Zoek")[0].placeholder=msg; @@ -498,17 +484,17 @@ $( document ).ready(function() { $('#Log').scrollTop($('#Log')[0].scrollHeight); break; case 'infobox': - run_infobox(elms[6],msg); + run_infobox(pathParts[6],msg); $("#MainButtons").css("background-color","#d7ffd7"); break; case 'accounts': - setupaccounts(elms[5],msg); + setupaccounts(pathParts[5],msg); break; case 'products': - setupproducts(elms[5],msg); + setupproducts(pathParts[5],msg); break; case 'stock': - setupstock(elms[5],msg); + setupstock(pathParts[5],msg); break; case 'history': history=JSON.parse(msg); @@ -532,15 +518,15 @@ $( document ).ready(function() { } } function runmsg(path,msg) { - elms=path.split("/"); + var elms=path.split("/"); //console.log(elms); - if(elms.length<3) return; + if(elms.length < 3) return; switch(elms[2]) { case 'session': - if(elms.length<5) return; - sessionID=elms[3]; - action=elms[4]; - runsession(sessionID,action,msg); + if(elms.length < 5) return; + var sessionID=elms[3]; + var action=elms[4]; + runsession(sessionID,action,msg,elms); break; case 'log': dolog(msg); @@ -572,7 +558,7 @@ $( document ).ready(function() { streamReconnectDelay = 1000; }; source.onmessage = function(event) { - if(event.data == "closed") { + if(event.data === "closed") { scheduleStreamReconnect(); return; } @@ -625,7 +611,7 @@ $( document ).ready(function() { $('#Buttons').append($('
',{class: "Knopje undo",id: 'undo'}).append($('',{class: "Knopjetext",text: "Undo"}))); $('#Buttons').append($('
',{class: "Knopje ok",id: 'ok'}).append($('',{class: "Knopjetext",text: "OK"}))); $('#Buttons').append($('
',{class: "Knopje knopjes",id: 'knopjes'}).append($('',{class: "Knopjetext",text: "Show Buttons"}))); - $( ".Knopjetext:visible" ).each(function( index, element ) { + $( ".Knopjetext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); @@ -652,7 +638,7 @@ $( document ).ready(function() { buttons.push(v); } }); - if(tabenable==1) { + if(tabenable === 1) { $.each(commandstobuttons(),function(i,v) { if(v.text.toLowerCase().startsWith(zoek) || v.display.toLowerCase().startsWith(zoek)) { buttons.push(v); @@ -664,7 +650,7 @@ $( document ).ready(function() { } else { var done2=0; $.each(v.aliases,function(i2,v2) { - if(v2.toLowerCase().startsWith(zoek) && ! done2) { + if(String(v2).toLowerCase().startsWith(zoek) && ! done2) { buttons.push(v); done2=1; } @@ -673,7 +659,7 @@ $( document ).ready(function() { }); } makepages('normal',buttons); - if(buttons.length==1 && fill) { + if(buttons.length === 1 && fill) { dingen[dingen.length-1]=buttons[0].text+" "; $('#Zoek')[0].value=dingen.join(" "); } @@ -703,12 +689,14 @@ $( document ).ready(function() { focus(); }); $("body" ).on( "click",'div.invoer', function() { - if(this.id=="enter") { + if(this.id === "enter") { verwerkinput(); - } else if(this.id=="backspace") { + } else if(this.id === "backspace") { $('#Zoek')[0].value=$('#Zoek')[0].value.substring(0, $('#Zoek')[0].value.length - 1); - } else if(this.id=="leeg") { - } else if(this.id=="space") { + } else if(this.id === "leeg") { + focus(); + return; + } else if(this.id === "space") { $('#Zoek')[0].value=$('#Zoek')[0].value+" "; } else { $('#Zoek')[0].value=$('#Zoek')[0].value+this.id; @@ -719,33 +707,33 @@ $( document ).ready(function() { $("body" ).on( "click", 'div.page' ,function() { $('.Pagina').hide(); $('#Page'+this.id).show(); - $( ".Buttontext:visible" ).each(function( index, element ) { + $( ".Buttontext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); focus(); }); $("body" ).on( "click", 'div.productgroups' ,function() { - makepages('normal',productgrouptobuttons(this.id).sort(compare_display)) + makepages('normal',productgrouptobuttons(this.id).sort(compare_display)); focus(); }); $('.Knopje').click(function() { switch(this.id) { case 'members': $('.topknop').removeClass('activetop'); $('#'+this.id).addClass('activetop'); - locked=0 + locked=0; makepages('normal',accountstobuttons(accounts,'m')); $('#TopButtons').prepend($('
',{class: "Knopje Button normal cash",id: 'cash'}).append($('',{class: "Paginatext",text: "cash"}))); - $( ".Paginatext:visible" ).each(function( index, element ) { + $( ".Paginatext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); focus(); break; case 'otherusers': $('.topknop').removeClass('activetop'); $('#'+this.id).addClass('activetop'); - locked=0 + locked=0; makepages('normal',accountstobuttons(accounts,'o')); $('#TopButtons').prepend($('
',{class: "Knopje Button normal cash",id: 'cash'}).append($('',{class: "Paginatext",text: "cash"}))); - $( ".Paginatext:visible" ).each(function( index, element ) { + $( ".Paginatext:visible" ).each(function() { $(this).textfill({maxFontPixels: 5}); }); focus(); @@ -772,7 +760,7 @@ $( document ).ready(function() { $('.topknop').removeClass('activetop'); $('#'+this.id).addClass('activetop'); locked=0; $('#IRCwindow').show(); - if($('#IRCwindow').html()=="") { + if($('#IRCwindow').html() === "") { $("#IRCwindow").append($('