From 0cd18ab9f84cd2c2bb7da0f18a524168607e9114 Mon Sep 17 00:00:00 2001 From: Glueck Martin Date: Tue, 28 May 2024 15:23:53 +0200 Subject: [PATCH] Update --- App_State.py | 365 +++++++++++++++++++++++++++++----- Login.py | 29 ++- Order_Display.py | 467 +++++++++++++++++++++++++------------------- Order_Watch.py | 57 +++--- designbox_orders.py | 254 +++++++++--------------- order_watch.yaml | 11 ++ users.yaml | 9 + 7 files changed, 734 insertions(+), 458 deletions(-) create mode 100644 users.yaml diff --git a/App_State.py b/App_State.py index 5eda9ce..a86c3e3 100644 --- a/App_State.py +++ b/App_State.py @@ -1,65 +1,332 @@ -from Log_View import Log_View, Log_Handler -import schema -import logging +import logging +import logging.handlers +from rich.logging import RichHandler +import yaml +import time +import argparse +import datetime +import threading +try : + import paho.mqtt.client as mqtt +except : + mqtt = None +try : + import redis +except : + redis = None +from Attr_Dict import Attr_Dict +try : + from cryptography.fernet import Fernet +except : + print ("Decryption supported disabled") + +logging.DEBUG5 = logging.DEBUG - 5 +logging.addLevelName (logging.DEBUG5, "DEBUG5") + +def debug5 (self, msg, *args, **kwargs): + """ + Log 'msg % args' with severity 'DEBUG4'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.debug("Houston, we have a %s", "thorny problem", exc_info=1) + """ + if self.isEnabledFor(logging.DEBUG5): + self._log(logging.DEBUG5, msg, args, **kwargs) +# end def debug5 +logging.Logger.debug5 = debug5 class App_State : - user_id = None + user = None imos_done_dir = "IMOS-Done" imos_order_dir = "ImOrder" sim_batches = "Batches" pgiq_dir = "OrderXML" - L = logging.getLogger () - Action_Kind = \ - { 1 : "Create Order" - , 2 : "Update Order" - , 10 : "Change owner" - , 15 : "Force order state" - , 20 : "IMOS CAD started" - , 25 : "IMOS CAD closed" - , 30 : "CNC generation started" - , 35 : "CNC generation finished" - , 40 : "Odoo Freigabe" - , 100 : "Article created" - , 110 : "Order Line created" - , 111 : "Order Line updated" - } - @classmethod - def Create_Action (cls, kind, text, order = None) : - if kind not in cls. Action_Kind : - raise ValueError (f"Unknow action kind {kind}") - schema.Action \ - ( user = cls.user_id - , date = schema.datetime.now () - , action_kind = kind - , action = text - , order = order + def _setup_topics (self, config = None) : + config = config or getattr (self, "config", {}) + mqr = config.mqtt ["topic-root"] + baskets = f"{mqr}/baskets" + self.mqtt = Attr_Dict \ + ( basket = Attr_Dict + ( state = f"{baskets}/{{basket_no}}/state" + , user = f"{baskets}/{{basket_no}}/user" + , changed = f"{baskets}/{{basket_no}}/changed" + , data = f"{baskets}/{{basket_no}}" + ) + , baskets = baskets ) - # end def create_action + + # end def _setup_topics @classmethod - def Log_View (cls, level = logging.INFO, parent = None) : - cls.L.info ("Log window created") - cls._log_view = Log_View (parent) - lh = Log_Handler (cls._log_view) - #lh.setFormatter \ - # ( logging.Formatter - # ('%(asctime)s ' - # '%(levelname)s - %(message)s' - # , datefmt = "%Y-%m-%d %H:%M:%S" - # ) - # ) - lh.setLevel (level) - lh.setFormatter \ - ( logging.Formatter - ( "%(asctime)s - %(message)s" + def Add_Logging_Attributes (cls, parser = None) : + if parser is None : + parser = argparse.ArgumentParser () + parser.add_argument ("--log-level", type = str, default = "INFO") + parser.add_argument ("--log-file", type = str) + parser.add_argument ("--log-file-level", type = str, default = "INFO") + return parser + # end def Add_Logging_Attributes + + @classmethod + def Setup_Logging (cls, cmd, name = None) : + logging.basicConfig \ + ( level = "DEBUG5" + , format = "%(message)s" + , handlers = [] + ) + cls.L = logging.getLogger (name) + handler = RichHandler () + handler.setLevel (cmd.log_level) + cls.L.addHandler (handler) + if cmd.log_file : + handler = logging.handlers.TimedRotatingFileHandler \ + (cmd.log_file, when = "midnight", encoding = "utf-8") + formatter = logging.Formatter \ + ( "%(asctime)s - %(levelname)-7s - %(message)s" , datefmt = "%Y-%m-%d %H:%M:%S" ) - ) - cls.L.addHandler (lh) - return cls._log_view - # end def Log_View + handler.setFormatter (formatter) + if cmd.log_file_level : + handler.setLevel (cmd.log_file_level) + cls.L.addHandler (handler) + return cls.L + # end def Setup_Logging + + @staticmethod + def snow (user = None) : + date = datetime.datetime.now ().strftime ("%Y-%m-%d %H-%M-%S") + if user : + return f"{date} -- {user}" + return date + # end def snow + + @classmethod + def Load_Yaml (cls, file_name) : + with open (file_name, "r") as f : + return Attr_Dict (yaml.safe_load (f)) + # end def Load_Yaml + + def load_config (self, file_name) : + with open (file_name, "r") as f : + self.config = Attr_Dict (yaml.safe_load (f)) + self.L.info ("Configuration loaded from %s", file_name) + return self.config + # end def load_config + + def connect_to_db (self) : + if self.config.get ("database", "redis") == "mqtt" : + return MQTT \ + (self.config, self._db_connected, self._db_changes) + return Redis (self.config, self._db_connected, self._db_changes) + # end def connect_to_db + + def _db_connected (self, client) : pass + + def _db_changes (self, key, value) : pass + + def change_basket_user (self, basket_no, user, data) : + if user != data.get ("user") : + self.L.debug \ + ( "Change basket user for %s from %s to %s" + , basket_no, data.get ("user", "<>"), user + ) + data ["user"] = user + bt = self.mqtt.basket + data ["changed"] = self.snow (self.User) + self.client.publish \ + ( bt.user.format (basket_no = basket_no) + , data ["user"] + ) + self.client.publish \ + ( bt.changed.format (basket_no = basket_no) + , data ["changed"] + ) + return True + return False + # end def change_basket_user + + def change_basket_state (self, basket_no, state, data) : + old_state = int (data.get ("state", "0")) + if state != old_state : + self.L.debug \ + ( "Change basket status for %s from %s to %s" + , basket_no, old_state, state + ) + data ["state"] = str (state) + bt = self.mqtt.basket + data ["changed"] = self.snow (self.User) + self.client.publish \ + ( bt.state.format (basket_no = basket_no) + , data ["state"] + ) + self.client.publish \ + ( bt.changed.format (basket_no = basket_no) + , data ["changed"] + ) + return True + return False + # end def change_basket_state + + _key = b'Mgl1Wae_JILP0rbLazTL5gOpfrp4tqOFOiEsD_IUH8c=' + + @classmethod + def Decrypt (cls, data: bytes) -> bytes: + """Decrypt the passed data""" + return Fernet (cls._key).decrypt (data) + # end def Decrypt + + @classmethod + def Encrypt (cls, data: bytes) -> bytes: + """Encrypt the passed data""" + return Fernet (cls._key).encrypt (data.encode ("ascii")) + # end def Encrypt # end class App_State + + +class _Database_Interface_ (App_State) : + + def __init__ (self, config, connected, changes) : + self.connected = connected + self.changes = changes + # end def __init__ + + def subscribe (self, key) : pass + def publish (self, key, value) : pass + def delete (self, key): pass + def get_all_values (self, key) : pass + +# end class _Database_Interface_ + +class MQTT (_Database_Interface_) : + + def __init__ (self, config, connected, changes) : + super ().__init__ (config, connected, changes) + self.client = client = mqtt.Client () + client.on_connect = self._on_connect + client.on_message = self._on_message + mqc = config ["mqtt"] + pw = self.Decrypt (mqc.password) + client.username_pw_set (mqc.user, pw) + self.L.info ("Try to connect to MQTT %s:%s", mqc.broker, mqc.port) + client.connect (mqc.broker, port = mqc.port) + self._mqtt_thread = threading.Thread \ + (target = self.client.loop_forever, daemon = True) + self._mqtt_thread.start () + # end def __init__ + + def _on_connect (self, client, user_data, flags, rc) : + self.L.info ("Connected to MQTT Broker") + if self.connected : + self.connected (self) + # end def _on_connect + + def _on_message (self, client, user_data, msg) : + self.L.debug5 ("MQTT Message: %s: %s", msg.topic, msg.payload) + if self.changes : + self.changes (msg.topic, msg.payload) + # end def _on_message + + def subscribe (self, key, recursie) : + if recursie : + key = f"{key}/#" + self.client.subscribe (key) + # end def subscribe + + def publish (self, key, value) : + self.client.publish (key, payload = value, retain = True) + self.L.debug5 ("Publish to topic %s: %s", key, value) + # end def publish + + def delete (self, key) : + self.client.publish (key, payload = None, retain = False) + self.L.debug5 ("Delete topic %s", key) + # end def delete + +# end class MQTT + +class Redis (_Database_Interface_) : + + def __init__ (self, config, connected, changes) : + super ().__init__ (config, connected, changes) + RC = config.redis + host = RC.host + port = RC.get ("port", 6379) + self.L.info ("Try to connect to redis %s:%s", host, port) + self.client = client = redis.StrictRedis \ + ( host = host + , port = port + , decode_responses = True + ) + self.subscribtions = {} + if connected : + connected (self) + threading.Thread (target = self._listen, daemon = True).start () + # end def __init__ + + def _listen (self) : + self.L.debug ("Start redis listener",) + pubsub = self.client.pubsub (ignore_subscribe_messages = True) + pubsub.execute_command (b"CLIENT", b"ID") + client_id = pubsub.connection.read_response () + pubsub.execute_command \ + (f"CLIENT TRACKING on REDIRECT {client_id} BCAST") + res = pubsub.connection.read_response () + self.L.debug ("Redis tracking enabled: %s", res) + pubsub.subscribe ('__redis__:invalidate') + while True : + m = pubsub.get_message () + if m and m ["channel"] == "__redis__:invalidate" : + if self.changes : + for key in m ["data"] : + for k, r in self.subscribtions.items () : + if ( ( r and key.startswith (k)) + or (not r and (key == k)) + ) : + val = self.client.get (key) + self.changes (key.replace (":", "/"), val) + else : + time.sleep (0.01) + # end def _listen + + def get_all_values (self, key) : + key = key.replace ("/", ":") + for k in self.client.keys (f"{key}:*") : + v = self.client.get (k) + yield k.replace (":", "/"), v + # end def get_all_values + + def subscribe (self, key, recursive) : + self.subscribtions [key.replace ("/", ":")] = recursive + # end def subscribe + + def publish (self, key, value) : + key = key.replace ("/", ":") + self.client.set (key, value) + self.L.debug5 ("Set %s: %s", key, value) + # end def publish + + def delete (self, key) : + key = key.replace ("/", ":") + self.client.delete (key) + self.L.debug5 ("Delete key %s", key) + # end def delete + + +# end class Redis +if __name__ == "__main__" : + import argparse + parser = argparse.ArgumentParser () + parser.add_argument ("-e", "--encrypt", type = str) + parser.add_argument ("-d", "--decrypt", type = str) + cmd = parser.parse_args () + + if cmd.encrypt : + print (App_State.Encrypt (cmd.encrypt)) + if cmd.decrypt : + print (App_State.Decrypt (cmd.decrypt)) diff --git a/Login.py b/Login.py index 2bfaab3..986a26e 100644 --- a/Login.py +++ b/Login.py @@ -1,11 +1,13 @@ from QT import QtWidgets, QT -import schema - +from User import User +import yaml class Login_Dialog (QtWidgets.QDialog) : - def __init__ (self, username = "") : + def __init__ (self, config_file, username = "") : super ().__init__ () + cfg = User.Load_Yaml (config_file) + self.users = User (cfg.users) self.setWindowTitle ("Login") self.layout = L = QtWidgets.QFormLayout (self) self.user_name = QtWidgets.QLineEdit () @@ -26,7 +28,6 @@ class Login_Dialog (QtWidgets.QDialog) : self.login = login self.password.editingFinished.connect (self._login) self.user_name.editingFinished.connect(self._check_login_possible) - self.user_id = None self.user_name.setText (username) if username : self.password.setFocus () @@ -39,18 +40,14 @@ class Login_Dialog (QtWidgets.QDialog) : # end def _check_login_possible def _login (self) : - un = self.user_name.text () - pw = self.password.text () - with schema.orm.db_session : - user = schema.User.get (username = un) - if not user : - self.error.setText ("User nicht gefunden") - else : - if user.check_password (pw) : - self.user_id = user.id - self.accept () - else : - self.error.setText ("Passwort falsch") + un = self.user_name.text () + pw = self.password.text () + pw_ok = self.users.check_password (un, pw, True) + self.login.setEnabled (pw_ok) + if not pw_ok : + self.error.setText ("Passwort falsch") + else : + self.accept () # end def _login # end class Login_Dialog \ No newline at end of file diff --git a/Order_Display.py b/Order_Display.py index 49ef304..e8bb180 100644 --- a/Order_Display.py +++ b/Order_Display.py @@ -1,12 +1,13 @@ from QT import QtWidgets, QtGui, QT +from QT.Input_Dialog import Input_Dialog from _With_Table_Widget_ import _With_Table_Widget_ from App_State import App_State from Action_List import Action_Dialog -import schema import subprocess import threading import time import pathlib +from lxml import etree class Order_List (_With_Table_Widget_) : @@ -16,7 +17,8 @@ class Order_List (_With_Table_Widget_) : ("Warenkorb", "Status", "Bearbeiter", "Kommission", "Kunde", "Linie") State_Map = \ - { 0 : "neu" + { -1 : "wird gelöscht" + , 0 : "neu" , 10 : "in Bearbeitung" , 20 : "im CAD geöffnet" , 25 : "CNC Erzeugung läuft" @@ -24,6 +26,7 @@ class Order_List (_With_Table_Widget_) : , 95 : "Zurückgesetzt" , 99 : "Storno" } + def __init__ (self, parent = None) : super ().__init__ (parent) self._view.setContextMenuPolicy (QT.CustomContextMenu) @@ -34,140 +37,227 @@ class Order_List (_With_Table_Widget_) : self._reset_order = ACT (self.tr ("Auftrag zurücksetzen")) self._cancel = ACT (self.tr ("Auftrag stronieren")) self._send_to_odoo = ACT (self.tr ("Auftrag ans Odoo schicken")) - self._actions = ACT (self.tr ("Aktionsliste")) self._reset_state = ACT (self.tr ("Status zurücksetzen")) self._open_cad.triggered.connect (self._open_order_in_cad) self._recreate_cnc.triggered.connect (self._start_recreate_cnc) self._reset_order.triggered.connect (self._reset_order_in_webshop) self._cancel.triggered.connect (self._cancel_order) self._send_to_odoo.triggered.connect (self._send_oder_to_odoo) - self._actions.triggered.connect (self._show_actions) self._reset_state.triggered.connect (self._reset_order_state) self._cad_processes = {} + self.selection_model.selectionChanged.connect (self._show_details) + self._view.setSortingEnabled (True) # end def __init__ + @property + def baskets (self) : + return self.parent ().baskets + # end def baskets + + @property + def config (self) : + return self.parent ().config + # end def config + + @property + def L (self) : + return self.root.L + # end def config + + @property + def root (self) : + return self.parent ().parent ().parent () + # end def root + + def _show_details (self, selected, deselected) : + row_data = self._view.selectedItems () + if row_data : + bno = row_data [0].text () + basket = self.baskets [bno] + self.parent ()._order_detail.update_view (bno, basket) + # end def _current_changed + def _show_context_menu (self, pos) : row_data = self._view.selectedItems () if row_data : bno = row_data [0].text () - with schema.orm.db_session () : - order = schema.Order.get (basket_no = bno, active = True) - if order.user and order.user.id != App_State.user_id : - r = QtWidgets.QMessageBox.question \ - ( self, self.tr ("Fehler") - , self.tr - ( "Dieser Auftrag wird bereits von jemand anderes " - "bearbeitet.\n" - "Wollen Sie den Auftrag als Bearbeiter übernehmen?" - ) - ) - if r == QtWidgets.QMessageBox.No : - return - App_State.Create_Action \ - ( 10 - , f"User changed from {order.user.id} to {App_State.user_id}" - , order - ) - order.user = schema.User [App_State.user_id] - self.update_order (order, False, row_data [0].row ()) - state = order.state - cm = QtWidgets.QMenu () - if state < 20 : - cm.addAction (self._open_cad) - cm.addAction (self._recreate_cnc) - cm.addSeparator () - cm.addAction (self._send_to_odoo) - cm.addSeparator () - cm.addAction (self._reset_order) - cm.addAction (self._cancel) - cm.addSeparator () - cm.addAction (self._actions) + basket = self.baskets [bno] + user = basket.get ("user") + if user and user != App_State.User : + r = QtWidgets.QMessageBox.question \ + ( self, self.tr ("Fehler") + , self.tr + ( "Dieser Auftrag wird bereits von jemand anderes " + "bearbeitet.\n" + "Wollen Sie den Auftrag als Bearbeiter übernehmen?" + ) + ) + if r == QtWidgets.QMessageBox.No : + return + if self.root.change_basket_user (bno, App_State.User, basket) : + self.update_basket (bno, basket, False, row_data [0].row ()) + state = int (basket ["state"]) + cm = QtWidgets.QMenu () + if state < 20 : + cm.addAction (self._open_cad) + cm.addAction (self._recreate_cnc) cm.addSeparator () - cm.addAction (self._reset_state) - cm.exec (self._view.mapToGlobal (pos)) + cm.addAction (self._send_to_odoo) + cm.addSeparator () + cm.addAction (self._reset_order) + cm.addAction (self._cancel) + cm.addSeparator () + cm.addSeparator () + cm.addAction (self._reset_state) + cm.exec (self._view.mapToGlobal (pos)) # end def _show_context_menu - def update_order (self, order, new, row = None) : - if order.active : - state = self.State_Map [order.state] - if new : - row = self._view.rowCount () - self._view.setRowCount (row + 1) - self._add_read_only_string (row, 0, str (order.basket_no)) - self._add_read_only_string (row, 1, state) - if order.user : - self._add_read_only_string (row, 2, order.user.username) - self._add_read_only_string (row, 3, order.commission) - self._add_read_only_string (row, 4, order.imos_user) - self._add_read_only_string (row, 5, str (order.production_line)) - else : - if row is None : - for row in range (self._view.rowCount ()) : - if self._view.item (row, 0).text () == order.basket_no : - break - else : - App_State.L.error \ - ("Could not find row for order %s", order.basket_no) - return - self._update_read_only_string (row, 1, state) - self._update_read_only_string (row, 2, order.user.username) + def update_basket (self, basket_no, data, new, row = None) : + state = int (data.get ("state", "0") or "-1") + if new : + row = self._view.rowCount () + self._view.setRowCount (row + 1) else : - for row in range (self._view.rowCount ()) : - if self._view.item (row, 0).text () == order.basket_no : - self._view.removeRow (row) - return order.basket_no - # end def update_order + if row is None : + for row in range (self._view.rowCount ()) : + if self._view.item (row, 0).text () == basket_no : + break + else : + self.root.L.error \ + ("Could not find row for order %s", basket_no) + return + self._add_read_only_string (row, 0, basket_no) + self._add_read_only_string (row, 1, self.State_Map [state]) + self._add_read_only_string (row, 2, data.get ("user", "")) + self._add_read_only_string (row, 3, data.get ("commission", "")) + self._add_read_only_string (row, 4, data.get ("shop_user", "")) + self._add_read_only_string (row, 5, data.get ("line", "")) + # end def update_basket - def remove_orders (self, orders) : - for bno in orders : - for row in range (self._view.rowCount ()) : - if self._view.item (row, 0).text () == bno : - self._view.removeRow (row) - break - # end def remove_orders + def remove_basket (self, basket_no) : + for row in range (self._view.rowCount ()) : + if self._view.item (row, 0).text () == basket_no : + self._view.removeRow (row) + return + # end def remove_basket + + def add_xml_element (self, root, tag, text = None, ** kw) : + result = etree.SubElement (root, tag, **kw) + if text is not None : + result.text = str (text) + return text + # end def add_xml_element + + def _add_order_line_to_xml_file (self, xml_file : pathlib.Path, xml_element) : + xml = etree.parse (xml_file).getroot () + bl = xml.xpath ("//BuilderList") [0] + bl.append (xml_element) + ori_name = xml_file.parent / "original" / xml_file.name + if not ori_name.exists () : + ori_name.parent.mkdir (exist_ok = True) + self.L.debug ("Rename %s to %s", xml_file, ori_name) + xml_file.rename (ori_name) + with xml_file.open ("wb") as f : + f.write (etree.tostring (xml, pretty_print = True, encoding = "utf-8")) + self.L.info ("Patched XML file written %s", xml_file) + # end def _add_order_line_to_xml_file + + def add_cnc_orderline (self) : + row_data = self._view.selectedItems () + if not row_data : + return + r = Input_Dialog \ + ( "CNC Bearbeitung verrechnen" + , _MIN_WIDTH = 400 + , article = {"default": "TZ_PROCESSING_FEE", "title" : "Artikel", "required": True} + , count = {"default": 1, "title" : "Anzahl", "required": True, "type" : "int"} + , text = {"default": "Sonderbearbeitung für Position", "title" : "Text", "required": True} + , price = {"default": 60, "title" : "Preis" + , "type" : "int" + } + ).run () + if r : + bno = row_data [0].text () + basket = self.baskets [bno] + positions = basket ["positions"] + pos = int (positions [-1] ["pos"].split (".") [0]) + hpos = int (positions [-1] ["hpos"]) + tax = positions [0] ["tax"] + price = r ["price"] + tax_amount= price / 100 * tax + pos += 1 + hpos += 1 + positions.append \ + ( { "art_name" : r ["article"] + , "count" : r ["count"] + , "pos" : str (pos) + , "hpos" : str (hpos) + , "text" : r ["text"] + , "tax" : tax + , "price" : price + } + ) + self.parent ()._order_detail.update_view (bno, basket) + xset = etree.Element ("Set", LineNo = str (pos)) + AXE = self.add_xml_element + AXE (xset, "hierarchicalPos", hpos) + AXE (xset, "Pname", r ["article"]) + AXE (xset, "Count", r ["count"]) + AXE (xset, "PVarString", f"IDBEXTID:=1|ART_NAME:={r ['article']}") + AXE (xset, "ARTICLE_TEXT_INFO1", r ["text"]) + AXE (xset, "ARTICLE_TEXT_INFO2", r ["text"]) + AXE (xset, "ARTICLE_TEXT_INFO3") + AXE (xset, "ARTICLE_TEXT_INFO4", 0) + AXE (xset, "ARTICLE_TEXT_INFO5", r ["text"]) + AXE (xset, "ARTICLE_TEXT_INFO6", r ["text"]) + AXE (xset, "ARTICLE_TEXT_INFO7") + AXE (xset, "ARTICLE_PRICE_INFO1", price) + AXE (xset, "ARTICLE_PRICE_INFO2", price) + AXE (xset, "ARTICLE_PRICE_INFO3", price) + AXE (xset, "ARTICLE_PRICE_INFO4", tax) + AXE (xset, "ARTICLE_PRICE_INFO5", round (tax_amount, 2)) + AXE (xset, "ARTICLE_PRICE_INFO6", round (price + tax_amount, 2)) + breakpoint () + OR = pathlib.Path (self.config.directories.order_ready) + AR = pathlib.Path (self.config.directories.sim_root) / "Archive" + ## first patch the XML in the archive directory + self._add_order_line_to_xml_file (AR / f"{bno}.xml", xset) + ## than patch the file in the order-ready directory + self._add_order_line_to_xml_file (OR / f"{bno}.xml", xset) + return True + return False + # end def add_cnc_orderline def _open_order_in_cad (self) : row_data = self._view.selectedItems () if row_data : - bno = row_data [0].text () - with schema.orm.db_session () : - state = schema.State.get () - order = schema.Order.get (basket_no = bno, active = True) - if order.state < 20 : - order.state = 20 - self.update_order (order, False, row_data [0].row ()) - order_dwg = r"%s\ImOrder\%s\%s.dwg" \ - % (state.imos_factory_root, bno, bno) - self._cad_processes [bno] = p = subprocess.Popen \ - ( ( order_dwg, ), shell = True ) - threading.Thread \ - ( target = self._wait_for_cad_close - , args = (bno, ) - , daemon = True - ).start () - App_State.Create_Action \ - ( 20 - , f"IMOS CAD started {order.basket_no}" - , order - ) + bno = row_data [0].text () + basket = self.baskets [bno] + state = int (basket.get ("state", 0)) + if state < 20 : + if self.root.change_basket_state (bno, 20, basket) : + self.update_basket (bno, basket, False, row_data [0].row ()) + order_dwg = r"%s\ImOrder\%s\%s.dwg" \ + % (self.config.directories.factory_root, bno, bno) + self._cad_processes [bno] = p = subprocess.Popen \ + ( ( order_dwg, ), shell = True ) + threading.Thread \ + ( target = self._wait_for_cad_close + , args = (bno, ) + , daemon = True + ).start () # end def _open_order_in_cad def _wait_for_cad_close (self, basket_no) : - App_State.L.info ("Auftrag %s im CAD geöffnet", basket_no) + self.L.info ("Auftrag %s im CAD geöffnet", basket_no) p = self._cad_processes [basket_no] while p.poll () is None : time.sleep (.5) del self._cad_processes [basket_no] - with schema.orm.db_session () : - order = schema.Order.get (basket_no = basket_no, active = True) - order.state = 10 - self.update_order (order, False) - App_State.Create_Action \ - ( 25 - , f"IMOS CAD closed {order.basket_no}: Error code {p.poll ()}" - , order - ) - App_State.L.info ("CAD für Auftrag %s beendet", basket_no) + basket = self.baskets [basket_no] + if self.root.change_basket_state (basket_no, 10, basket) : + self.update_basket (basket_no, basket, False) + self.L.info ("Auftrag %s im CAD geschlossen", basket_no) # end def _wait_for_cad_close def _start_recreate_cnc (self) : @@ -185,101 +275,80 @@ class Order_List (_With_Table_Widget_) : """ - with schema.orm.db_session () : - state = schema.State.get () - order = schema.Order.get (basket_no = bno, active = True) - job_file_name = ( pathlib.Path (state.imos_sim_root) - / App_State.sim_batches - / f"{bno}.xml" - ) - txt_file = ( pathlib.Path (state.imos_factory_root) - / App_State.imos_done_dir - / f"{bno}.txt" - ) - order.state = 25 - with job_file_name.open ("w") as f : - f.write (xml) - if txt_file.exists () : - txt_file.unlink () - self.update_order (order, False, row_data [0].row ()) - threading.Thread \ - ( target = self._wait_for_cnc_done - , args = (bno, ) - , daemon = True - ).start () - App_State.Create_Action \ - ( 30, f"Start CNC generation for {bno}", order) + job_file_name = ( pathlib.Path (self.config.directories.sim_root) + / App_State.sim_batches + / f"{bno}.xml" + ) + txt_file = ( pathlib.Path (self.config.directories.factory_root) + / App_State.imos_done_dir + / f"{bno}.txt" + ) + with job_file_name.open ("w") as f : + f.write (xml) + if txt_file.exists () : + txt_file.unlink () + basket = self.baskets [bno] + if self.root.change_basket_state (bno, 25, basket) : + self.update_basket (bno, basket, False, row_data [0].row ()) + threading.Thread \ + ( target = self._wait_for_cnc_done + , args = (bno, ) + , daemon = True + ).start () # end def _start_recreate_cnc def _wait_for_cnc_done (self, basket_no) : - with schema.orm.db_session () : - state = schema.State.get () - txt_file = ( pathlib.Path (state.imos_factory_root) + txt_file = ( pathlib.Path (self.config.directories.factory_root) / App_State.imos_done_dir / f"{basket_no}.txt" ) - err_file_name = ( pathlib.Path (state.imos_sim_root) + err_file_name = ( pathlib.Path (self.config.directories.sim_root) / App_State.sim_batches / "Error" / f"{basket_no}.xml" ) while not txt_file.exists () and not err_file_name.exists (): time.sleep (0.5) - App_State.L.debug (f"wait for {txt_file}/{err_file_name}") + self.L.debug (f"wait for {txt_file}/{err_file_name}") if err_file_name.exists () : text = f"Fehler bei der CNC Erzeugung for {basket_no}" else : text = f"CNC Erzeugung für {basket_no} abgeschlossen" - with schema.orm.db_session () : - order = schema.Order.get (basket_no = basket_no, active = True) - order.state = 10 - self.update_order (order, False) - App_State.Create_Action (35, text, order) + self.L.info (text.format (basket_no = basket_no)) + basket = self.baskets [basket_no] + if self.root.change_basket_state (basket_no, 10, basket) : + self.update_basket (basket_no, basket, False) # end def _wait_for_cnc_done def _reset_order_in_webshop (self) : row_data = self._view.selectedItems () if row_data : bno = row_data [0].text () - App_State.L.error ("Reset order in webshop %s", bno) + self.L.error ("Reset order in webshop %s", bno) # end def _reset_order_in_webshop def _cancel_order (self) : row_data = self._view.selectedItems () if row_data : bno = row_data [0].text () - App_State.L.error ("Cancel Order %s", bno) + self.L.error ("Cancel Order %s", bno) # end def _cancel_order def _send_oder_to_odoo (self) : row_data = self._view.selectedItems () if row_data : - bno = row_data [0].text () - with schema.orm.db_session () : - state = schema.State.get () - order = schema.Order.get (basket_no = bno, active = True) - factory = pathlib.Path (state.imos_factory_root) - for ext in ".xml", ".txt" : - sf = factory / App_State.imos_done_dir / f"{bno}{ext}" - df = factory / App_State.pgiq_dir / f"{bno}{ext}" - sf.rename (df) - App_State.L.info ("Auftrag %s für Odoo freigegeben", bno) - App_State.Create_Action \ - (40, f"Auftrag {bno} für Odoo freigegeben", order) - order.active = False - self.update_order (order, False) + bno = row_data [0].text () + basket = self.baskets [bno] + state = int (basket ["state"]) + D = self.config.directories + factory = pathlib.Path (D.factory_root) + for ext in ".xml", ".txt" : + sf = factory / D.order_ready / f"{bno}{ext}" + df = factory / D.pgiq_dir / f"{bno}{ext}" + sf.rename (df) + self.L.info ("Auftrag %s für Odoo freigegeben", bno) # end def _send_oder_to_odoo - def _show_actions (self) : - row_data = self._view.selectedItems () - if row_data : - bno = row_data [0].text () - with schema.orm.db_session () : - order = schema.Order.get (basket_no = bno, active = True) - Action_Dialog \ - (order.actions.order_by (schema.orm.desc (schema.Action.date))).exec () - # end def _show_actions - def _reset_order_state (self) : row_data = self._view.selectedItems () if row_data : @@ -293,15 +362,9 @@ class Order_List (_With_Table_Widget_) : ) if r == QtWidgets.QMessageBox.No : return - with schema.orm.db_session () : - order = schema.Order.get (basket_no = bno, active = True) - order.state = 10 - self.update_order (order, False) - App_State.Create_Action \ - ( 15 - , f"Status wird auf 10 gesetzt" - , order - ) + basket = self.baskets [bno] + if self.root.change_basket_state (bno, 10, basket) : + self.update_basket (bno, basket, False) # end def _reset_order_state # end class Order_List @@ -311,40 +374,50 @@ class Order_Detail (_With_Table_Widget_) : settings_group = "order-detail" Header = "Auftrags Detail" Columns = \ - ("Position", "Artikel", "Preis") + ("Position", "Anzahl", "Artikel", "Preis") + + def update_view (self, basket_no, basket) : + if basket : + positions = basket.get ("positions", ()) + self._view.setRowCount (len (positions)) + for i, p in enumerate (positions) : + self._update_read_only_string (i, 0, str (p ["pos"])) + self._update_read_only_string (i, 1, str (p ["count"])) + self._update_read_only_string (i, 2, str (p ["text"])) + self._update_read_only_string (i, 3, "%7.2f" % p ["price"]) + # end def update_view # end class Order_Detail class Order_Display (QtWidgets.QSplitter) : - def __init__ (self, parent) : + def __init__ (self, config, parent) : super ().__init__ (parent) + self.config = config self._order_list = Order_List () self._order_detail = Order_Detail () self.addWidget (self._order_list) self.addWidget (self._order_detail) - self.orders = {} + self.baskets = {} # end def __init__ - def update_orders (self) : - orders = set (self.orders.keys ()) - with schema.orm.db_session : - for o in schema.Order.select (active = True) : - db_lc = o.actions.order_by (schema.Action.date).first ().date - if o.basket_no not in self.orders : - self._order_list.update_order (o, True) - self.orders [o.basket_no] = o - o.last_changed = db_lc - else : - if db_lc > self.orders [o.basket_no].last_changed : - self._order_list.update_order (o, False) - self.orders [o.basket_no].last_changed = db_lc - orders.discard (o.basket_no) - self._order_list.remove_orders (orders) - for o in orders : - self.orders.pop (o, None) - # end def update_orders - + def update_basket (self, basket_no, data) : + new = basket_no not in self.baskets + if data is not None : + if basket_no not in self.baskets : + self.baskets [basket_no] = data + else : + self.baskets [basket_no].update (data) + self._order_list.update_basket (basket_no, self.baskets [basket_no], new) + else : ### remove order + self._order_list.remove_basket (basket_no) + self.baskets.pop (basket_no, None) + # end def update_basket + + def add_cnc_orderline (self) : + self._order_list.add_cnc_orderline () + # end def add_cnc_orderline + def save_settings (self, settings) : settings.beginGroup ("order-display") settings.setValue ("geometry", self.saveGeometry ()) diff --git a/Order_Watch.py b/Order_Watch.py index 6ceb227..883c36e 100644 --- a/Order_Watch.py +++ b/Order_Watch.py @@ -6,22 +6,14 @@ from filesystem_watcher import \ WindowsApiEmitterExceptionHandling, Directory_Handler import time import json -import datetime -class Order_Handler (Directory_Handler) : +class Order_Handler (Directory_Handler, App_State) : def __init__ (self, path, client, config) : super ().__init__ (path) self.client = client - mqr = config.mqtt ["topic-root"] - baskets = f"{mqr}/baskets" - self.mqtt = Attr_Dict \ - ( basket_topic = f"{baskets}/{{basket_no}}" - , basket_state = f"{baskets}/{{basket_no}}/state" - , basket_user = f"{baskets}/{{basket_no}}/user" - , basket_changed = f"{baskets}/{{basket_no}}/changed" - , baskets = baskets - ) + self.config = config + self._setup_topics (config) # end def __init__ def on_any_event (self, event) : @@ -39,11 +31,10 @@ class Order_Handler (Directory_Handler) : file = Path (event.src_path) if file.suffix == ".xml" : basket_no = file.stem - for t in self.mqtt.values () : - self.client.publish \ - ( t.format (basket_no = basket_no) - , payload = None, retain = False - ) + for t in self.mqtt.basket.values () : + key = t.format (basket_no = basket_no) + self.client.delete (key) + self.L.debug ("Delete key %s", key) self.L.info ("Order removed %s", basket_no) super ().on_any_event (event) # end def on_any_event @@ -51,24 +42,28 @@ class Order_Handler (Directory_Handler) : def new_order (self, xml_file : Path) : try : self.L.info ("New basket %s", xml_file.name) - xml = etree.parse (xml_file).getroot () + sim_xml = ( Path ( self.config.directories.sim_root) + / "Archive" / xml_file.name + ) + if not sim_xml.exists () : + self.L.error ("Archive order xml does not exist: %s", sim_xml) + xml = etree.parse (sim_xml).getroot () com = xml.xpath ("//TEXT_SHORT") [0].text user, line = xml.xpath ("//INFO8") [0].text.split ("/") basket_no = xml_file.stem + bt = self.mqtt.basket self.client.publish \ - ( self.mqtt.basket_state.format (basket_no = basket_no) - , payload = "new", retain = True - ) + ( bt.state.format (basket_no = basket_no), 0) self.client.publish \ - ( self.mqtt.basket_changed.format (basket_no = basket_no) - , payload = App_State.snow ("import"), retain = True + ( bt.changed.format (basket_no = basket_no) + , App_State.snow ("import") ) basket = dict \ - (user = user, line = line, commission = com) + (shop_user = user, line = line, commission = com) basket ["positions"] = self._scan_order_pos (xml) self.client.publish \ - ( self.mqtt.basket_topic.format (basket_no = basket_no) - , payload = json.dumps (basket), retain = True + ( bt.data.format (basket_no = basket_no) + , json.dumps (basket) ) self.L.info ("Created new basket %s", basket_no) except Exception as e : @@ -82,13 +77,17 @@ class Order_Handler (Directory_Handler) : art_name = a.xpath ("Pname") [0].text.strip () count = int (a.xpath ("Count") [0].text) pos = a.get ("LineNo") + hpos = a.xpath ("hierarchicalPos") [0].text text = a.xpath ("ARTICLE_TEXT_INFO1" ) [0].text.strip () price = float (a.xpath ("ARTICLE_PRICE_INFO1") [0].text) + tax = float (a.xpath ("ARTICLE_PRICE_INFO4") [0].text) result.append \ ( dict ( art_name = art_name , count = count - , pos = pos + , pos = pos + , hpos = hpos , text = text + , tax = tax , price = price ) ) @@ -102,8 +101,8 @@ class Order_Watch (App_State) : order_incomming_dir = r"n:\glueck\watch-me\inbox" def __init__ (self, config_file) : - self.load_config (config_file) - self.client = self.connect_to_mqtt () + self.load_config (config_file) + self.client = self.connect_to_db () # end def __init__ def _start_watchdog (self) : @@ -139,7 +138,7 @@ if __name__ == "__main__" : parser.add_argument ("config_file", type = str) cmd = parser.parse_args () - _Logger_.L = Order_Watch.Setup_Logging (cmd) + _Logger_.L = App_State.Setup_Logging (cmd) ow = Order_Watch (cmd.config_file) ow.run () \ No newline at end of file diff --git a/designbox_orders.py b/designbox_orders.py index a1f485e..9ce8985 100644 --- a/designbox_orders.py +++ b/designbox_orders.py @@ -1,16 +1,17 @@ import os from QT.Main_Window import Main_Window, QtWidgets from QT import QT +from Log_View import Log_View, Log_Handler from Order_Display import Order_Display from Login import Login_Dialog from App_State import App_State import designbox_orders_rc -import schema import pathlib -from lxml import etree import logging +import threading +import json -class Order_Processing (Main_Window) : +class Order_Processing (Main_Window, App_State) : APP_NAME = "Designbox Orders" VERSION = "0.5" @@ -20,18 +21,27 @@ class Order_Processing (Main_Window) : @classmethod def _load_app_settings (cls, settings) : settings.beginGroup ("TempSettings") - result = {"database" : settings.value ("database")} - result = {"username" : settings.value ("username")} + result = { "config_file" : settings.value ("config_file") + , "user_name" : settings.value ("user_name") + } settings.endGroup () return result # end def _load_app_settings def _setupActions (self, app) : super ()._setupActions (app) - self._add_action ("test", "Scan Dir", self.scan_orders_directory) + self._add_action \ + ( "add_cnc_orderline", "CNC Bearbeitung verrechnen" + , self.add_cnc_orderline + ) # end def _setupActions def _setupGui (self) : + settings = self.settings + settings.beginGroup ("TempSettings") + config_file = settings.value ("config_file") + settings.endGroup () + self.load_config (config_file) self.main_menu = QtWidgets.QMenuBar (self) self.setMenuBar (self.main_menu) file = QtWidgets.QMenu ("& File") @@ -41,157 +51,53 @@ class Order_Processing (Main_Window) : for a, l in ( ( self.actions.exit , (file, self.tool_bar_file) ) - , ( (self.actions.test), (self.tool_bar_file, )) + , ( (self.actions.add_cnc_orderline), (self.tool_bar_file, )) ) : for x in l : x.addAction (a) sp = QtWidgets.QSplitter (self) - self._order_display = Order_Display (self) - self._log = App_State.Log_View (parent = self) + self._order_display = Order_Display (self.config, self) + self.create_log_view (parent = self) sp.addWidget (self._order_display) - sp.addWidget (self._log) + sp.addWidget (self._log_view) sp.setOrientation (QT.Vertical) self.setCentralWidget (sp) - settings = self.settings - settings.beginGroup ("TempSettings") - user_id = settings.value ("user_id") - settings.endGroup () - with schema.orm.db_session : - App_State.user_id = schema.User [user_id].id - self.scan_orders_directory () + self._setup_topics () + self.L.info ("User: %s", App_State.User) + self.client = self.connect_to_db () # end def _setupGui - def _find_orders (self, root_path : pathlib.Path) : - result = set () - for path, dirs, files in root_path.walk () : - for f in files : - if f.endswith (".txt") : - xml_name = (path / f).with_suffix (".xml") - if xml_name.exists () : - result.add (xml_name) - for d in dirs : - result.intersection_update (self._find_orders (root_path / d)) - return result - # end def _find_orders + def _db_connected (self, client) : + topic = self.mqtt.baskets + client.subscribe (topic, True) + self.L.debug ("Subscribe to %s", topic) + for k, v in client.get_all_values (topic) : + self._db_changes (k, v) + # end def _mqtt_connected - def scan_orders_directory (self) : - with schema.orm.db_session : - state = schema.State.get () - sim_archive = pathlib.Path (state.imos_sim_root) / "Archive" - iroot = pathlib.Path (state.imos_factory_root) - orders = self._find_orders (iroot / App_State.imos_done_dir) - with schema.orm.db_session : - for o in orders : - self._update_order_in_database (sim_archive, o) - orders = set (o.stem for o in orders) - for o in schema.Order.select (active = True) : - if o.basket_no not in orders : - o.active = False - self._order_display.update_orders () - # end def scan_orders_directory - - def _update_order_in_database (self, sim_archive, xml_file) : - basket_no = xml_file.stem - if True : - order = schema.Order.get (basket_no = basket_no, active = True) - xml_file = sim_archive / xml_file.name - xml = etree.parse (xml_file).getroot () - com = xml.xpath ("//TEXT_SHORT") [0].text - user,line = xml.xpath ("//INFO8") [0].text.split ("/") - if not order : - order = schema.Order \ - ( basket_no = basket_no - , state = 0 - , commission = com - , imos_user = user - , production_line = schema.Production_Line.get (short = line) - , active = True - ) - schema.Action \ - ( user = schema.User [App_State.user_id] - , date = schema.datetime.now () - , action_kind = 1 - , action = f"Auftrag importiert {basket_no}" - , order = order - ) + def _db_changes (self, key, value) : + bst = self.mqtt.baskets + if key.startswith (bst) : + key = key [len (bst) + 1:] + parts = key.split ("/") + basket_no = parts.pop (0) + if not parts : + if value : + data = json.loads (value) + else : + data = None else : - pl = schema.Production_Line.get (short = line) - if ( (order.commission != com) - or (order.imos_user != user) - or (order.production_line != pl) - ) : - order.set ( commission = com - , imos_user = user - , production_line = pl - ) - schema.Action \ - ( user = schema.User [App_State.user_id] - , date = schema.datetime.now () - , action_kind = 2 - , action = - f"Auftrag geändert fom XML {basket_no}" - , order = order - ) - self._scan_order_pos (order, xml) - order.active = True - # end def _update_order_in_database - - def _scan_order_pos (self, order, xml) : - bl = xml.xpath ("//BuilderList") [0] - changes = 0 - for a in bl.getchildren () : - art_name = a.xpath ("Pname") [0].text.strip () - count = int (a.xpath ("Count") [0].text) - pos = a.get ("LineNo") - text = a.xpath ("ARTICLE_TEXT_INFO1" ) [0].text.strip () - price = float (a.xpath ("ARTICLE_PRICE_INFO1") [0].text) - article = schema.Odoo_Article.get (name = art_name) - if not article : - article = schema.Odoo_Article \ - ( name = art_name - , default_text = art_name - , default_price = 0 - ) - App_State.Create_Action \ - ( 100 - , f"Artikel {art_name} wurde in der Datenbank angelegt" - , order - ) - order_line = schema.Order_Line.get (order = order, position = pos) - if not order_line : - order_line = schema.Order_Line \ - ( order = order - , position = pos - , count = count - , text = text - , price = price - , odoo_article = article - ) - App_State.Create_Action \ - ( 110 - , f"Auftragszeile {pos} für {order.basket_no} wurde in der " - "Datenbank angelegt" - , order - ) - changes += 1 - else : - if ( (order_line.text != text) - or (order_line.count != count) - or (order_line.price != price) - ) : - print ("Update") - order_line.text = text - order_line.price = price - order_line.count = count - changes += 1 - App_State.Create_Action \ - ( 111 - , f"Auftragszeile {pos} für {order.basket_no} wurde " - "in der Datenbank geändert" - , order - ) - # end def _scan_order_pos + if isinstance (value, bytes) : + value = value.decode ("utf-8") + data = {parts [0] : value} + self.L.debug ("Change basket %s: %s", basket_no, data) + self._order_display.update_basket (basket_no, data) + # end def _mqtt_message + def add_cnc_orderline (self) : + self._order_display.add_cnc_orderline () + # end def add_cnc_orderline + def _restore_settings (self) : super ()._restore_settings () sp = self.centralWidget () @@ -201,7 +107,7 @@ class Order_Processing (Main_Window) : settings.setValue ("windowState", sp.saveState ()) settings.endGroup () self._order_display.restore_settings (settings) - self._log.restore_settings (settings) + self._log_view.restore_settings (settings) # end def _restore_settings def save_settings (self) : @@ -213,47 +119,61 @@ class Order_Processing (Main_Window) : sp.restoreState (settings.value ("windowState")) settings.endGroup () self._order_display.save_settings (settings) - self._log.save_settings (settings) + self._log_view.save_settings (settings) settings.beginGroup ("TempSettings") - settings.setValue ("user_id", App_State.user_id) + + settings.setValue ("user_name", App_State.User) settings.endGroup () # end def save_settings @classmethod def pre_main_window_action (cls, settings) : settings.beginGroup ("TempSettings") - db_file = settings.value ("database") - user_id = settings.value ("user_id") + config_file = settings.value ("config_file") + user_name = settings.value ("user_name") settings.endGroup () - schema.db.bind (provider = "sqlite", filename = db_file) - schema.db.generate_mapping (create_tables = False) - username = "" - if user_id : - with schema.orm.db_session : - username = schema.User [user_id].username if False : - login = Login_Dialog (username) + login = Login_Dialog (config_file, user_name) if login.exec () == 1 : settings.beginGroup ("TempSettings") - settings.setValue ("user_id", login.user_id) + settings.setValue ("user_name", App_State.User) settings.endGroup () return True else : - App_State.L.error ("### Login skipped") + cls.L.error ("### Login skipped") + App_State.User = user_name return True # end def pre_main_window_action + def create_log_view (self, level = "INFO", parent = None) : + self.L.debug ("Log window created") + self._log_view = Log_View (parent) + lh = Log_Handler (self._log_view) + #lh.setFormatter \ + # ( logging.Formatter + # ('%(asctime)s ' + # '%(levelname)s - %(message)s' + # , datefmt = "%Y-%m-%d %H:%M:%S" + # ) + # ) + lh.setLevel (level) + lh.setFormatter \ + ( logging.Formatter + ( "%(asctime)s - %(message)s" + , datefmt = "%Y-%m-%d %H:%M:%S" + ) + ) + self.L.addHandler (lh) + return self._log_view + # end def create_log_view + # end class Order_Processing if __name__ == "__main__" : import argparse - parser = argparse.ArgumentParser () - parser.add_argument ("-s","--settings-file", type = str) - parser.add_argument ("-d", "--database", type = str) + parser = Order_Processing.Add_Logging_Attributes () + parser.add_argument ("-s", "--settings-file", type = str) + parser.add_argument ("-c", "--config-file", type = str) cmd = parser.parse_args () - logging.basicConfig \ - ( level = logging.DEBUG - , format = "%(asctime)s - %(levelname)s - %(message)s" - , datefmt = "%Y-%m-%d %H:%M:%S" - ) + App_State.Setup_Logging (cmd) Order_Processing.run (Order_Processing.load_settings (cmd)) diff --git a/order_watch.yaml b/order_watch.yaml index 2edd3c4..5f52685 100644 --- a/order_watch.yaml +++ b/order_watch.yaml @@ -4,3 +4,14 @@ mqtt: user: imos password: gAAAAABmTy4fipVAw10oKCE2aLWq79BF9Id5H8-lEEm8tOgcoaGgIJRWwBdR0nj-lrN-70hcLMHVqNudk7FTJjOvE0KOCkDr0A== topic-root: imos-order +redis: + host: odoo-test + +database: redis + +users: users.yaml +directories: + order_ready: n:\glueck\watch-me\inbox + factory_root: n:\IMOS\Live-System\Factory + sim_root: n:\IMOS\Live-System\SIM + pgiq_dir: n:\glueck\watch-me\odoo \ No newline at end of file diff --git a/users.yaml b/users.yaml new file mode 100644 index 0000000..0d65800 --- /dev/null +++ b/users.yaml @@ -0,0 +1,9 @@ +glueck: + active: true + password: 8625ff9e7b2f0916bf6e9ef6c163d3d6f873a7796146453bf236d52f57cb90a6 +user1: + active: true + password: 8625ff9e7b2f0916bf6e9ef6c163d3d6f873a7796146453bf236d52f57cb90a6 +user2: + active: true + password: 8625ff9e7b2f0916bf6e9ef6c163d3d6f873a7796146453bf236d52f57cb90a6