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 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:57:49 +03:00
co-authored by Claude Opus 4.6
parent 43549046e5
commit d5ef8eea6f
3 changed files with 264 additions and 76 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os 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") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+38
View File
@@ -985,6 +985,42 @@ def on_tree_right_click(event):
tree_comp_context_menu.post(event.x_root, event.y_root) 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): def on_canvas_click(event):
canvas = app_state.canvas canvas = app_state.canvas
canvas.itemconfig("block_outline", outline="#7f9db9", width=2) canvas.itemconfig("block_outline", outline="#7f9db9", width=2)
@@ -2502,6 +2538,7 @@ tree.bind("<<TreeviewSelect>>", on_tree_select)
tree.bind("<ButtonPress-3>", on_tree_right_click) tree.bind("<ButtonPress-3>", on_tree_right_click)
tree.bind("<ButtonPress-2>", on_tree_right_click) tree.bind("<ButtonPress-2>", on_tree_right_click)
tree.bind("<Double-1>", on_tree_double_click)
left_pane.add(frame_tree, weight=3) left_pane.add(frame_tree, weight=3)
@@ -2729,6 +2766,7 @@ canvas.bind("<Configure>", lambda e: wiresheet.draw_grid())
canvas.bind("<ButtonPress-3>", on_canvas_right_click) canvas.bind("<ButtonPress-3>", on_canvas_right_click)
canvas.bind("<ButtonPress-2>", on_canvas_right_click) canvas.bind("<ButtonPress-2>", on_canvas_right_click)
canvas.bind("<Double-1>", on_canvas_double_click)
# Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead. # Wheel zoom. Windows and macOS report a delta; X11 sends button 4 and 5 instead.
canvas.bind("<Control-MouseWheel>", on_canvas_wheel_zoom) canvas.bind("<Control-MouseWheel>", on_canvas_wheel_zoom)
+225 -75
View File
@@ -99,6 +99,18 @@ def _get_prop(comp, name):
return prop.get("val") if prop is not None else None 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): def _get_users(service_comp):
"""Returns child comp elements, excluding uncached placeholders.""" """Returns child comp elements, excluding uncached placeholders."""
return [c for c in service_comp return [c for c in service_comp
@@ -124,23 +136,176 @@ def is_user_service(comp):
if comp is None: if comp is None:
return False return False
type_name = comp.get("type", "") type_name = comp.get("type", "")
if type_name.endswith("::UserService") or type_name == "UserService": if "UserService" in type_name:
return True return True
for child in comp: 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 True
return False 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: 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): def __init__(self, parent, service_comp, user_elem, on_done):
self._service_comp = service_comp self._service_comp = service_comp
self._user_elem = user_elem self._user_elem = user_elem
self._on_done = on_done self._on_done = on_done
self._is_edit = user_elem is not None 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 = tk.Toplevel(parent)
self._dlg.title("Edit User" if self._is_edit else "Add User") 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) main.pack(fill=tk.BOTH, expand=True)
row = 0 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( self._name_var = tk.StringVar(
value=user_elem.get("name", "") if self._is_edit else "") value=user_elem.get("name", "") if self._is_edit else "")
name_entry = ttk.Entry(main, textvariable=self._name_var, width=24) name_entry = ttk.Entry(main, textvariable=self._name_var, width=28)
name_entry.grid(row=row, column=1, columnspan=3, sticky=tk.EW, pady=2) name_entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
if self._is_edit: if self._is_edit:
name_entry.config(state="disabled") name_entry.config(state="disabled")
row += 1 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() self._pass_var = tk.StringVar()
ttk.Entry(main, textvariable=self._pass_var, show="*", width=24).grid( ttk.Entry(main, textvariable=self._pass_var, show="*", width=28).grid(
row=row, column=1, columnspan=3, sticky=tk.EW, pady=2) row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
row += 1 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() self._confirm_var = tk.StringVar()
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=24).grid( ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
row=row, column=1, columnspan=3, sticky=tk.EW, pady=2) row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
# permissions # permissions summary + >> button
row += 1 row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid( 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 row += 1
ttk.Label(main, text="Group Permissions:").grid( ttk.Label(main, text="Permissions").grid(row=row, column=0, sticky=tk.W, pady=2)
row=row, column=0, columnspan=4, 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")) # provisioning summary + >> button
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
row += 1 row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid( ttk.Label(main, text="Provisioning Permissions").grid(
row=row, column=0, columnspan=4, sticky=tk.EW, pady=6) 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 # OK / Cancel
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
row += 1 row += 1
btn_frame = ttk.Frame(main) btn_frame = ttk.Frame(main)
btn_frame.grid(row=row, column=0, columnspan=4, pady=(10, 0)) btn_frame.grid(row=row, column=0, columnspan=3, pady=(10, 0))
ttk.Button(btn_frame, text="OK", command=self._on_ok).pack(side=tk.LEFT, padx=4) ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
ttk.Button(btn_frame, text="Cancel", command=self._on_cancel).pack( side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4) side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel) self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
name_entry.focus_set() name_entry.focus_set()
self._dlg.wait_window() 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): def _on_ok(self):
username = self._name_var.get().strip() username = self._name_var.get().strip()
password = self._pass_var.get() password = self._pass_var.get()
@@ -243,32 +405,29 @@ class _UserDialog:
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg) messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
return return
# check duplicate name on add
if not self._is_edit: if not self._is_edit:
for c in _get_users(self._service_comp): for c in _get_users(self._service_comp):
if c.get("name", "") == username: if c.get("name", "") == username:
messagebox.showwarning("Validation", f"User '{username}' already exists.", messagebox.showwarning("Validation",
f"User '{username}' already exists.",
parent=self._dlg) parent=self._dlg)
return 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: if self._is_edit:
self._apply_edit(username, password, perm_groups, prov_dict) self._apply_edit(username, password)
else: else:
self._apply_add(username, password, perm_groups, prov_dict) self._apply_add(username, password)
self._dlg.destroy() self._dlg.destroy()
if self._on_done: if self._on_done:
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") elem = ET.Element("comp", name=username, type="sys::User")
cred = _hash_credentials(username, password) cred = _hash_credentials(username, password)
ET.SubElement(elem, "prop", name="cred", val=cred) 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="perm", val=str(self._perm_val))
ET.SubElement(elem, "prop", name="prov", val=str(_encode_prov(prov_dict))) ET.SubElement(elem, "prop", name="prov", val=str(self._prov_val))
index = len(list(self._service_comp)) index = len(list(self._service_comp))
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user") cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
@@ -276,16 +435,16 @@ class _UserDialog:
undo.push(cmd) undo.push(cmd)
app_state.mark_dirty() app_state.mark_dirty()
def _apply_edit(self, username, password, perm_groups, prov_dict): def _apply_edit(self, username, password):
user = self._user_elem user = self._user_elem
changes = [] changes = []
new_perm = str(_encode_perm(perm_groups)) new_perm = str(self._perm_val)
old_perm = _get_prop(user, "perm") old_perm = _get_prop(user, "perm")
if new_perm != old_perm: if new_perm != old_perm:
changes.append((user, "perm", old_perm, new_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") old_prov = _get_prop(user, "prov")
if new_prov != old_prov: if new_prov != old_prov:
changes.append((user, "prov", old_prov, new_prov)) changes.append((user, "prov", old_prov, new_prov))
@@ -412,9 +571,6 @@ class UserServicePanel:
self.on_change() self.on_change()
def _on_add(self): 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) _UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
def _on_edit(self): def _on_edit(self):
@@ -423,9 +579,6 @@ class UserServicePanel:
messagebox.showinfo("Edit User", "Select a user first.", messagebox.showinfo("Edit User", "Select a user first.",
parent=self.frame.winfo_toplevel()) parent=self.frame.winfo_toplevel())
return 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) _UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
def _on_delete(self): def _on_delete(self):
@@ -434,9 +587,6 @@ class UserServicePanel:
messagebox.showinfo("Delete User", "Select a user first.", messagebox.showinfo("Delete User", "Select a user first.",
parent=self.frame.winfo_toplevel()) parent=self.frame.winfo_toplevel())
return 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", "?") name = user_elem.get("name", "?")
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?", if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",