Files
clever-show/Server/config_editor_models.py
2019-12-01 21:06:57 +03:00

629 lines
20 KiB
Python

import pickle
from ast import literal_eval
from functools import partial
from copy import deepcopy
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt as Qt
from PyQt5.QtGui import QCursor, QStandardItemModel
from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog
import config_editor
def dict_walk(d: dict, keys):
current = d
for key in keys:
try:
current = current[key]
except KeyError:
return None
return current
states_colors = {
'normal': Qt.white,
'unchanged': Qt.darkGray,
'default': Qt.lightGray,
'edited': Qt.yellow,
'added': Qt.green,
'deleted': Qt.red,
}
class ConfigModelItem:
def __init__(self, values=(), is_section=False, state='normal', default=None, parent=None):
self.spec_default = default
values = list(values)
if is_section:
values[1:1] = ('<section>',)
self.spec_default = values[1]
self.itemData = values
self.type = 'section' if is_section else None
self.state = state
self.default_values = deepcopy(self.itemData)
self.default_state = state
self.childItems = []
self.parentItem = parent
if self.parentItem is not None:
self.parentItem.appendChild(self)
@property
def is_section(self):
return self.type == 'section'
def reset(self):
self.set_data(self.spec_default, 1)
self.check_state()
if self.default_state == 'unchanged':
self.set_state('unchanged')
for child in self.childItems:
child.reset()
def reset_all(self):
self.itemData = self.default_values
self.set_state(self.default_state)
for child in self.childItems:
child.reset()
def appendChild(self, item):
self.childItems.append(item)
item.parentItem = self
def addChildren(self, items, row):
if row == -1:
row = 0
self.childItems[row:row] = items
for item in items:
item.parentItem = self
def child(self, row):
return self.childItems[row]
def childCount(self):
return len(self.childItems)
def columnCount(self):
return len(self.itemData)
def data(self, column):
try:
return self.itemData[column]
except IndexError:
return None
def set_data(self, data, column):
old_data = self.data(column)
if old_data is None:
data = literal_eval(data) if data else None
try:
self.itemData[column] = data
except IndexError:
return False
if old_data != data:
self.set_state('edited')
self.check_state()
return True
def check_state(self):
if self.spec_default is not None and self.data(1) == self.spec_default \
and self.data(0) == self.default_values[0]:
self.set_state('default')
def set_state(self, state):
# if self.state == 'unchanged' and state == 'default':
# return
if self.state == 'added' and state in ('edited', 'unchanged', 'default', 'normal'):
return
self.state = state
for child in self.childItems:
child.set_state(state)
def parent(self):
return self.parentItem
def row(self):
if self.parentItem is not None:
return self.parentItem.childItems.index(self)
return 0
def removeChild(self, position):
if position < 0 or position > len(self.childItems):
return False
child = self.childItems.pop(position)
child.parentItem = None
return True
def __repr__(self):
return str(self.itemData)
def ensure_unique_names(item, include_self=True):
name = item.data(0)
siblings_names = [child.data(0) for child in item.parent().childItems]
if not include_self:
siblings_names.remove(name)
while name in siblings_names:
if '_copy' in name:
spl = name.split('_copy')
num = int(spl[1]) if spl[1] else 0
num += 1
name = spl[0] + '_copy' + str(num)
else:
name = name + '_copy'
item.set_data(name, 0)
class ConfigModel(QtCore.QAbstractItemModel):
def __init__(self, parent=None, widget=None,
headers=("Option", "Value", 'Comment', 'Inline Comment')):
super(ConfigModel, self).__init__(parent)
self.widget = widget
self.rootItem = ConfigModelItem(headers)
self.do_color = True
self.initial_comment = ''
self.final_comment = ''
@QtCore.pyqtSlot(int)
def enable_color(self, value):
self.do_color = value
self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex(), (Qt.BackgroundRole, ))
def headerData(self, section, orientation, role):
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return self.rootItem.data(section)
def columnCount(self, parent):
return self.rootItem.columnCount()
def rowCount(self, parent):
if parent.column() > 0:
return 0
if not parent.isValid():
parentItem = self.rootItem
else:
parentItem = parent.internalPointer()
return parentItem.childCount()
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QtCore.QModelIndex()
parentItem = self.nodeFromIndex(parent)
childItem = parentItem.child(row)
if childItem:
return self.createIndex(row, column, childItem)
else:
return QtCore.QModelIndex()
def parent(self, index):
if not index.isValid():
return QtCore.QModelIndex()
childItem = index.internalPointer()
parentItem = childItem.parent()
if parentItem == self.rootItem or parentItem is None:
return QtCore.QModelIndex()
return self.createIndex(parentItem.row(), 0, parentItem)
def nodeFromIndex(self, index):
if index.isValid():
return index.internalPointer()
return self.rootItem
def data(self, index, role):
if not index.isValid():
return None
item = index.internalPointer()
if role == Qt.DisplayRole or role == Qt.EditRole:
return item.data(index.column())
if role == Qt.BackgroundRole and self.do_color:
return QtGui.QBrush(states_colors[item.state])
return None
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return False
item = index.internalPointer()
if role == Qt.EditRole:
if index.column() == 0 and (self.widget is not None) \
and value != item.data(index.column()):
if not self.widget.edit_caution():
return False
item.set_data(value, index.column())
if index.column() == 0:
ensure_unique_names(item, include_self=False)
self.dataChanged.emit(index, index, (role,))
return True
def flags(self, index):
if not index.isValid():
return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled # Qt.NoItemFlags
item = index.internalPointer()
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if index.column() == 0:
flags |= int(QtCore.Qt.ItemIsDragEnabled)
if item.is_section:
flags |= int(QtCore.Qt.ItemIsDropEnabled)
if not (index.column() > 0 and item.is_section):
flags |= Qt.ItemIsEditable
return flags
def supportedDropActions(self):
return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction
def mimeTypes(self):
return ['app/configitem', 'text/xml']
def mimeData(self, indexes):
mimedata = QtCore.QMimeData()
index = indexes[0]
mimedata.setData('app/configitem', pickle.dumps(self.nodeFromIndex(index)))
return mimedata
def dropMimeData(self, mimedata, action, row, column, parentIndex):
if action == Qt.IgnoreAction:
return True
droppedNode = deepcopy(pickle.loads(mimedata.data('app/configitem')))
self.insertItems(row, [droppedNode], parentIndex)
self.dataChanged.emit(parentIndex, parentIndex)
self.widget.ui.config_view.expandAll()
if action & Qt.CopyAction:
return False # to not delete original item
return True
def removeRows(self, row, count, parent):
self.beginRemoveRows(parent, row, row + count - 1)
parentItem = self.nodeFromIndex(parent)
for x in range(count):
parentItem.removeChild(row)
self.endRemoveRows()
return True
def removeRow(self, index):
parent = index.parent()
self.beginRemoveRows(parent, index.row(), index.row())
parentItem = self.nodeFromIndex(parent)
parentItem.removeChild(index.row())
self.endRemoveRows()
return True
def insertItems(self, row, items, parentIndex):
parent = self.nodeFromIndex(parentIndex)
self.beginInsertRows(parentIndex, row, row + len(items) - 1)
parent.addChildren(items, row)
self.endInsertRows()
self.dataChanged.emit(parentIndex, parentIndex)
return True
def get_key_sequence(self, index):
item = index.internalPointer()
keys = []
while item is not None:
key = item.data(0)
keys.append(key)
item = item.parent()
return list(reversed(keys[:-1]))
def dict_setup(self, data: dict, parent=None):
if parent is None:
parent = self.rootItem
for key, value in data.items():
if isinstance(value, dict):
item = ConfigModelItem((key,), parent=parent, is_section=True)
self.dict_setup(value, parent=item)
else:
parent.appendChild(ConfigModelItem((key, value)))
def config_dict_setup(self, data: dict, parent=None):
if parent is None:
parent = self.rootItem
self.initial_comment = '\n'.join(data.pop('initial_comment', ['']))
self.final_comment = '\n'.join(data.pop('final_comment', ['']))
for key, item in data.items():
if item.get('__option__', False):
# {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False, 'comments': [], 'inline_comment': None}
value = item['value']
default = item['default']
comments = '\n'.join(item['comments']) or ''
inline_comment = item['inline_comment'] or ''
if item['unchanged']:
state = 'unchanged'
elif value == default:
state = 'default'
else:
state = 'normal'
parent.appendChild(ConfigModelItem((key, value, comments, inline_comment),
state=state, default=default))
else:
section = ConfigModelItem((key,), parent=parent, is_section=True)
self.config_dict_setup(item, parent=section)
def to_dict(self, parent=None) -> dict:
if parent is None:
parent = self.rootItem
data = {}
for item in parent.childItems:
item_name, item_data = item.data(0), item.data(1)
if item.is_section:
data[item_name] = self.to_dict(item)
else:
data[item_name] = item_data
return data
def to_config_dict(self, parent=None) -> dict:
data = {}
if parent is None:
parent = self.rootItem
data['initial_comment'] = self.initial_comment.split('\n')
data['final_comment'] = self.final_comment.split('\n')
for item in parent.childItems:
key = item.data(0)
if item.is_section:
d = self.to_config_dict(item)
if d: # to prevent empty sections
data[key] = d
elif item.state != 'unchanged':
d = {'__option__': True,
'value': item.data(1),
# 'default': item.default,
# 'unchanged': False,
'comments': (item.data(2) or '').split('\n'),
'inline_comment': item.data(3) or ''
}
data[key] = d
return data
@property
def dict(self):
return self.to_dict()
class ConfigDialog(QtWidgets.QDialog):
def __init__(self):
super(ConfigDialog, self).__init__()
self.ui = config_editor.Ui_config_dialog()
self.model = ConfigModel(widget=self)
self.setupUi()
def setupModel(self, data, pure_dict=False):
if pure_dict:
self.model.dict_setup(data)
else:
self.model.config_dict_setup(data)
self.ui.config_view.expandAll()
def setupUi(self):
self.ui.setupUi(self)
self.ui.config_view = Tree()
self.ui.config_view.setObjectName("config_view")
self.ui.config_view.setModel(self.model)
self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1)
self.ui.config_view.expandAll()
self.ui.do_coloring.stateChanged.connect(self.model.enable_color)
# self.ui.delete_button.pressed.connect(self.remove_selected)
# index = self.config_view.selectedIndexes()[0]
def edit_caution(self):
reply = QMessageBox().warning(self, "Editing caution",
"Are you sure you want to edit section/option name? "
"Proceed with caution!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
return reply == QMessageBox.Yes
class Tree(QTreeView):
def __init__(self):
QTreeView.__init__(self)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.open_menu)
self.setSelectionMode(self.SingleSelection)
# self.setSelectionBehavior(self.SelectItems)
self.setDragDropMode(QAbstractItemView.DragDrop)
self.setDefaultDropAction(Qt.MoveAction)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setDropIndicatorShown(True)
# self.header()
# header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
# header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents)
# self.resizeColumnToContents(1)
# header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self.setAnimated(True)
def open_menu(self, point):
index = self.indexAt(point)
item = index.internalPointer()
menu = QMenu()
duplicate = QAction("Duplicate")
duplicate.triggered.connect(partial(self.duplicate, index))
menu.addAction(duplicate)
remove = QAction("Remove from config")
remove.triggered.connect(partial(self.remove, index))
menu.addAction(remove)
menu.addSeparator()
reset = QAction("Reset value to default")
reset.triggered.connect(partial(self.reset_item, index, False))
menu.addAction(reset)
reset_all = QAction("Reset all data")
reset_all.triggered.connect(partial(self.reset_item, index, True))
menu.addAction(reset_all)
menu.addSeparator()
add_option = QAction("Add option")
add_option.triggered.connect(partial(self.add_item, index, False))
menu.addAction(add_option)
add_section = QAction("Add section")
add_section.triggered.connect(partial(self.add_item, index, True))
menu.addAction(add_section)
if item is None:
reset.setDisabled(True)
reset_all.setDisabled(True)
duplicate.setDisabled(True)
remove.setDisabled(True)
menu.exec_(QCursor.pos())
def duplicate(self, index):
item = deepcopy(index.internalPointer())
item.set_state('added')
ensure_unique_names(item)
self.model().insertItems(index.row() + 1, [item], index.parent())
self.expandAll() # fix not expanded duplicated section
def remove(self, index):
self.model().removeRow(index)
def add_item(self, index, is_section):
prompt = 'Enter {} name'.format('section' if is_section else 'option')
text, ok = QInputDialog.getText(self, prompt, prompt)
if not ok:
return
item = ConfigModelItem((text, None, '', ''), is_section=is_section, state='added')
row = index.row()
if row == -1: # to append at last position
parentItem = self.model().nodeFromIndex(index)
row = parentItem.childCount() - 1
self.model().insertItems(row + 1, [item], index.parent())
ensure_unique_names(item, include_self=False)
def reset_item(self, index, reset_all):
item = index.internalPointer()
if reset_all:
item.reset_all()
else:
item.reset()
if __name__ == '__main__':
import sys
def except_hook(cls, exception, traceback):
sys.__excepthook__(cls, exception, traceback)
sys.excepthook = except_hook
app = QtWidgets.QApplication(sys.argv)
# data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}},
# "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}}
data = {
'config_name': {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False,
'comments': [], 'inline_comment': None},
'config_version': {'__option__': True, 'value': 0.0, 'default': 0.0, 'unchanged': False, 'comments': [],
'inline_comment': None}, 'SERVER': {
'port': {'__option__': True, 'value': 25000, 'default': 25000, 'unchanged': False, 'comments': [],
'inline_comment': None},
'host': {'__option__': True, 'value': '192.168.1.103', 'default': '192.168.1.101', 'unchanged': False,
'comments': [], 'inline_comment': None},
'buffer_size': {'__option__': True, 'value': 1024, 'default': 1024, 'unchanged': False, 'comments': [],
'inline_comment': None}}, 'BROADCAST': {
'use': {'__option__': True, 'value': True, 'default': True, 'unchanged': False, 'comments': [],
'inline_comment': None},
'port': {'__option__': True, 'value': 8181, 'default': 8181, 'unchanged': False, 'comments': [],
'inline_comment': None}}, 'NTP': {
'use': {'__option__': True, 'value': False, 'default': False, 'unchanged': False, 'comments': [],
'inline_comment': None},
'port': {'__option__': True, 'value': 123, 'default': 123, 'unchanged': False,
'comments': ['#host = ntp1.stratum2.ru'], 'inline_comment': None},
'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': True,
'comments': [], 'inline_comment': ''}}, 'PRIVATE': {
'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': True,
'comments': ['# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh', 'newlibe'],
'inline_comment': None}},
'initial_comment': ['# This is generated config_attrs with default_values', '# Modify to configure'],
'final_comment': []}
ui = ConfigDialog()
ui.setupModel(data)
ui.show()
print(app.exec_())
print(ui.result())
print(ui.model.to_dict())
print(ui.model.to_config_dict())
sys.exit()