From d5ef8eea6f19b7c2ecb2c9efb49bf8cb317f3021 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Tue, 22 Sep 2026 20:57:49 +0300 Subject: [PATCH] Improve User Manager: Workbench-style permissions dialog, live mode support, double-click to open - Permissions dialog now uses two-level UI: compact Edit User with >> buttons opening a separate grid dialog (Groups x Operator/Admin columns) with Clear All / Set All and auto-cascade (write implies read, admin implies operator) - Provisioning has its own >> popup dialog - Removed live mode restrictions for add/edit/delete user operations - Broadened type detection and added safe int conversion for live mode values - Double-click on tree node or canvas box opens special view (User Manager for UserService, Properties for other components) Co-Authored-By: Claude Opus 4.6 --- app_state.py | 2 +- main.py | 38 ++++++ user_service_view.py | 300 ++++++++++++++++++++++++++++++++----------- 3 files changed, 264 insertions(+), 76 deletions(-) diff --git a/app_state.py b/app_state.py index e30ee94..f9c26f2 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.117" +VERSION = "0.0.0.118" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/main.py b/main.py index f3e9243..d4b31bb 100644 --- a/main.py +++ b/main.py @@ -985,6 +985,42 @@ def on_tree_right_click(event): tree_comp_context_menu.post(event.x_root, event.y_root) +def _open_special_view(comp): + """Opens the type-specific view for a component, or Properties as fallback.""" + if user_service_view.is_user_service(comp): + show_user_service(comp) + return + show_properties(comp) + + +def on_tree_double_click(event): + """Double-click on a tree node opens its special view (or Properties).""" + tree = app_state.tree + item_id = tree.identify_row(event.y) + if not item_id or tree.parent(item_id) == "": + return + comp = app_state.tree_element_map.get(item_id) + if comp is not None: + _open_special_view(comp) + + +def on_canvas_double_click(event): + """Double-click on a canvas component opens its special view (or Properties).""" + canvas = app_state.canvas + cx = canvas.canvasx(event.x) + cy = canvas.canvasy(event.y) + clicked = canvas.find_closest(cx, cy, halo=3) + if not clicked: + return + tags = canvas.gettags(clicked[0]) + group_tag = next((t for t in tags if t.startswith("comp_group_")), None) + if group_tag is None: + return + comp = app_state.canvas_comp_map.get(group_tag) + if comp is not None: + _open_special_view(comp) + + def on_canvas_click(event): canvas = app_state.canvas canvas.itemconfig("block_outline", outline="#7f9db9", width=2) @@ -2502,6 +2538,7 @@ tree.bind("<>", on_tree_select) tree.bind("", on_tree_right_click) tree.bind("", on_tree_right_click) +tree.bind("", on_tree_double_click) left_pane.add(frame_tree, weight=3) @@ -2729,6 +2766,7 @@ canvas.bind("", lambda e: wiresheet.draw_grid()) canvas.bind("", on_canvas_right_click) canvas.bind("", on_canvas_right_click) +canvas.bind("", on_canvas_double_click) # Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead. canvas.bind("", on_canvas_wheel_zoom) diff --git a/user_service_view.py b/user_service_view.py index 20d680a..06167f6 100644 --- a/user_service_view.py +++ b/user_service_view.py @@ -99,6 +99,18 @@ def _get_prop(comp, name): return prop.get("val") if prop is not None else None +def _safe_int(val): + if val is None: + return 0 + try: + return int(val) + except (TypeError, ValueError): + try: + return int(float(val)) + except (TypeError, ValueError): + return 0 + + def _get_users(service_comp): """Returns child comp elements, excluding uncached placeholders.""" return [c for c in service_comp @@ -124,23 +136,176 @@ def is_user_service(comp): if comp is None: return False type_name = comp.get("type", "") - if type_name.endswith("::UserService") or type_name == "UserService": + if "UserService" in type_name: return True for child in comp: - if child.tag == "comp" and child.get("type", "").endswith("::User"): + if child.tag == "comp" and "User" in child.get("type", ""): return True return False +class _PermissionsDialog: + """Workbench-style permissions grid: Groups x Operator/Admin columns.""" + + def __init__(self, parent, perm_val): + self.result = None + groups = _decode_perm(perm_val) + + self._dlg = tk.Toplevel(parent) + self._dlg.title("Permissions") + self._dlg.transient(parent) + self._dlg.grab_set() + self._dlg.resizable(False, False) + + grid = tk.Frame(self._dlg, padx=8, pady=8) + grid.pack(fill=tk.BOTH, expand=True) + + COL_HEADERS = [ + ("Operator", ["Read", "Write", "Invoke"]), + ("Admin", ["Read", "Write", "Invoke", "User"]), + ] + + # header row 0: "Groups" + category spans + tk.Label(grid, text="Groups", relief=tk.GROOVE, bg="#e8e8e8", + padx=4, pady=2).grid(row=0, column=0, rowspan=2, sticky=tk.NSEW) + col = 1 + for cat_name, sub_cols in COL_HEADERS: + span = len(sub_cols) + tk.Label(grid, text=cat_name, relief=tk.GROOVE, bg="#d8e8f0", + font=("TkDefaultFont", 8, "bold"), + padx=4, pady=2).grid(row=0, column=col, columnspan=span, + sticky=tk.NSEW) + col += span + + # header row 1: sub-column names + col = 1 + for _, sub_cols in COL_HEADERS: + for sub in sub_cols: + tk.Label(grid, text=sub, relief=tk.GROOVE, bg="#e8e8e8", + font=("TkDefaultFont", 7), padx=4, pady=1).grid( + row=1, column=col, sticky=tk.NSEW) + col += 1 + + # data rows + self._perm_vars = [] + for g in range(4): + r = g + 2 + tk.Label(grid, text=f"Group {g + 1}", relief=tk.GROOVE, + bg="#f4f4f4", padx=4, pady=1).grid(row=r, column=0, + sticky=tk.NSEW) + gvars = [] + for f_idx in range(7): + var = tk.BooleanVar(value=groups[g][f_idx]) + var.trace_add("write", lambda *_a, g=g, f=f_idx: self._on_perm_toggle(g, f)) + ttk.Checkbutton(grid, variable=var).grid( + row=r, column=f_idx + 1, padx=6, pady=1) + gvars.append(var) + self._perm_vars.append(gvars) + + # Clear All / Set All + action_frame = tk.Frame(grid) + action_frame.grid(row=6, column=0, columnspan=8, sticky=tk.E, pady=(6, 0)) + ttk.Button(action_frame, text="Clear All", width=8, + command=self._clear_all).pack(side=tk.LEFT, padx=2) + ttk.Button(action_frame, text="Set All", width=8, + command=self._set_all).pack(side=tk.LEFT, padx=2) + + # OK / Cancel + btn_frame = tk.Frame(self._dlg, pady=6) + btn_frame.pack() + ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack( + side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack( + side=tk.LEFT, padx=4) + + self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel) + self._dlg.wait_window() + + def _on_perm_toggle(self, group, flag_idx): + """Auto-cascade: write implies read, admin implies operator.""" + gvars = self._perm_vars[group] + if not gvars[flag_idx].get(): + return + # Flags: 0=or, 1=ow, 2=oi, 3=ar, 4=aw, 5=ai, 6=ua + # Write (1) → Read (0); Admin Write (4) → Admin Read (3) + if flag_idx in (1, 4): + gvars[flag_idx - 1].set(True) + # Admin flag (3,4,5) → same Operator flag (0,1,2) + if 3 <= flag_idx <= 5: + gvars[flag_idx - 3].set(True) + + def _clear_all(self): + for gvars in self._perm_vars: + for v in gvars: + v.set(False) + + def _set_all(self): + for gvars in self._perm_vars: + for v in gvars: + v.set(True) + + def _on_ok(self): + groups = [[v.get() for v in gvars] for gvars in self._perm_vars] + self.result = _encode_perm(groups) + self._dlg.destroy() + + def _on_cancel(self): + self._dlg.destroy() + + +class _ProvisioningDialog: + """Provisioning flags popup: app, kits, svm checkboxes.""" + + def __init__(self, parent, prov_val): + self.result = None + flags = _decode_prov(prov_val) + + self._dlg = tk.Toplevel(parent) + self._dlg.title("Provisioning Permissions") + self._dlg.transient(parent) + self._dlg.grab_set() + self._dlg.resizable(False, False) + + main = ttk.Frame(self._dlg, padding=10) + main.pack(fill=tk.BOTH, expand=True) + + self._prov_vars = {} + for name, _ in PROV_FLAGS: + var = tk.BooleanVar(value=flags.get(name, False)) + ttk.Checkbutton(main, text=name, variable=var).pack( + anchor=tk.W, pady=2) + self._prov_vars[name] = var + + btn_frame = ttk.Frame(main) + btn_frame.pack(pady=(10, 0)) + ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack( + side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack( + side=tk.LEFT, padx=4) + + self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel) + self._dlg.wait_window() + + def _on_ok(self): + self.result = _encode_prov( + {name: var.get() for name, var in self._prov_vars.items()}) + self._dlg.destroy() + + def _on_cancel(self): + self._dlg.destroy() + + class _UserDialog: - """Modal dialog for adding or editing a user.""" + """Compact Edit/Add User dialog with >> buttons opening Permissions/Provisioning popups.""" def __init__(self, parent, service_comp, user_elem, on_done): self._service_comp = service_comp self._user_elem = user_elem self._on_done = on_done self._is_edit = user_elem is not None - self.result = None + + self._perm_val = _safe_int(_get_prop(user_elem, "perm")) if self._is_edit else 0 + self._prov_val = _safe_int(_get_prop(user_elem, "prov")) if self._is_edit else 0 self._dlg = tk.Toplevel(parent) self._dlg.title("Edit User" if self._is_edit else "Add User") @@ -152,81 +317,78 @@ class _UserDialog: main.pack(fill=tk.BOTH, expand=True) row = 0 - ttk.Label(main, text="Username:").grid(row=row, column=0, sticky=tk.W, pady=2) + ttk.Label(main, text="Username").grid(row=row, column=0, sticky=tk.W, pady=2) self._name_var = tk.StringVar( value=user_elem.get("name", "") if self._is_edit else "") - name_entry = ttk.Entry(main, textvariable=self._name_var, width=24) - name_entry.grid(row=row, column=1, columnspan=3, sticky=tk.EW, pady=2) + name_entry = ttk.Entry(main, textvariable=self._name_var, width=28) + name_entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW, pady=2) if self._is_edit: name_entry.config(state="disabled") row += 1 - ttk.Label(main, text="Password:").grid(row=row, column=0, sticky=tk.W, pady=2) + ttk.Label(main, text="Password").grid(row=row, column=0, sticky=tk.W, pady=2) self._pass_var = tk.StringVar() - ttk.Entry(main, textvariable=self._pass_var, show="*", width=24).grid( - row=row, column=1, columnspan=3, sticky=tk.EW, pady=2) + ttk.Entry(main, textvariable=self._pass_var, show="*", width=28).grid( + row=row, column=1, columnspan=2, sticky=tk.EW, pady=2) row += 1 - ttk.Label(main, text="Confirm:").grid(row=row, column=0, sticky=tk.W, pady=2) + ttk.Label(main, text="Confirm").grid(row=row, column=0, sticky=tk.W, pady=2) self._confirm_var = tk.StringVar() - ttk.Entry(main, textvariable=self._confirm_var, show="*", width=24).grid( - row=row, column=1, columnspan=3, sticky=tk.EW, pady=2) + ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid( + row=row, column=1, columnspan=2, sticky=tk.EW, pady=2) - # permissions + # permissions summary + >> button row += 1 ttk.Separator(main, orient=tk.HORIZONTAL).grid( - row=row, column=0, columnspan=4, sticky=tk.EW, pady=6) + row=row, column=0, columnspan=3, sticky=tk.EW, pady=6) row += 1 - ttk.Label(main, text="Group Permissions:").grid( - row=row, column=0, columnspan=4, sticky=tk.W, pady=2) + ttk.Label(main, text="Permissions").grid(row=row, column=0, sticky=tk.W, pady=2) + self._perm_label = tk.StringVar(value=_format_perm(self._perm_val)) + ttk.Label(main, textvariable=self._perm_label, + font=("TkDefaultFont", 8)).grid( + row=row, column=1, sticky=tk.W, pady=2) + ttk.Button(main, text="»", width=3, + command=self._open_permissions).grid( + row=row, column=2, sticky=tk.E, pady=2) - perm_groups = (_decode_perm(_get_prop(user_elem, "perm")) - if self._is_edit else [[False] * 7 for _ in range(4)]) - self._perm_vars = [] - for g in range(4): - row += 1 - ttk.Label(main, text=f"Group {g + 1}:").grid(row=row, column=0, sticky=tk.W) - gframe = ttk.Frame(main) - gframe.grid(row=row, column=1, columnspan=3, sticky=tk.W) - gvars = [] - for f_idx, label in enumerate(PERM_LABELS): - var = tk.BooleanVar(value=perm_groups[g][f_idx]) - ttk.Checkbutton(gframe, text=label, variable=var).pack( - side=tk.LEFT, padx=1) - gvars.append(var) - self._perm_vars.append(gvars) - - # provisioning + # provisioning summary + >> button row += 1 - ttk.Separator(main, orient=tk.HORIZONTAL).grid( - row=row, column=0, columnspan=4, sticky=tk.EW, pady=6) + ttk.Label(main, text="Provisioning Permissions").grid( + row=row, column=0, sticky=tk.W, pady=2) + self._prov_label = tk.StringVar(value=_format_prov(self._prov_val)) + ttk.Label(main, textvariable=self._prov_label, + font=("TkDefaultFont", 8)).grid( + row=row, column=1, sticky=tk.W, pady=2) + ttk.Button(main, text="»", width=3, + command=self._open_provisioning).grid( + row=row, column=2, sticky=tk.E, pady=2) - row += 1 - ttk.Label(main, text="Provisioning:").grid(row=row, column=0, sticky=tk.W, pady=2) - prov_dict = (_decode_prov(_get_prop(user_elem, "prov")) - if self._is_edit else {n: False for n, _ in PROV_FLAGS}) - prov_frame = ttk.Frame(main) - prov_frame.grid(row=row, column=1, columnspan=3, sticky=tk.W) - self._prov_vars = {} - for name, _ in PROV_FLAGS: - var = tk.BooleanVar(value=prov_dict.get(name, False)) - ttk.Checkbutton(prov_frame, text=name, variable=var).pack( - side=tk.LEFT, padx=4) - self._prov_vars[name] = var - - # buttons + # OK / Cancel row += 1 btn_frame = ttk.Frame(main) - btn_frame.grid(row=row, column=0, columnspan=4, pady=(10, 0)) - ttk.Button(btn_frame, text="OK", command=self._on_ok).pack(side=tk.LEFT, padx=4) - ttk.Button(btn_frame, text="Cancel", command=self._on_cancel).pack( + btn_frame.grid(row=row, column=0, columnspan=3, pady=(10, 0)) + ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack( + side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack( side=tk.LEFT, padx=4) self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel) name_entry.focus_set() self._dlg.wait_window() + def _open_permissions(self): + dlg = _PermissionsDialog(self._dlg, self._perm_val) + if dlg.result is not None: + self._perm_val = dlg.result + self._perm_label.set(_format_perm(self._perm_val)) + + def _open_provisioning(self): + dlg = _ProvisioningDialog(self._dlg, self._prov_val) + if dlg.result is not None: + self._prov_val = dlg.result + self._prov_label.set(_format_prov(self._prov_val)) + def _on_ok(self): username = self._name_var.get().strip() password = self._pass_var.get() @@ -243,32 +405,29 @@ class _UserDialog: messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg) return - # check duplicate name on add if not self._is_edit: for c in _get_users(self._service_comp): if c.get("name", "") == username: - messagebox.showwarning("Validation", f"User '{username}' already exists.", + messagebox.showwarning("Validation", + f"User '{username}' already exists.", parent=self._dlg) return - perm_groups = [[v.get() for v in gvars] for gvars in self._perm_vars] - prov_dict = {name: var.get() for name, var in self._prov_vars.items()} - if self._is_edit: - self._apply_edit(username, password, perm_groups, prov_dict) + self._apply_edit(username, password) else: - self._apply_add(username, password, perm_groups, prov_dict) + self._apply_add(username, password) self._dlg.destroy() if self._on_done: self._on_done() - def _apply_add(self, username, password, perm_groups, prov_dict): + def _apply_add(self, username, password): elem = ET.Element("comp", name=username, type="sys::User") cred = _hash_credentials(username, password) ET.SubElement(elem, "prop", name="cred", val=cred) - ET.SubElement(elem, "prop", name="perm", val=str(_encode_perm(perm_groups))) - ET.SubElement(elem, "prop", name="prov", val=str(_encode_prov(prov_dict))) + ET.SubElement(elem, "prop", name="perm", val=str(self._perm_val)) + ET.SubElement(elem, "prop", name="prov", val=str(self._prov_val)) index = len(list(self._service_comp)) cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user") @@ -276,16 +435,16 @@ class _UserDialog: undo.push(cmd) app_state.mark_dirty() - def _apply_edit(self, username, password, perm_groups, prov_dict): + def _apply_edit(self, username, password): user = self._user_elem changes = [] - new_perm = str(_encode_perm(perm_groups)) + new_perm = str(self._perm_val) old_perm = _get_prop(user, "perm") if new_perm != old_perm: changes.append((user, "perm", old_perm, new_perm)) - new_prov = str(_encode_prov(prov_dict)) + new_prov = str(self._prov_val) old_prov = _get_prop(user, "prov") if new_prov != old_prov: changes.append((user, "prov", old_prov, new_prov)) @@ -412,9 +571,6 @@ class UserServicePanel: self.on_change() def _on_add(self): - if app_state.live_mode: - app_state.set_status("Add user is not available in live mode", warn=True) - return _UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change) def _on_edit(self): @@ -423,9 +579,6 @@ class UserServicePanel: messagebox.showinfo("Edit User", "Select a user first.", parent=self.frame.winfo_toplevel()) return - if app_state.live_mode: - app_state.set_status("Edit user is not available in live mode", warn=True) - return _UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change) def _on_delete(self): @@ -434,9 +587,6 @@ class UserServicePanel: messagebox.showinfo("Delete User", "Select a user first.", parent=self.frame.winfo_toplevel()) return - if app_state.live_mode: - app_state.set_status("Delete user is not available in live mode", warn=True) - return name = user_elem.get("name", "?") if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",