feat(client): add SSE live updates, revision pull dialog, and auth dock support
- Add SiloEventListener class for SSE-based real-time notifications - Add SiloPullDialog for revision selection when pulling - Add conflict detection before pull (unsaved changes, stale revision, mtime) - Add progress callback to _download_file() for UI feedback - Integrate SSE listener into SiloAuthDockWidget with status indicator - Refresh activity panel on remote change events - Add top-level PySide.QtCore import for QThread/Signal usage
This commit is contained in:
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
from PySide import QtCore
|
||||
|
||||
# Preference group for Kindred Silo settings
|
||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||
@@ -383,19 +384,35 @@ class SiloClient:
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection error: {e.reason}")
|
||||
|
||||
def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool:
|
||||
"""Download a file from MinIO storage."""
|
||||
def _download_file(
|
||||
self,
|
||||
part_number: str,
|
||||
revision: int,
|
||||
dest_path: str,
|
||||
progress_callback=None,
|
||||
) -> bool:
|
||||
"""Download a file from MinIO storage.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callable(bytes_downloaded, total_bytes).
|
||||
total_bytes is -1 if the server did not send Content-Length.
|
||||
"""
|
||||
url = f"{self.base_url}/items/{part_number}/file/{revision}"
|
||||
req = urllib.request.Request(url, headers=_get_auth_headers(), method="GET")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||
total = int(resp.headers.get("Content-Length", -1))
|
||||
downloaded = 0
|
||||
with open(dest_path, "wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback is not None:
|
||||
progress_callback(downloaded, total)
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
@@ -1675,13 +1692,154 @@ class Silo_Commit:
|
||||
return FreeCAD.ActiveDocument is not None
|
||||
|
||||
|
||||
def _check_pull_conflicts(part_number, local_path, doc=None):
|
||||
"""Check for conflicts between local file and server state.
|
||||
|
||||
Returns a list of conflict description strings, or an empty list if clean.
|
||||
"""
|
||||
conflicts = []
|
||||
|
||||
# Check for unsaved changes in an open document
|
||||
if doc is not None and doc.IsModified():
|
||||
conflicts.append("Document has unsaved local changes.")
|
||||
|
||||
# Check local revision vs server latest
|
||||
if doc is not None:
|
||||
obj = get_tracked_object(doc)
|
||||
if obj and hasattr(obj, "SiloRevision"):
|
||||
local_rev = getattr(obj, "SiloRevision", 0)
|
||||
latest = _client.latest_file_revision(part_number)
|
||||
if latest and local_rev and latest["revision_number"] > local_rev:
|
||||
conflicts.append(
|
||||
f"Local file is at revision {local_rev}, "
|
||||
f"server has revision {latest['revision_number']}."
|
||||
)
|
||||
|
||||
# Check local file mtime vs server timestamp
|
||||
if local_path and local_path.exists():
|
||||
import datetime
|
||||
|
||||
local_mtime = datetime.datetime.fromtimestamp(
|
||||
local_path.stat().st_mtime, tz=datetime.timezone.utc
|
||||
)
|
||||
try:
|
||||
item = _client.get_item(part_number)
|
||||
server_updated = item.get("updated_at", "")
|
||||
if server_updated:
|
||||
# Parse ISO format timestamp
|
||||
server_dt = datetime.datetime.fromisoformat(
|
||||
server_updated.replace("Z", "+00:00")
|
||||
)
|
||||
if server_dt > local_mtime:
|
||||
conflicts.append("Server version is newer than local file.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
class SiloPullDialog:
|
||||
"""Dialog for selecting which revision to pull."""
|
||||
|
||||
def __init__(self, part_number, revisions, parent=None):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self._selected_revision = None
|
||||
|
||||
self._dialog = QtGui.QDialog(parent)
|
||||
self._dialog.setWindowTitle(f"Pull - {part_number}")
|
||||
self._dialog.setMinimumWidth(600)
|
||||
self._dialog.setMinimumHeight(350)
|
||||
|
||||
layout = QtGui.QVBoxLayout(self._dialog)
|
||||
|
||||
info = QtGui.QLabel(f"Select a revision to download for {part_number}:")
|
||||
layout.addWidget(info)
|
||||
|
||||
# Revision table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Rev", "Date", "Comment", "Status", "File"]
|
||||
)
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
header = self._table.horizontalHeader()
|
||||
header.setStretchLastSection(True)
|
||||
|
||||
# Populate rows
|
||||
file_revisions = []
|
||||
self._table.setRowCount(len(revisions))
|
||||
for i, rev in enumerate(revisions):
|
||||
rev_num = rev.get("revision_number", "")
|
||||
date = rev.get("created_at", "")[:16].replace("T", " ")
|
||||
comment = rev.get("comment", "")
|
||||
status = rev.get("status", "")
|
||||
has_file = "\u2713" if rev.get("file_key") else ""
|
||||
|
||||
self._table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev_num)))
|
||||
self._table.setItem(i, 1, QtGui.QTableWidgetItem(date))
|
||||
self._table.setItem(i, 2, QtGui.QTableWidgetItem(comment))
|
||||
self._table.setItem(i, 3, QtGui.QTableWidgetItem(status))
|
||||
file_item = QtGui.QTableWidgetItem(has_file)
|
||||
file_item.setTextAlignment(QtCore.Qt.AlignCenter)
|
||||
self._table.setItem(i, 4, file_item)
|
||||
|
||||
if rev.get("file_key"):
|
||||
file_revisions.append(i)
|
||||
|
||||
self._table.resizeColumnsToContents()
|
||||
layout.addWidget(self._table)
|
||||
|
||||
# Pre-select the latest revision with a file
|
||||
if file_revisions:
|
||||
self._table.selectRow(file_revisions[0])
|
||||
|
||||
# Store revision data for lookup
|
||||
self._revisions = revisions
|
||||
|
||||
# Buttons
|
||||
btn_layout = QtGui.QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
download_btn = QtGui.QPushButton("Download")
|
||||
cancel_btn = QtGui.QPushButton("Cancel")
|
||||
download_btn.clicked.connect(self._on_download)
|
||||
cancel_btn.clicked.connect(self._dialog.reject)
|
||||
self._table.doubleClicked.connect(self._on_download)
|
||||
btn_layout.addWidget(download_btn)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _on_download(self):
|
||||
row = self._table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
rev = self._revisions[row]
|
||||
if not rev.get("file_key"):
|
||||
from PySide import QtGui
|
||||
|
||||
QtGui.QMessageBox.information(
|
||||
self._dialog, "Pull", "Selected revision has no file attached."
|
||||
)
|
||||
return
|
||||
self._selected_revision = rev
|
||||
self._dialog.accept()
|
||||
|
||||
def exec_(self):
|
||||
if self._dialog.exec_() == 1: # QDialog.Accepted
|
||||
return self._selected_revision
|
||||
return None
|
||||
|
||||
|
||||
class Silo_Pull:
|
||||
"""Download from MinIO / sync from database."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Pull",
|
||||
"ToolTip": "Download latest from MinIO or create from database",
|
||||
"ToolTip": "Download from MinIO with revision selection",
|
||||
"Pixmap": _icon("pull"),
|
||||
}
|
||||
|
||||
@@ -1690,6 +1848,7 @@ class Silo_Pull:
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
part_number = None
|
||||
obj = None
|
||||
|
||||
if doc:
|
||||
obj = get_tracked_object(doc)
|
||||
@@ -1702,43 +1861,25 @@ class Silo_Pull:
|
||||
return
|
||||
part_number = part_number.strip().upper()
|
||||
|
||||
# Check if local file exists
|
||||
# Fetch revisions from server
|
||||
try:
|
||||
revisions = _client.get_revisions(part_number)
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(None, "Pull", f"Cannot reach server: {e}")
|
||||
return
|
||||
|
||||
existing_local = find_file_by_part_number(part_number)
|
||||
|
||||
# Check if file exists in MinIO
|
||||
has_file, rev_num = _client.has_file(part_number)
|
||||
# If no revisions have files, fall back to create-from-database
|
||||
has_any_file = any(r.get("file_key") for r in revisions)
|
||||
|
||||
if has_file:
|
||||
# File exists in MinIO
|
||||
if not has_any_file:
|
||||
if existing_local:
|
||||
# Local file exists - ask before overwriting
|
||||
reply = QtGui.QMessageBox.question(
|
||||
None,
|
||||
"Pull",
|
||||
f"Download revision {rev_num} and overwrite local file?",
|
||||
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||
)
|
||||
if reply != QtGui.QMessageBox.Yes:
|
||||
return
|
||||
|
||||
# Download from MinIO (creates local file automatically)
|
||||
downloaded = _sync.download_file(part_number)
|
||||
if downloaded:
|
||||
FreeCAD.Console.PrintMessage(f"Downloaded: {downloaded}\n")
|
||||
# Automatically open the downloaded file
|
||||
FreeCAD.openDocument(str(downloaded))
|
||||
else:
|
||||
QtGui.QMessageBox.warning(None, "Pull", "Download failed")
|
||||
else:
|
||||
# No file in MinIO - create from database
|
||||
if existing_local:
|
||||
# Local file already exists, just open it
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Opening existing local file: {existing_local}\n"
|
||||
)
|
||||
FreeCAD.openDocument(str(existing_local))
|
||||
else:
|
||||
# No local file and no MinIO file - create new from DB
|
||||
try:
|
||||
item = _client.get_item(part_number)
|
||||
new_doc = _sync.create_document_for_item(item, save=True)
|
||||
@@ -1748,10 +1889,93 @@ class Silo_Pull:
|
||||
)
|
||||
else:
|
||||
QtGui.QMessageBox.warning(
|
||||
None, "Pull", f"Failed to create document for {part_number}"
|
||||
None,
|
||||
"Pull",
|
||||
f"Failed to create document for {part_number}",
|
||||
)
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(None, "Pull", f"Failed: {e}")
|
||||
return
|
||||
|
||||
# Conflict detection
|
||||
conflicts = _check_pull_conflicts(part_number, existing_local, doc)
|
||||
if conflicts:
|
||||
detail = "\n".join(f" - {c}" for c in conflicts)
|
||||
reply = QtGui.QMessageBox.warning(
|
||||
None,
|
||||
"Pull - Conflicts Detected",
|
||||
f"Potential conflicts found:\n{detail}\n\n"
|
||||
"Download anyway and overwrite local file?",
|
||||
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||
)
|
||||
if reply != QtGui.QMessageBox.Yes:
|
||||
return
|
||||
|
||||
# Show revision selection dialog
|
||||
dlg = SiloPullDialog(part_number, revisions)
|
||||
selected = dlg.exec_()
|
||||
if selected is None:
|
||||
return
|
||||
|
||||
rev_num = selected["revision_number"]
|
||||
|
||||
# Determine destination path
|
||||
try:
|
||||
item = _client.get_item(part_number)
|
||||
except Exception:
|
||||
item = {}
|
||||
dest_path = get_cad_file_path(part_number, item.get("description", ""))
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download with progress
|
||||
progress = QtGui.QProgressDialog(
|
||||
f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100
|
||||
)
|
||||
progress.setWindowModality(2) # Qt.WindowModal
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
|
||||
def on_progress(downloaded, total):
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
if total > 0:
|
||||
pct = int(downloaded * 100 / total)
|
||||
progress.setValue(min(pct, 99))
|
||||
else:
|
||||
# Indeterminate - pulse between 0-90
|
||||
progress.setValue(min(int(downloaded / 1024) % 90, 89))
|
||||
QtGui.QApplication.processEvents()
|
||||
|
||||
try:
|
||||
ok = _client._download_file(
|
||||
part_number, rev_num, str(dest_path), progress_callback=on_progress
|
||||
)
|
||||
except Exception as e:
|
||||
progress.close()
|
||||
QtGui.QMessageBox.warning(None, "Pull", f"Download failed: {e}")
|
||||
return
|
||||
|
||||
progress.setValue(100)
|
||||
progress.close()
|
||||
|
||||
if not ok:
|
||||
QtGui.QMessageBox.warning(None, "Pull", "Download failed.")
|
||||
return
|
||||
|
||||
FreeCAD.Console.PrintMessage(f"Pulled revision {rev_num} of {part_number}\n")
|
||||
|
||||
# Close existing document if open, then reopen
|
||||
if doc and doc.FileName == str(dest_path):
|
||||
FreeCAD.closeDocument(doc.Name)
|
||||
FreeCAD.openDocument(str(dest_path))
|
||||
|
||||
# Update SiloRevision property on the tracked object
|
||||
new_doc = FreeCAD.ActiveDocument
|
||||
if new_doc:
|
||||
new_obj = get_tracked_object(new_doc)
|
||||
if new_obj and hasattr(new_obj, "SiloRevision"):
|
||||
new_obj.SiloRevision = rev_num
|
||||
new_doc.save()
|
||||
|
||||
def IsActive(self):
|
||||
return True
|
||||
@@ -2914,6 +3138,139 @@ class Silo_ToggleMode:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE live-update listener
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SiloEventListener(QtCore.QThread):
|
||||
"""Background thread that listens to Server-Sent Events from the Silo API.
|
||||
|
||||
Emits Qt signals when items are updated or new revisions are created.
|
||||
Degrades gracefully if the server does not support the ``/api/events``
|
||||
endpoint.
|
||||
"""
|
||||
|
||||
item_updated = QtCore.Signal(str) # part_number
|
||||
revision_created = QtCore.Signal(str, int) # part_number, revision
|
||||
connection_status = QtCore.Signal(
|
||||
str
|
||||
) # "connected" / "disconnected" / "unsupported"
|
||||
|
||||
_MAX_FAST_RETRIES = 3
|
||||
_FAST_RETRY_SECS = 5
|
||||
_SLOW_RETRY_SECS = 30
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._stop_flag = False
|
||||
self._response = None
|
||||
|
||||
# -- public API ---------------------------------------------------------
|
||||
|
||||
def stop(self):
|
||||
self._stop_flag = True
|
||||
# Close the socket so readline() unblocks immediately
|
||||
try:
|
||||
if self._response is not None:
|
||||
self._response.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.wait(5000)
|
||||
|
||||
# -- thread entry -------------------------------------------------------
|
||||
|
||||
def run(self):
|
||||
retries = 0
|
||||
while not self._stop_flag:
|
||||
try:
|
||||
self._listen()
|
||||
# _listen returns normally only on clean EOF / stop
|
||||
if self._stop_flag:
|
||||
return
|
||||
retries += 1
|
||||
except _SSEUnsupported:
|
||||
self.connection_status.emit("unsupported")
|
||||
return
|
||||
except Exception:
|
||||
retries += 1
|
||||
|
||||
self.connection_status.emit("disconnected")
|
||||
|
||||
if retries <= self._MAX_FAST_RETRIES:
|
||||
delay = self._FAST_RETRY_SECS
|
||||
else:
|
||||
delay = self._SLOW_RETRY_SECS
|
||||
|
||||
# Interruptible sleep
|
||||
for _ in range(delay):
|
||||
if self._stop_flag:
|
||||
return
|
||||
self.msleep(1000)
|
||||
|
||||
# -- SSE stream reader --------------------------------------------------
|
||||
|
||||
def _listen(self):
|
||||
url = f"{_get_api_url().rstrip('/')}/api/events"
|
||||
headers = {"Accept": "text/event-stream"}
|
||||
headers.update(_get_auth_headers())
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
|
||||
try:
|
||||
self._response = urllib.request.urlopen(
|
||||
req, context=_get_ssl_context(), timeout=90
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (404, 501):
|
||||
raise _SSEUnsupported()
|
||||
raise
|
||||
except urllib.error.URLError:
|
||||
raise
|
||||
|
||||
self.connection_status.emit("connected")
|
||||
|
||||
event_type = ""
|
||||
data_buf = ""
|
||||
|
||||
for raw_line in self._response:
|
||||
if self._stop_flag:
|
||||
return
|
||||
|
||||
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
|
||||
if line == "":
|
||||
# Blank line = dispatch event
|
||||
if data_buf:
|
||||
self._dispatch(event_type or "message", data_buf.strip())
|
||||
event_type = ""
|
||||
data_buf = ""
|
||||
elif line.startswith("event:"):
|
||||
event_type = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_buf += line[5:].strip() + "\n"
|
||||
# Ignore comments (lines starting with ':') and other fields
|
||||
|
||||
def _dispatch(self, event_type, data):
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
|
||||
pn = payload.get("part_number", "")
|
||||
if not pn:
|
||||
return
|
||||
|
||||
if event_type in ("item_updated", "message"):
|
||||
self.item_updated.emit(pn)
|
||||
elif event_type == "revision_created":
|
||||
rev = payload.get("revision", 0)
|
||||
self.revision_created.emit(pn, int(rev))
|
||||
|
||||
|
||||
class _SSEUnsupported(Exception):
|
||||
"""Raised when the server does not support the SSE endpoint."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth dock widget
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2926,6 +3283,7 @@ class SiloAuthDockWidget:
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self.widget = QtGui.QWidget()
|
||||
self._event_listener = None
|
||||
self._build_ui()
|
||||
self._refresh_status()
|
||||
|
||||
@@ -2993,6 +3351,20 @@ class SiloAuthDockWidget:
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# Live updates row
|
||||
sse_row = QtGui.QHBoxLayout()
|
||||
sse_row.setSpacing(6)
|
||||
sse_lbl = QtGui.QLabel("Live:")
|
||||
sse_lbl.setStyleSheet("color: #888;")
|
||||
self._sse_label = QtGui.QLabel("")
|
||||
self._sse_label.setStyleSheet("font-size: 11px;")
|
||||
sse_row.addWidget(sse_lbl)
|
||||
sse_row.addWidget(self._sse_label)
|
||||
sse_row.addStretch()
|
||||
layout.addLayout(sse_row)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# Buttons
|
||||
btn_row = QtGui.QHBoxLayout()
|
||||
btn_row.setSpacing(6)
|
||||
@@ -3080,6 +3452,83 @@ class SiloAuthDockWidget:
|
||||
self._login_btn.setText("Login")
|
||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||
|
||||
# Manage SSE listener based on auth state
|
||||
self._sync_event_listener(authed)
|
||||
|
||||
# -- SSE listener management --------------------------------------------
|
||||
|
||||
def _sync_event_listener(self, authed):
|
||||
"""Start or stop the SSE listener depending on authentication state."""
|
||||
if authed:
|
||||
if self._event_listener is None or not self._event_listener.isRunning():
|
||||
self._event_listener = SiloEventListener()
|
||||
self._event_listener.item_updated.connect(self._on_remote_change)
|
||||
self._event_listener.revision_created.connect(self._on_remote_revision)
|
||||
self._event_listener.connection_status.connect(self._on_sse_status)
|
||||
self._event_listener.start()
|
||||
else:
|
||||
if self._event_listener is not None and self._event_listener.isRunning():
|
||||
self._event_listener.stop()
|
||||
self._sse_label.setText("")
|
||||
|
||||
def _on_sse_status(self, status):
|
||||
if status == "connected":
|
||||
self._sse_label.setText("Listening")
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;")
|
||||
elif status == "disconnected":
|
||||
self._sse_label.setText("Reconnecting...")
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
|
||||
elif status == "unsupported":
|
||||
self._sse_label.setText("Not available")
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
|
||||
|
||||
def _on_remote_change(self, part_number):
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Part {part_number} updated on server\n")
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is not None:
|
||||
mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000)
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _on_remote_revision(self, part_number, revision):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: New revision {revision} for {part_number}\n"
|
||||
)
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is not None:
|
||||
mw.statusBar().showMessage(
|
||||
f"Silo: {part_number} rev {revision} available", 5000
|
||||
)
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _refresh_activity_panel(self):
|
||||
"""Refresh the Database Activity panel if it exists."""
|
||||
from PySide import QtWidgets
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity")
|
||||
if panel is None:
|
||||
return
|
||||
activity_list = panel.findChild(QtWidgets.QListWidget)
|
||||
if activity_list is None:
|
||||
return
|
||||
activity_list.clear()
|
||||
try:
|
||||
items = _client.list_items()
|
||||
if isinstance(items, list):
|
||||
for item in items[:20]:
|
||||
pn = item.get("part_number", "")
|
||||
desc = item.get("description", "")
|
||||
updated = item.get("updated_at", "")
|
||||
if updated:
|
||||
updated = updated[:10]
|
||||
activity_list.addItem(f"{pn} - {desc} - {updated}")
|
||||
if activity_list.count() == 0:
|
||||
activity_list.addItem("(No items in database)")
|
||||
except Exception:
|
||||
activity_list.addItem("(Unable to refresh activity)")
|
||||
|
||||
# -- Actions ------------------------------------------------------------
|
||||
|
||||
def _on_login_clicked(self):
|
||||
|
||||
Reference in New Issue
Block a user