In live mode the device is now written first and the document only takes what the device accepted. Every live_hooks failure records a reason, and warn_failure() shows it in a dialog and the status bar instead of failing silently. - Move, properties, OX page editor: rejected values are rolled back and listed - Add/remove link: no longer edits the DOM alone when an end can't be resolved - Add/delete component: report why the device refused - Tidy Layout: sends each new position to the device, reverts refusals, marks the document dirty, and pushes no undo step in live mode - User Manager: add (one Sox add carrying cred/perm/prov), edit and delete now go to the device - Binary Buf slots (e.g. User.cred) are sent as Buf bytes, not as Str text - Bump version to 0.0.0.136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
671 lines
24 KiB
Python
671 lines
24 KiB
Python
"""The User Manager: a table of sys::User components under a sys::UserService.
|
||
|
||
Like the other panels it takes the editor pane over from the canvas rather than
|
||
floating above it. `main` is what hides the canvas and puts this in its place;
|
||
this module knows only the frame it was handed.
|
||
|
||
Each sys::User component carries three properties:
|
||
cred — SHA-1 of "username:password" (20 bytes, base64 in the SAX)
|
||
perm — 32-bit int: four groups × 7 permission flags per group
|
||
prov — 8-bit byte: app/kits/svm provisioning flags
|
||
"""
|
||
|
||
import base64
|
||
import hashlib
|
||
import tkinter as tk
|
||
from tkinter import font as tkfont, messagebox, ttk
|
||
import xml.etree.ElementTree as ET
|
||
|
||
import app_state
|
||
import live_hooks
|
||
import sax_file
|
||
import undo
|
||
from sax_dom import set_prop_value
|
||
|
||
HEADER_BG = "#f2f2f2"
|
||
|
||
PERM_FLAGS = ["or", "ow", "oi", "ar", "aw", "ai", "ua"]
|
||
PERM_LABELS = "rwIRWIu"
|
||
|
||
PROV_FLAGS = [("app", 0x01), ("kits", 0x02), ("svm", 0x04)]
|
||
|
||
|
||
def _hash_credentials(username, password):
|
||
"""SHA-1 of 'username:password', the Sedona credential algorithm."""
|
||
return base64.b64encode(
|
||
hashlib.sha1(f"{username}:{password}".encode("utf-8")).digest()
|
||
).decode("ascii")
|
||
|
||
|
||
def _decode_perm(perm_val):
|
||
"""32-bit int -> list of four 7-element bool lists."""
|
||
try:
|
||
val = int(perm_val)
|
||
except (TypeError, ValueError):
|
||
val = 0
|
||
groups = []
|
||
for g in range(4):
|
||
byte = (val >> (g * 8)) & 0xFF
|
||
groups.append([(byte >> bit) & 1 == 1 for bit in range(7)])
|
||
return groups
|
||
|
||
|
||
def _encode_perm(groups):
|
||
"""List of four 7-element bool lists -> 32-bit int."""
|
||
val = 0
|
||
for g, flags in enumerate(groups):
|
||
byte = 0
|
||
for bit, on in enumerate(flags):
|
||
if on:
|
||
byte |= (1 << bit)
|
||
val |= (byte << (g * 8))
|
||
return val
|
||
|
||
|
||
def _format_perm(perm_val):
|
||
"""Human-readable: '1=rwIRWIu, 2=rwIRWIu, ...'"""
|
||
groups = _decode_perm(perm_val)
|
||
parts = []
|
||
for g, flags in enumerate(groups):
|
||
chars = "".join(PERM_LABELS[i] if on else "-" for i, on in enumerate(flags))
|
||
parts.append(f"{g + 1}={chars}")
|
||
return ", ".join(parts)
|
||
|
||
|
||
def _decode_prov(prov_val):
|
||
"""8-bit byte -> dict of flag name -> bool."""
|
||
try:
|
||
val = int(prov_val)
|
||
except (TypeError, ValueError):
|
||
val = 0
|
||
return {name: bool(val & mask) for name, mask in PROV_FLAGS}
|
||
|
||
|
||
def _encode_prov(flags_dict):
|
||
val = 0
|
||
for name, mask in PROV_FLAGS:
|
||
if flags_dict.get(name):
|
||
val |= mask
|
||
return val
|
||
|
||
|
||
def _format_prov(prov_val):
|
||
"""Human-readable: 'app,kits,svm' for set flags."""
|
||
flags = _decode_prov(prov_val)
|
||
active = [name for name, on in flags.items() if on]
|
||
return ",".join(active) if active else "(none)"
|
||
|
||
|
||
def _get_prop(comp, name):
|
||
prop = comp.find(f"prop[@name='{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
|
||
if c.tag == "comp" and c.get("name", "") != "..."]
|
||
|
||
|
||
def _resolve_comp(comp):
|
||
"""Re-locate a comp element in the current DOM by id (live mode rebuilds the tree)."""
|
||
root = app_state.xml_root_element
|
||
if root is None:
|
||
return comp
|
||
comp_id = comp.get("id")
|
||
if comp_id is None:
|
||
return comp
|
||
for elem in root.iter("comp"):
|
||
if elem.get("id") == comp_id:
|
||
return elem
|
||
return comp
|
||
|
||
|
||
def is_user_service(comp):
|
||
"""Whether a component is a sys::UserService (or looks like one in live mode)."""
|
||
if comp is None:
|
||
return False
|
||
type_name = comp.get("type", "")
|
||
if "UserService" in type_name:
|
||
return True
|
||
for child in comp:
|
||
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:
|
||
"""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._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")
|
||
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)
|
||
|
||
row = 0
|
||
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=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)
|
||
self._pass_var = tk.StringVar()
|
||
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)
|
||
self._confirm_var = tk.StringVar()
|
||
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
|
||
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||
|
||
# permissions summary + >> button
|
||
row += 1
|
||
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
|
||
row=row, column=0, columnspan=3, sticky=tk.EW, pady=6)
|
||
|
||
row += 1
|
||
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)
|
||
|
||
# provisioning summary + >> button
|
||
row += 1
|
||
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)
|
||
|
||
# OK / Cancel
|
||
row += 1
|
||
btn_frame = ttk.Frame(main)
|
||
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()
|
||
confirm = self._confirm_var.get()
|
||
|
||
if not username:
|
||
messagebox.showwarning("Validation", "Username is required.", parent=self._dlg)
|
||
return
|
||
if not self._is_edit and not password:
|
||
messagebox.showwarning("Validation", "Password is required for new users.",
|
||
parent=self._dlg)
|
||
return
|
||
if password and password != confirm:
|
||
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
|
||
return
|
||
|
||
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.",
|
||
parent=self._dlg)
|
||
return
|
||
|
||
if self._is_edit:
|
||
self._apply_edit(username, password)
|
||
else:
|
||
self._apply_add(username, password)
|
||
|
||
self._dlg.destroy()
|
||
if self._on_done:
|
||
self._on_done()
|
||
|
||
def _apply_add(self, username, password):
|
||
elem = ET.Element("comp", name=username, type="sys::User")
|
||
cred = _hash_credentials(username, password)
|
||
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
|
||
|
||
if app_state.live_mode:
|
||
# One Sox add carrying every config value, so the user never exists on the
|
||
# device without its password and rights.
|
||
try:
|
||
config = sax_file.build_config_values("sys::User", values)
|
||
except (ValueError, TypeError) as e:
|
||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
|
||
f"A value does not fit its slot: {e}")
|
||
return
|
||
new_id = live_hooks.live_add_component(
|
||
self._service_comp, "sys", "User", username, config)
|
||
if new_id is None:
|
||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
|
||
return
|
||
elem.set("id", str(new_id))
|
||
|
||
for name, val in values.items():
|
||
ET.SubElement(elem, "prop", name=name, val=val)
|
||
|
||
if app_state.live_mode:
|
||
self._service_comp.append(elem)
|
||
app_state.mark_dirty()
|
||
app_state.set_status(f"User '{username}' added to device.")
|
||
return
|
||
|
||
index = len(list(self._service_comp))
|
||
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
|
||
cmd.redo()
|
||
undo.push(cmd)
|
||
app_state.mark_dirty()
|
||
|
||
def _apply_edit(self, username, password):
|
||
user = self._user_elem
|
||
changes = []
|
||
|
||
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(self._prov_val)
|
||
old_prov = _get_prop(user, "prov")
|
||
if new_prov != old_prov:
|
||
changes.append((user, "prov", old_prov, new_prov))
|
||
|
||
if password:
|
||
new_cred = _hash_credentials(username, password)
|
||
old_cred = _get_prop(user, "cred")
|
||
if new_cred != old_cred:
|
||
changes.append((user, "cred", old_cred, new_cred))
|
||
|
||
if not changes:
|
||
return
|
||
|
||
if app_state.live_mode:
|
||
failures = []
|
||
for elem, prop, _old, new in changes:
|
||
if live_hooks.live_set_prop_value(elem, prop, new):
|
||
set_prop_value(elem, prop, new)
|
||
app_state.mark_dirty()
|
||
else:
|
||
failures.append(f"{prop}: {live_hooks.last_error}")
|
||
if failures:
|
||
live_hooks.warn_failure(
|
||
"Edit User",
|
||
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
|
||
"not reach the device and were not applied",
|
||
"\n".join(f" • {failure}" for failure in failures),
|
||
)
|
||
else:
|
||
app_state.set_status(f"User '{username}' updated on device.")
|
||
return
|
||
|
||
cmd = undo.SlotValueCommand(changes, label="edit user")
|
||
cmd.redo()
|
||
undo.push(cmd)
|
||
app_state.mark_dirty()
|
||
|
||
def _on_cancel(self):
|
||
self._dlg.destroy()
|
||
|
||
|
||
class UserServicePanel:
|
||
"""Table of users under a sys::UserService, with Add/Edit/Delete."""
|
||
|
||
def __init__(self, parent, comp, on_change=None, on_close=None):
|
||
self.comp = comp
|
||
self.on_change = on_change
|
||
self.on_close = on_close
|
||
self.dirty = False
|
||
self.frame = tk.Frame(parent)
|
||
|
||
self._build_header()
|
||
self._build_table()
|
||
self._build_footer()
|
||
self._refresh_table()
|
||
|
||
def _build_header(self):
|
||
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||
header.pack(fill=tk.X)
|
||
|
||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||
|
||
tk.Label(header, text="User Manager", font=name_font,
|
||
bg=HEADER_BG).pack(side=tk.LEFT)
|
||
tk.Label(header, text=f"— {self.comp.get('name', '?')}",
|
||
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||
padx=(8, 0))
|
||
self.count_var = tk.StringVar(value="")
|
||
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
|
||
bg=HEADER_BG).pack(side=tk.RIGHT)
|
||
|
||
def _build_table(self):
|
||
area = tk.Frame(self.frame)
|
||
area.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4, 0))
|
||
|
||
columns = ("username", "permissions", "provisioning")
|
||
self.table = ttk.Treeview(area, columns=columns, show="headings",
|
||
selectmode="browse")
|
||
self.table.heading("username", text="Username")
|
||
self.table.heading("permissions", text="Group Permissions")
|
||
self.table.heading("provisioning", text="Provisioning")
|
||
|
||
self.table.column("username", width=120, minwidth=80)
|
||
self.table.column("permissions", width=320, minwidth=200)
|
||
self.table.column("provisioning", width=120, minwidth=80)
|
||
|
||
vscroll = ttk.Scrollbar(area, orient=tk.VERTICAL, command=self.table.yview)
|
||
self.table.configure(yscrollcommand=vscroll.set)
|
||
|
||
self.table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
|
||
self.table.bind("<Double-1>", lambda _e: self._on_edit())
|
||
self._user_elements = {}
|
||
|
||
def _build_footer(self):
|
||
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||
|
||
ttk.Button(footer, text="Close", width=9,
|
||
command=self.close).pack(side=tk.RIGHT)
|
||
ttk.Button(footer, text="Delete User", width=12,
|
||
command=self._on_delete).pack(side=tk.RIGHT, padx=(0, 6))
|
||
ttk.Button(footer, text="Edit User", width=12,
|
||
command=self._on_edit).pack(side=tk.RIGHT, padx=(0, 6))
|
||
ttk.Button(footer, text="Add User", width=12,
|
||
command=self._on_add).pack(side=tk.RIGHT, padx=(0, 6))
|
||
|
||
def _refresh_table(self):
|
||
for item in self.table.get_children():
|
||
self.table.delete(item)
|
||
self._user_elements.clear()
|
||
|
||
self.comp = _resolve_comp(self.comp)
|
||
users = _get_users(self.comp)
|
||
for user_elem in users:
|
||
name = user_elem.get("name", "?")
|
||
perm = _get_prop(user_elem, "perm")
|
||
prov = _get_prop(user_elem, "prov")
|
||
|
||
iid = self.table.insert("", tk.END, values=(
|
||
name,
|
||
_format_perm(perm),
|
||
_format_prov(prov),
|
||
))
|
||
self._user_elements[iid] = user_elem
|
||
|
||
self.count_var.set(f"{len(users)} user{'' if len(users) == 1 else 's'}")
|
||
|
||
def refresh(self):
|
||
self._refresh_table()
|
||
|
||
def _selected_user(self):
|
||
sel = self.table.selection()
|
||
if not sel:
|
||
return None, None
|
||
return sel[0], self._user_elements.get(sel[0])
|
||
|
||
def _after_change(self):
|
||
self._refresh_table()
|
||
if self.on_change:
|
||
self.on_change()
|
||
|
||
def _on_add(self):
|
||
_UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
|
||
|
||
def _on_edit(self):
|
||
iid, user_elem = self._selected_user()
|
||
if user_elem is None:
|
||
messagebox.showinfo("Edit User", "Select a user first.",
|
||
parent=self.frame.winfo_toplevel())
|
||
return
|
||
_UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
|
||
|
||
def _on_delete(self):
|
||
iid, user_elem = self._selected_user()
|
||
if user_elem is None:
|
||
messagebox.showinfo("Delete User", "Select a user first.",
|
||
parent=self.frame.winfo_toplevel())
|
||
return
|
||
|
||
name = user_elem.get("name", "?")
|
||
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",
|
||
parent=self.frame.winfo_toplevel()):
|
||
return
|
||
|
||
if app_state.live_mode:
|
||
if not live_hooks.live_delete_component(user_elem):
|
||
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
||
return
|
||
self.comp.remove(user_elem)
|
||
app_state.mark_dirty()
|
||
app_state.set_status(f"User '{name}' deleted from device.")
|
||
self._after_change()
|
||
return
|
||
|
||
index = list(self.comp).index(user_elem)
|
||
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
|
||
cmd.undo()
|
||
undo.push(cmd)
|
||
app_state.mark_dirty()
|
||
self._after_change()
|
||
|
||
def close(self):
|
||
if self.on_close is not None:
|
||
self.on_close()
|
||
return True
|
||
|
||
def destroy(self):
|
||
if self.frame.winfo_exists():
|
||
self.frame.destroy()
|
||
|
||
|
||
def build_user_service_panel(parent, comp, on_change=None, on_close=None):
|
||
if comp is None or parent is None:
|
||
return None
|
||
return UserServicePanel(parent, comp, on_change=on_change, on_close=on_close)
|