Created
February 24, 2023 11:17
-
-
Save rfletchr/a178f6545ce59c267fdef2c19cc0ebba to your computer and use it in GitHub Desktop.
Simple Validation Framework
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import enum | |
import time | |
import textwrap | |
import qtawesome | |
from PySide2 import QtCore, QtWidgets, QtGui | |
__ICONS = {} | |
def get_icons(): | |
global __ICONS | |
if not __ICONS: | |
__ICONS = { | |
ValidationStatus.pending: qtawesome.icon("fa5s.question-circle", color="grey"), | |
ValidationStatus.working: qtawesome.icon("fa5s.play-circle", color="black"), | |
ValidationStatus.success: qtawesome.icon("fa5s.check-circle", color="green"), | |
ValidationStatus.error: qtawesome.icon("fa5s.exclamation-circle", color="red"), | |
ValidationStatus.failed: qtawesome.icon("fa5s.times-circle", color="red") | |
} | |
return __ICONS | |
class ValidationStatus(enum.Enum): | |
pending = 1 | |
working = 2 | |
success = 3 | |
error = 4 | |
failed = 5 | |
class BaseItem(QtGui.QStandardItem): | |
progressRole = QtCore.Qt.UserRole + 1 | |
statusRole = QtCore.Qt.UserRole + 2 | |
def __init__(self, label): | |
super().__init__(label) | |
self.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) | |
self._icons = get_icons() | |
def setProgress(self, progress): | |
self.setData(progress, self.progressRole) | |
def setStatus(self, status: ValidationStatus): | |
self.setIcon(self._icons[status]) | |
self.setData(status, self.statusRole) | |
def execute(self, data): | |
raise NotImplementedError | |
def data(self, role=QtCore.Qt.DisplayRole): | |
status = super().data(self.statusRole) | |
if role == QtCore.Qt.BackgroundRole: | |
if status == ValidationStatus.pending: | |
return super().data(role) | |
elif status == ValidationStatus.working: | |
return QtGui.QBrush(QtGui.QColor(255, 255, 0, 10)) | |
elif status == ValidationStatus.success: | |
return QtGui.QBrush(QtGui.QColor(0, 255, 0, 10)) | |
elif status == ValidationStatus.error: | |
return QtGui.QBrush(QtGui.QColor(255, 0, 0, 10)) | |
elif status == ValidationStatus.failed: | |
return QtGui.QBrush(QtGui.QColor(255, 0, 0, 10)) | |
elif role == QtCore.Qt.DecorationRole: | |
if status == ValidationStatus.pending: | |
return self._icons[ValidationStatus.pending] | |
elif status == ValidationStatus.working: | |
return self._icons[ValidationStatus.working] | |
elif status == ValidationStatus.success: | |
return self._icons[ValidationStatus.success] | |
elif status == ValidationStatus.error: | |
return self._icons[ValidationStatus.error] | |
elif status == ValidationStatus.failed: | |
return self._icons[ValidationStatus.failed] | |
return super().data(role) | |
class CategoryItem(BaseItem): | |
def execute(self, data): | |
self.setProgress(0) | |
self.setStatus(ValidationStatus.working) | |
results = [] | |
for row in range(self.rowCount()): | |
item = self.child(row) | |
results.append(item.execute(data)) | |
self.setProgress((row + 1) / self.rowCount() * 100) | |
if all(results): | |
self.setStatus(ValidationStatus.success) | |
return True | |
else: | |
self.setStatus(ValidationStatus.failed) | |
return False | |
class ValidationItemBase(BaseItem): | |
label = "Unset" | |
description = "Unset" | |
category = "Unset" | |
def __init__(self): | |
super().__init__(self.label) | |
self.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) | |
def setProgress(self, progress): | |
self.setData(progress, self.progressRole) | |
def setStatus(self, status: ValidationStatus): | |
self.setIcon(self._icons[status]) | |
self.setData(status, self.statusRole) | |
def execute(self, data): | |
self.setStatus(ValidationStatus.working) | |
try: | |
result = self.validate(data) | |
if result: | |
self.setStatus(ValidationStatus.success) | |
return True | |
else: | |
self.setStatus(ValidationStatus.failed) | |
return False | |
except Exception as e: | |
self.setStatus(ValidationStatus.error) | |
raise e | |
def validate(self, data): | |
raise NotImplementedError | |
class ValidationModel(QtGui.QStandardItemModel): | |
progress = QtCore.Signal(str, int, int) | |
def __init__(self, parent=None): | |
super().__init__(parent) | |
self._root_nodes = {} | |
self._categories = {} | |
self._icons = get_icons() | |
def addValidationItem(self, item: ValidationItemBase): | |
if item.category not in self._root_nodes: | |
ns_item = self._root_nodes[item.category] = CategoryItem(item.category) | |
self.invisibleRootItem().appendRow(ns_item) | |
self._categories.setdefault(item.category, []).append(item) | |
self._root_nodes[item.category].appendRow(item) | |
def execute(self, data): | |
result = [] | |
for index, category in enumerate(self._root_nodes.values()): | |
self.progress.emit(f"Validating: {category.text()}", index, len(self._root_nodes)) | |
result.append(category.execute(data)) | |
if all(result): | |
self.progress.emit("Success 😊", 1, 1) | |
return True | |
else: | |
self.progress.emit("Failed 😢", 1, 1) | |
return False | |
def clear(self) -> None: | |
self._root_nodes = {} | |
self._categories = {} | |
super().clear() | |
class ValidationController(QtCore.QObject): | |
def __init__(self, parent=None): | |
super().__init__(parent) | |
self._model = ValidationModel() | |
self._view = ValidationView() | |
self._view.setModel(self._model) | |
self._model.progress.connect(self._view.setProgress) | |
self._view.itemClicked.connect(self.onItemClicked) | |
self._view.validateClicked.connect(self.execute) | |
def populate_model(self): | |
raise NotImplementedError | |
def collect_data(self): | |
raise NotImplementedError | |
def execute(self): | |
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) | |
self._model.clear() | |
self.populate_model() | |
QtWidgets.QApplication.processEvents() | |
try: | |
data = self.collect_data() | |
self._model.execute(data) | |
except Exception as e: | |
QtWidgets.QMessageBox.critical(self._view, "Error", str(e)) | |
finally: | |
QtWidgets.QApplication.restoreOverrideCursor() | |
def onItemClicked(self, index): | |
item = self._model.itemFromIndex(index) | |
if isinstance(item, ValidationItemBase): | |
self._view.setDescription(item.description) | |
else: | |
self._view.setDescription("") | |
def show(self, parent=None): | |
# center the widget in the screen | |
self._view.setParent(parent) | |
self._view.show() | |
class ValidationDelegate(QtWidgets.QStyledItemDelegate): | |
def paint(self, painter, option, index): | |
# select the background color based on the status, respecting the selection state | |
if option.state & QtWidgets.QStyle.State_Selected: | |
brush = option.palette.highlight() | |
else: | |
brush = index.data(QtCore.Qt.BackgroundRole) or option.palette.base() | |
painter.setPen(QtCore.Qt.NoPen) | |
painter.setBrush(brush) | |
painter.drawRect(option.rect) | |
painter.setPen(option.palette.text().color()) | |
# render the icon on the left hand side | |
icon = index.data(QtCore.Qt.DecorationRole) | |
# create rect with width and height of 0.9 * the height of the rect | |
icon_rect = QtCore.QRect(option.rect) | |
icon_rect.setWidth(icon_rect.height() * 0.6) | |
icon_rect.setHeight(icon_rect.height() * 0.6) | |
# move the rect to the right of the option rect | |
icon_rect.moveCenter(option.rect.center()) | |
icon_rect.moveLeft(option.rect.left()) | |
progress_rect = QtCore.QRect(icon_rect) | |
progress_rect.moveRight(option.rect.right() - 2) | |
if index.data(ValidationItemBase.statusRole) == ValidationStatus.working: | |
progress = index.data(ValidationItemBase.progressRole) | |
progress_bar = QtWidgets.QStyleOptionProgressBar() | |
progress_bar.rect = progress_rect | |
progress_bar.minimum = 0 | |
progress_bar.maximum = 100 if progress is not None else 0 | |
progress_bar.progress = progress if progress is not None else 0 | |
progress_bar.textVisible = False | |
QtWidgets.QApplication.style().drawControl(QtWidgets.QStyle.CE_ProgressBar, progress_bar, painter) | |
if icon is not None: | |
icon.paint(painter, icon_rect) | |
# create a left aligned rect for the text with the width of the rect minus the width of the icon rect | |
# with a margin of 5 pixels | |
text_rect = QtCore.QRect(option.rect) | |
text_rect.setLeft(icon_rect.right() + 5) | |
text_rect.setRight(progress_rect.left() - 2) | |
# render the text left aligned | |
painter.drawText(text_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, index.data(QtCore.Qt.DisplayRole)) | |
def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex) -> QtCore.QSize: | |
base = super().sizeHint(option, index) | |
base.setHeight(32) | |
return base | |
class ValidationTree(QtWidgets.QTreeView): | |
def __init__(self, parent=None): | |
super().__init__(parent) | |
self.setAlternatingRowColors(True) | |
self.setItemDelegate(ValidationDelegate(self)) | |
self.header().hide() | |
def setModel(self, model: ValidationModel): | |
super().setModel(model) | |
model.dataChanged.connect(self.onDataChanged) | |
def onDataChanged(self, *args, **kwargs): | |
QtWidgets.QApplication.processEvents() | |
self.expandAll() | |
class ValidationView(QtWidgets.QWidget): | |
itemClicked = QtCore.Signal(QtCore.QModelIndex) | |
validateClicked = QtCore.Signal() | |
acceptClicked = QtCore.Signal() | |
def __init__(self, parent=None): | |
super().__init__(parent=parent) | |
self.setStyleSheet("QWidget { font-family: 'Noto', 'Noto Emoji'; font-size: 20px }") | |
self._validation_tree = ValidationTree() | |
self._description_view = QtWidgets.QTextEdit() | |
self._description_view.setReadOnly(True) | |
self._progress_bar = QtWidgets.QProgressBar() | |
self._validate_button = QtWidgets.QPushButton("Validate") | |
self._accept_button = QtWidgets.QPushButton("Accept") | |
inner_layout = QtWidgets.QHBoxLayout() | |
inner_layout.addWidget(self._validation_tree, 3) | |
inner_layout.addWidget(self._description_view, 7) | |
button_layout = QtWidgets.QHBoxLayout() | |
button_layout.addStretch(100) | |
button_layout.addWidget(self._validate_button) | |
button_layout.addWidget(self._accept_button) | |
main_layout = QtWidgets.QVBoxLayout(self) | |
main_layout.addLayout(inner_layout) | |
main_layout.addWidget(self._progress_bar) | |
main_layout.addLayout(button_layout) | |
self._validate_button.clicked.connect(self.onValidateClicked) | |
self._accept_button.clicked.connect(self.onAcceptClicked) | |
self._validation_tree.clicked.connect(self.itemClicked) | |
def onValidateClicked(self): | |
self.validateClicked.emit() | |
def onAcceptClicked(self): | |
self.acceptClicked.emit() | |
def setDescription(self, html: str): | |
self._description_view.setHtml(html) | |
def setModel(self, model: ValidationModel): | |
self._validation_tree.setModel(model) | |
def setProgress(self, message, progress, total): | |
self._progress_bar.setFormat(message) | |
self._progress_bar.setMaximum(total) | |
self._progress_bar.setValue(progress) | |
def sizeHint(self) -> QtCore.QSize: | |
# calculate the size of the hint based on the width and height of the screen | |
screen = QtWidgets.QApplication.primaryScreen() | |
screen_size = screen.size() | |
return QtCore.QSize(screen_size.width() * 0.5, screen_size.height() * 0.9) | |
class ValidateSomething(ValidationItemBase): | |
label = "Validate Something" | |
category = "Burps" | |
def validate(self, data): | |
for i in range(100): | |
time.sleep(0.01) | |
self.setProgress(i) | |
return True | |
class ValidateSomethingElse(ValidationItemBase): | |
label = "Validate Something Else" | |
category = "Farts" | |
description = textwrap.dedent(""" | |
<h1>Validate something else</h2> | |
""") | |
def validate(self, data): | |
for i in range(100): | |
time.sleep(0.01) | |
self.setProgress(i) | |
return False | |
class SomethingValidator(ValidationController): | |
def populate_model(self): | |
self._model.addValidationItem(ValidateSomething()) | |
self._model.addValidationItem(ValidateSomething()) | |
self._model.addValidationItem(ValidateSomethingElse()) | |
def collect_data(self): | |
return None | |
if __name__ == '__main__': | |
import sys | |
app = QtWidgets.QApplication(sys.argv) | |
validator = SomethingValidator() | |
validator.show() | |
validator.execute() | |
sys.exit(app.exec_()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment