Merge pull request 'feat(create): remaining viewers — dependencies, jobs, macros, approvals' (#272) from feat/remaining-viewers into main
Some checks failed
Build and Test / build (push) Has been cancelled
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #272
This commit was merged in pull request #272.
This commit is contained in:
@@ -548,14 +548,450 @@ class SiloHistoryViewer(QtWidgets.QWidget):
|
|||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Approvals Viewer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_APPROVAL_STATUS_ICONS = {
|
||||||
|
"approved": ("\u2713", "#a6e3a1"), # ✓ green
|
||||||
|
"pending": ("\u25cb", "#cdd6f4"), # ○ text
|
||||||
|
"rejected": ("\u2717", "#f38ba8"), # ✗ red
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SiloApprovalsViewer(QtWidgets.QWidget):
|
||||||
|
"""Read-only ECO approval status from ``silo/approvals.json``."""
|
||||||
|
|
||||||
|
WINDOW_TITLE = "Silo \u2014 Approvals"
|
||||||
|
|
||||||
|
def __init__(self, obj, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||||
|
self._build_ui(obj.RawContent)
|
||||||
|
|
||||||
|
def _build_ui(self, raw_content):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_content)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
outer = QtWidgets.QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(16, 16, 16, 16)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
# Header: ECO ID + state badge
|
||||||
|
header = QtWidgets.QHBoxLayout()
|
||||||
|
eco_id = data.get("eco", "\u2014")
|
||||||
|
title = QtWidgets.QLabel(eco_id)
|
||||||
|
font = title.font()
|
||||||
|
font.setPointSize(font.pointSize() + 2)
|
||||||
|
font.setBold(True)
|
||||||
|
title.setFont(font)
|
||||||
|
header.addWidget(title)
|
||||||
|
|
||||||
|
state = data.get("state", "")
|
||||||
|
if state:
|
||||||
|
badge = QtWidgets.QLabel(state)
|
||||||
|
style = _BADGE_STYLES.get(
|
||||||
|
state.replace("pending_", ""),
|
||||||
|
"background: #45475a; color: #cdd6f4;",
|
||||||
|
)
|
||||||
|
badge.setStyleSheet(
|
||||||
|
f"QLabel {{ {style} border-radius: 4px; "
|
||||||
|
f"padding: 1px 6px; font-size: 11px; }}"
|
||||||
|
)
|
||||||
|
header.addWidget(badge)
|
||||||
|
|
||||||
|
header.addStretch()
|
||||||
|
outer.addLayout(header)
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
sep = QtWidgets.QFrame()
|
||||||
|
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||||
|
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||||
|
outer.addWidget(sep)
|
||||||
|
|
||||||
|
# Approver cards
|
||||||
|
approvers = data.get("approvers", [])
|
||||||
|
if not approvers:
|
||||||
|
outer.addWidget(QtWidgets.QLabel("No approval data available."))
|
||||||
|
else:
|
||||||
|
for approver in approvers:
|
||||||
|
outer.addWidget(self._make_approver_card(approver))
|
||||||
|
|
||||||
|
# Open in web UI button
|
||||||
|
silo_instance = ""
|
||||||
|
try:
|
||||||
|
import FreeCAD as _fc
|
||||||
|
|
||||||
|
doc = _fc.ActiveDocument
|
||||||
|
if doc:
|
||||||
|
manifest_obj = doc.getObject("SiloManifest")
|
||||||
|
if manifest_obj and manifest_obj.RawContent:
|
||||||
|
m = json.loads(manifest_obj.RawContent)
|
||||||
|
silo_instance = m.get("silo_instance", "") or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if silo_instance and eco_id != "\u2014":
|
||||||
|
link_btn = QtWidgets.QPushButton("Open in Silo Web UI \u2192")
|
||||||
|
link_btn.clicked.connect(
|
||||||
|
lambda checked=False, url=f"{silo_instance}/eco/{eco_id}": _open_url(
|
||||||
|
url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
outer.addWidget(link_btn)
|
||||||
|
|
||||||
|
outer.addStretch()
|
||||||
|
|
||||||
|
def _make_approver_card(self, approver):
|
||||||
|
card = QtWidgets.QWidget()
|
||||||
|
layout = QtWidgets.QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
layout.setSpacing(2)
|
||||||
|
|
||||||
|
top = QtWidgets.QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
|
|
||||||
|
status = approver.get("status", "pending")
|
||||||
|
icon_char, icon_color = _APPROVAL_STATUS_ICONS.get(
|
||||||
|
status, _APPROVAL_STATUS_ICONS["pending"]
|
||||||
|
)
|
||||||
|
icon_label = QtWidgets.QLabel(icon_char)
|
||||||
|
icon_label.setStyleSheet(f"color: {icon_color}; font-weight: bold;")
|
||||||
|
top.addWidget(icon_label)
|
||||||
|
|
||||||
|
user = approver.get("user", "")
|
||||||
|
top.addWidget(QtWidgets.QLabel(f"<b>{user}</b>"))
|
||||||
|
|
||||||
|
role = approver.get("role", "")
|
||||||
|
if role:
|
||||||
|
role_label = QtWidgets.QLabel(role)
|
||||||
|
role_label.setStyleSheet("color: #a6adc8;")
|
||||||
|
top.addWidget(role_label)
|
||||||
|
|
||||||
|
top.addWidget(QtWidgets.QLabel(status))
|
||||||
|
top.addStretch()
|
||||||
|
layout.addLayout(top)
|
||||||
|
|
||||||
|
ts = approver.get("timestamp")
|
||||||
|
if ts:
|
||||||
|
ts_label = QtWidgets.QLabel(_format_value("created_at", ts))
|
||||||
|
ts_label.setStyleSheet("color: #a6adc8; margin-left: 24px;")
|
||||||
|
layout.addWidget(ts_label)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def _open_url(url):
|
||||||
|
"""Open a URL in the system browser."""
|
||||||
|
from PySide.QtCore import QUrl
|
||||||
|
from PySide.QtGui import QDesktopServices
|
||||||
|
|
||||||
|
QDesktopServices.openUrl(QUrl(url))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dependency Table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SiloDependencyTable(QtWidgets.QWidget):
|
||||||
|
"""Table view of assembly dependencies from ``silo/dependencies.json``."""
|
||||||
|
|
||||||
|
WINDOW_TITLE = "Silo \u2014 Dependencies"
|
||||||
|
|
||||||
|
def __init__(self, obj, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||||
|
self._build_ui(obj.RawContent)
|
||||||
|
|
||||||
|
def _build_ui(self, raw_content):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_content)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
links = data.get("links", [])
|
||||||
|
|
||||||
|
outer = QtWidgets.QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(16, 16, 16, 16)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
title = QtWidgets.QLabel("Assembly Dependencies")
|
||||||
|
font = title.font()
|
||||||
|
font.setPointSize(font.pointSize() + 2)
|
||||||
|
font.setBold(True)
|
||||||
|
title.setFont(font)
|
||||||
|
outer.addWidget(title)
|
||||||
|
|
||||||
|
sep = QtWidgets.QFrame()
|
||||||
|
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||||
|
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||||
|
outer.addWidget(sep)
|
||||||
|
|
||||||
|
if not links:
|
||||||
|
outer.addWidget(QtWidgets.QLabel("No dependencies defined."))
|
||||||
|
outer.addStretch()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Table
|
||||||
|
from PySide.QtGui import QStandardItemModel, QStandardItem
|
||||||
|
|
||||||
|
table = QtWidgets.QTableView()
|
||||||
|
model = QStandardItemModel(len(links), 5)
|
||||||
|
model.setHorizontalHeaderLabels(["Label", "Part Number", "Rev", "Qty", "Local"])
|
||||||
|
|
||||||
|
for row, link in enumerate(links):
|
||||||
|
label_item = QStandardItem(link.get("label", ""))
|
||||||
|
label_item.setEditable(False)
|
||||||
|
model.setItem(row, 0, label_item)
|
||||||
|
|
||||||
|
pn_item = QStandardItem(link.get("part_number", ""))
|
||||||
|
pn_item.setEditable(False)
|
||||||
|
model.setItem(row, 1, pn_item)
|
||||||
|
|
||||||
|
rev_item = QStandardItem(str(link.get("revision", "")))
|
||||||
|
rev_item.setEditable(False)
|
||||||
|
model.setItem(row, 2, rev_item)
|
||||||
|
|
||||||
|
qty_item = QStandardItem(str(link.get("quantity", "")))
|
||||||
|
qty_item.setEditable(False)
|
||||||
|
model.setItem(row, 3, qty_item)
|
||||||
|
|
||||||
|
# Resolution: check if UUID is in any open document
|
||||||
|
resolved = _is_dependency_resolved(link.get("uuid", ""))
|
||||||
|
status_item = QStandardItem("\u2713" if resolved else "\u2717")
|
||||||
|
status_item.setEditable(False)
|
||||||
|
model.setItem(row, 4, status_item)
|
||||||
|
|
||||||
|
table.setModel(model)
|
||||||
|
table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
||||||
|
table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||||||
|
outer.addWidget(table, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dependency_resolved(uuid_str):
|
||||||
|
"""Check if a dependency UUID corresponds to a locally open document."""
|
||||||
|
if not uuid_str:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
for doc in FreeCAD.listDocuments().values():
|
||||||
|
manifest_obj = doc.getObject("SiloManifest")
|
||||||
|
if manifest_obj and manifest_obj.RawContent:
|
||||||
|
m = json.loads(manifest_obj.RawContent)
|
||||||
|
if m.get("part_uuid") == uuid_str:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job Viewer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SiloJobViewer(QtWidgets.QWidget):
|
||||||
|
"""YAML source viewer for ``silo/jobs/*.yaml`` files."""
|
||||||
|
|
||||||
|
WINDOW_TITLE = "Silo \u2014 Job"
|
||||||
|
|
||||||
|
def __init__(self, obj, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||||
|
self._obj = obj
|
||||||
|
self._build_ui(obj.RawContent, obj.Label)
|
||||||
|
|
||||||
|
def _build_ui(self, raw_content, label):
|
||||||
|
outer = QtWidgets.QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(16, 16, 16, 16)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QtWidgets.QHBoxLayout()
|
||||||
|
title = QtWidgets.QLabel(label)
|
||||||
|
font = title.font()
|
||||||
|
font.setPointSize(font.pointSize() + 2)
|
||||||
|
font.setBold(True)
|
||||||
|
title.setFont(font)
|
||||||
|
header.addWidget(title)
|
||||||
|
header.addStretch()
|
||||||
|
|
||||||
|
self._edit_btn = QtWidgets.QPushButton("Edit")
|
||||||
|
self._edit_btn.setCheckable(True)
|
||||||
|
self._edit_btn.toggled.connect(self._on_edit_toggled)
|
||||||
|
header.addWidget(self._edit_btn)
|
||||||
|
outer.addLayout(header)
|
||||||
|
|
||||||
|
sep = QtWidgets.QFrame()
|
||||||
|
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||||
|
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||||
|
outer.addWidget(sep)
|
||||||
|
|
||||||
|
# Source editor
|
||||||
|
self._editor = QtWidgets.QPlainTextEdit()
|
||||||
|
self._editor.setPlainText(raw_content)
|
||||||
|
self._editor.setReadOnly(True)
|
||||||
|
self._editor.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||||
|
mono = self._editor.font()
|
||||||
|
mono.setFamily("monospace")
|
||||||
|
self._editor.setFont(mono)
|
||||||
|
outer.addWidget(self._editor, 1)
|
||||||
|
|
||||||
|
# Button bar
|
||||||
|
btn_bar = QtWidgets.QHBoxLayout()
|
||||||
|
btn_bar.addStretch()
|
||||||
|
self._save_btn = QtWidgets.QPushButton("Save")
|
||||||
|
self._save_btn.setEnabled(False)
|
||||||
|
self._save_btn.clicked.connect(self._on_save)
|
||||||
|
btn_bar.addWidget(self._save_btn)
|
||||||
|
outer.addLayout(btn_bar)
|
||||||
|
|
||||||
|
self._editor.textChanged.connect(self._on_text_changed)
|
||||||
|
|
||||||
|
def _on_edit_toggled(self, checked):
|
||||||
|
self._editor.setReadOnly(not checked)
|
||||||
|
self._edit_btn.setText("Lock" if checked else "Edit")
|
||||||
|
|
||||||
|
def _on_text_changed(self):
|
||||||
|
if not self._editor.isReadOnly():
|
||||||
|
self._obj.Proxy.mark_dirty()
|
||||||
|
self._save_btn.setEnabled(True)
|
||||||
|
|
||||||
|
def _on_save(self):
|
||||||
|
self._obj.RawContent = self._editor.toPlainText()
|
||||||
|
self._obj.Proxy.clear_dirty()
|
||||||
|
self._save_btn.setEnabled(False)
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self._obj.Proxy.is_dirty():
|
||||||
|
reply = QtWidgets.QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Unsaved Changes",
|
||||||
|
"Job definition has unsaved changes. Discard?",
|
||||||
|
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
|
||||||
|
QtWidgets.QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
if reply == QtWidgets.QMessageBox.Cancel:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
self._obj.Proxy.clear_dirty()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Macro Editor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SiloMacroEditor(QtWidgets.QWidget):
|
||||||
|
"""Python source editor for ``silo/macros/*.py`` files."""
|
||||||
|
|
||||||
|
WINDOW_TITLE = "Silo \u2014 Macro"
|
||||||
|
|
||||||
|
def __init__(self, obj, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName(f"SiloViewer_{obj.Name}")
|
||||||
|
self._obj = obj
|
||||||
|
self._build_ui(obj.RawContent, obj.Label)
|
||||||
|
|
||||||
|
def _build_ui(self, raw_content, label):
|
||||||
|
outer = QtWidgets.QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(16, 16, 16, 16)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QtWidgets.QHBoxLayout()
|
||||||
|
title = QtWidgets.QLabel(label)
|
||||||
|
font = title.font()
|
||||||
|
font.setPointSize(font.pointSize() + 2)
|
||||||
|
font.setBold(True)
|
||||||
|
title.setFont(font)
|
||||||
|
header.addWidget(title)
|
||||||
|
header.addStretch()
|
||||||
|
outer.addLayout(header)
|
||||||
|
|
||||||
|
sep = QtWidgets.QFrame()
|
||||||
|
sep.setFrameShape(QtWidgets.QFrame.HLine)
|
||||||
|
sep.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||||
|
outer.addWidget(sep)
|
||||||
|
|
||||||
|
# Source editor
|
||||||
|
self._editor = QtWidgets.QPlainTextEdit()
|
||||||
|
self._editor.setPlainText(raw_content)
|
||||||
|
self._editor.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||||
|
mono = self._editor.font()
|
||||||
|
mono.setFamily("monospace")
|
||||||
|
self._editor.setFont(mono)
|
||||||
|
outer.addWidget(self._editor, 1)
|
||||||
|
|
||||||
|
self._editor.textChanged.connect(self._on_text_changed)
|
||||||
|
|
||||||
|
# Button bar
|
||||||
|
btn_bar = QtWidgets.QHBoxLayout()
|
||||||
|
btn_bar.addStretch()
|
||||||
|
run_btn = QtWidgets.QPushButton("Run Now")
|
||||||
|
run_btn.clicked.connect(self._on_run)
|
||||||
|
btn_bar.addWidget(run_btn)
|
||||||
|
self._save_btn = QtWidgets.QPushButton("Save")
|
||||||
|
self._save_btn.setEnabled(False)
|
||||||
|
self._save_btn.clicked.connect(self._on_save)
|
||||||
|
btn_bar.addWidget(self._save_btn)
|
||||||
|
outer.addLayout(btn_bar)
|
||||||
|
|
||||||
|
def _on_text_changed(self):
|
||||||
|
self._obj.Proxy.mark_dirty()
|
||||||
|
self._save_btn.setEnabled(True)
|
||||||
|
|
||||||
|
def _on_save(self):
|
||||||
|
self._obj.RawContent = self._editor.toPlainText()
|
||||||
|
self._obj.Proxy.clear_dirty()
|
||||||
|
self._save_btn.setEnabled(False)
|
||||||
|
|
||||||
|
def _on_run(self):
|
||||||
|
"""Execute the macro in FreeCAD's Python context."""
|
||||||
|
code = self._editor.toPlainText()
|
||||||
|
try:
|
||||||
|
FreeCAD.Console.PrintMessage(f"--- Running macro: {self._obj.Label} ---\n")
|
||||||
|
exec(code, {"__builtins__": __builtins__, "FreeCAD": FreeCAD})
|
||||||
|
except Exception as exc:
|
||||||
|
FreeCAD.Console.PrintError(f"Macro error: {exc}\n")
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self._obj.Proxy.is_dirty():
|
||||||
|
reply = QtWidgets.QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Unsaved Changes",
|
||||||
|
"Macro has unsaved changes. Discard?",
|
||||||
|
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
|
||||||
|
QtWidgets.QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
if reply == QtWidgets.QMessageBox.Cancel:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
self._obj.Proxy.clear_dirty()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Viewer factory
|
# Viewer factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Exact path → viewer class
|
||||||
_VIEWER_REGISTRY = {
|
_VIEWER_REGISTRY = {
|
||||||
"silo/manifest.json": SiloManifestViewer,
|
"silo/manifest.json": SiloManifestViewer,
|
||||||
"silo/metadata.json": SiloMetadataEditor,
|
"silo/metadata.json": SiloMetadataEditor,
|
||||||
"silo/history.json": SiloHistoryViewer,
|
"silo/history.json": SiloHistoryViewer,
|
||||||
|
"silo/approvals.json": SiloApprovalsViewer,
|
||||||
|
"silo/dependencies.json": SiloDependencyTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefix → viewer class (for subdirectory entries)
|
||||||
|
_VIEWER_PREFIX_REGISTRY = {
|
||||||
|
"silo/jobs/": SiloJobViewer,
|
||||||
|
"silo/macros/": SiloMacroEditor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -566,6 +1002,11 @@ def create_viewer_widget(obj):
|
|||||||
is registered for this node's SiloPath.
|
is registered for this node's SiloPath.
|
||||||
"""
|
"""
|
||||||
cls = _VIEWER_REGISTRY.get(obj.SiloPath)
|
cls = _VIEWER_REGISTRY.get(obj.SiloPath)
|
||||||
|
if cls is None:
|
||||||
|
for prefix, prefix_cls in _VIEWER_PREFIX_REGISTRY.items():
|
||||||
|
if obj.SiloPath.startswith(prefix):
|
||||||
|
cls = prefix_cls
|
||||||
|
break
|
||||||
if cls is None:
|
if cls is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user