From ce20377e39e00885350e50adb3b23b1705fe54b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Fri, 9 Nov 2018 15:44:14 +0100 Subject: [PATCH 1/5] Initial template manager A working template manager; at the moment it only provides means to easily change templates of multiple VMs at once, but it should also give tools to install new templates. fixes QubeSOS/qubes-issues#4085 --- qubes-template-manager.desktop | 9 + qubesmanager/template_manager.py | 395 +++++++++++++++++++++++++++++++ rpm_spec/qmgr.spec.in | 5 + setup.py | 3 +- 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 qubes-template-manager.desktop create mode 100644 qubesmanager/template_manager.py diff --git a/qubes-template-manager.desktop b/qubes-template-manager.desktop new file mode 100644 index 0000000..a0eca51 --- /dev/null +++ b/qubes-template-manager.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Exec=qubes-template-manager +Icon=qubes-manager +Terminal=false +Name=Qubes Template Manager +GenericName=Qubes Template Manager +StartupNotify=false +Categories=System; diff --git a/qubesmanager/template_manager.py b/qubesmanager/template_manager.py new file mode 100644 index 0000000..f8666bc --- /dev/null +++ b/qubesmanager/template_manager.py @@ -0,0 +1,395 @@ +#!/usr/bin/python3 +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2018 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . +# +# + +import sys +import os +import os.path +import traceback +import quamash +import asyncio +from contextlib import suppress + +from qubesadmin import Qubes +from qubesadmin import exc +from qubesadmin import events + +from PyQt4 import QtGui # pylint: disable=import-error +from PyQt4 import QtCore # pylint: disable=import-error +from PyQt4 import Qt + + +from . import ui_templatemanager + +column_names = ['Qube', 'State', 'Current template', 'New template'] + + +class TemplateManagerWindow( + ui_templatemanager.Ui_MainWindow, QtGui.QMainWindow): + + def __init__(self, qt_app, qubes_app, dispatcher, parent=None): + # pylint: disable=unused-argument + super(TemplateManagerWindow, self).__init__() + self.setupUi(self) + + self.qubes_app = qubes_app + self.qt_app = qt_app + self.dispatcher = dispatcher + + self.rows_in_table = {} + self.templates = [] + self.timers = [] + + self.prepare_vm_list() + self.initialize_table_events() + + self.buttonBox.button(QtGui.QDialogButtonBox.Ok).clicked.connect( + self.apply) + self.buttonBox.button(QtGui.QDialogButtonBox.Cancel).clicked.connect( + self.cancel) + self.buttonBox.button(QtGui.QDialogButtonBox.Reset).clicked.connect( + self.reset) + + self.vm_list.show() + + def prepare_vm_list(self): + self.templates = [vm.name for vm in self.qubes_app.domains + if vm.klass == 'TemplateVM'] + vms_with_templates = [vm for vm in self.qubes_app.domains + if getattr(vm, 'template', None)] + + self.vm_list.setColumnCount(len(column_names)) + self.vm_list.setRowCount(len(vms_with_templates)) + + row_count = 0 + for vm in vms_with_templates: + row = VMRow(vm, row_count, self.vm_list, column_names, + self.templates) + self.rows_in_table[vm.name] = row + row_count += 1 + + self.vm_list.setHorizontalHeaderLabels(['Qube', '', 'Current', 'New']) + self.vm_list.resizeColumnsToContents() + + def initialize_table_events(self): + self.vm_list.cellDoubleClicked.connect(self.table_double_click) + self.vm_list.horizontalHeader().sortIndicatorChanged.connect( + self.sorting_changed) + + self.dispatcher.add_handler('domain-pre-start', self.vm_state_changed) + self.dispatcher.add_handler('domain-start-failed', + self.vm_state_changed) + self.dispatcher.add_handler('domain-stopped', self.vm_state_changed) + self.dispatcher.add_handler('domain-shutdown', self.vm_state_changed) + + self.dispatcher.add_handler('domain-add', self.vm_added) + self.dispatcher.add_handler('domain-delete', self.vm_removed) + + def vm_added(self, _submitter, _event, vm, **_kwargs): + # unfortunately, a VM just in the moment of creation may not have + # a template it will have in a second - e.g., when cloning + timer = Qt.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(lambda: self._vm_added(vm, timer)) + self.timers.append(timer) + timer.start(1000) # 1s + + def _vm_added(self, vm_name, timer): + self.timers.remove(timer) + try: + vm = self.qubes_app.domains[vm_name] + if not getattr(vm, 'template', None): + return + except (exc.QubesException, KeyError): + return # it was a dispVM that crashed on start + + row_no = self.vm_list.rowCount() + self.vm_list.setRowCount(self.vm_list.rowCount() + 1) + row = VMRow(vm, row_no, self.vm_list, column_names, + self.templates) + self.rows_in_table[vm.name] = row + self.vm_list.show() + + def vm_removed(self, _submitter, _event, **kwargs): + if kwargs['vm'] not in self.rows_in_table: + return + + self.vm_list.removeRow(self.rows_in_table[kwargs['vm']].name_item.row()) + + def vm_state_changed(self, vm, event, **_kwargs): + try: + if vm.name not in self.rows_in_table: + return + except exc.QubesException: + return # it was a crashing DispVM or closed DispVM + + if event == 'domain-pre-start': + self.rows_in_table[vm.name].vm_state_change(is_running=True) + elif event == 'domain-start-failed': + self.rows_in_table[vm.name].vm_state_change(is_running=False) + elif event == 'domain-stopped': + self.rows_in_table[vm.name].vm_state_change(is_running=False) + elif event == 'domain-shutdown': + self.rows_in_table[vm.name].vm_state_change(is_running=False) + + def sorting_changed(self, index, _order): + # this is very much not perfect, but QTableWidget does not + # want to be sorted on custom widgets + # possible fix - try to set data of dummy items. + if index == column_names.index('New template'): + self.vm_list.horizontalHeader().setSortIndicator( + -1, QtCore.Qt.AscendingOrder) + + def table_double_click(self, row, column): + template_column = column_names.index('Current template') + + if column != template_column: + return + + template_name = self.vm_list.item(row, column).text() + + self.vm_list.clearSelection() + + for row_number in range(0, self.vm_list.rowCount()): + if self.vm_list.item( + row_number, template_column).text() == template_name: + self.vm_list.selectRow(row_number) + + def reset(self): + for row in self.rows_in_table.values(): + row.new_item.reset_choice() + + def cancel(self): + self.close() + + def apply(self): + errors = {} + for vm, row in self.rows_in_table.items(): + if row.new_item.changed: + try: + setattr(self.qubes_app.domains[vm], + 'template', row.new_item.currentText()) + except Exception as ex: # pylint: disable=broad-except + errors[vm] = str(ex) + if errors: + error_messages = [vm + ": " + errors[vm] for vm in errors] + QtGui.QMessageBox.warning( + self, + self.tr("Errors encountered!"), + self.tr( + "Errors encountered on template change in the following " + "qubes:
{}.").format("
".join(error_messages))) + + self.close() + + +class VMNameItem(QtGui.QTableWidgetItem): + def __init__(self, vm): + super(VMNameItem, self).__init__() + self.vm = vm + + self.setText(self.vm.name) + self.setIcon(QtGui.QIcon.fromTheme(vm.label.icon)) + + +class StatusItem(QtGui.QTableWidgetItem): + def __init__(self, vm): + super(StatusItem, self).__init__() + self.vm = vm + + self.state = None + + def set_state(self, is_running): + self.state = is_running + + if self.state: + self.setIcon(QtGui.QIcon.fromTheme('dialog-warning')) + self.setToolTip("Cannot change template on a running VM.") + else: + self.setIcon(QtGui.QIcon()) + self.setToolTip("") + + def __lt__(self, other): + if self.state == other.state: + return self.vm.name < other.vm.name + return self.state < other.state + + +class CurrentTemplateItem(QtGui.QTableWidgetItem): + def __init__(self, vm): + super(CurrentTemplateItem, self).__init__() + self.vm = vm + + self.setText(self.vm.template.name) + + def __lt__(self, other): + if self.text() == other.text(): + return self.vm.name < other.vm.name + return self.text() < other.text() + + +class NewTemplateItem(QtGui.QComboBox): + def __init__(self, vm, templates, table_widget): + super(NewTemplateItem, self).__init__() + self.vm = vm + self.table_widget = table_widget + self.changed = False + + for t in templates: + self.addItem(t) + self.setCurrentIndex(self.findText(vm.template.name)) + self.start_value = self.currentText() + + self.currentIndexChanged.connect(self.choice_changed) + + def choice_changed(self): + if self.currentText() != self.start_value: + self.changed = True + self.setStyleSheet('font-weight: bold') + else: + self.changed = False + self.setStyleSheet('font-weight: normal') + + for row_index in self.table_widget.selectionModel().selectedRows(): + widget = self.table_widget.cellWidget( + row_index.row(), column_names.index('New template')) + if widget.isEnabled() and widget.currentText() !=\ + self.currentText(): + widget.setCurrentIndex(widget.findText(self.currentText())) + + self.table_widget.clearSelection() + + def reset_choice(self): + self.setCurrentIndex(self.findText(self.start_value)) + + +class VMRow: + def __init__(self, vm, row_no, table_widget, columns, templates): + self.vm = vm + + # icon and name + self.name_item = VMNameItem(self.vm) + table_widget.setItem(row_no, columns.index('Qube'), self.name_item) + + # state + self.state_item = StatusItem(self.vm) + table_widget.setItem(row_no, columns.index('State'), self.state_item) + + # current template + self.current_item = CurrentTemplateItem(self.vm) + table_widget.setItem(row_no, columns.index('Current template'), + self.current_item) + + # new template + # this is needed to make the cell correctly selectable/non-selectable + self.dummy_new_item = QtGui.QTableWidgetItem() + self.new_item = NewTemplateItem(self.vm, templates, table_widget) + + table_widget.setCellWidget(row_no, columns.index('New template'), + self.new_item) + table_widget.setItem(row_no, columns.index('New template'), + self.dummy_new_item) + + self.vm_state_change(self.vm.is_running()) + + def vm_state_change(self, is_running): + self.new_item.setEnabled(not is_running) + self.state_item.set_state(is_running) + + items = [self.name_item, self.state_item, self.current_item, + self.dummy_new_item] + + for item in items: + if is_running: + item.setFlags(item.flags() & ~QtCore.Qt.ItemIsSelectable) + else: + item.setFlags(item.flags() | QtCore.Qt.ItemIsSelectable) + +# Bases on the original code by: +# Copyright (c) 2002-2007 Pascal Varet + + +def handle_exception(exc_type, exc_value, exc_traceback): + + filename, line, dummy, dummy = traceback.extract_tb(exc_traceback).pop() + filename = os.path.basename(filename) + error = "%s: %s" % (exc_type.__name__, exc_value) + + strace = "" + stacktrace = traceback.extract_tb(exc_traceback) + while stacktrace: + (filename, line, func, txt) = stacktrace.pop() + strace += "----\n" + strace += "line: %s\n" % txt + strace += "func: %s\n" % func + strace += "line no.: %d\n" % line + strace += "file: %s\n" % filename + + msg_box = QtGui.QMessageBox() + msg_box.setDetailedText(strace) + msg_box.setIcon(QtGui.QMessageBox.Critical) + msg_box.setWindowTitle("Houston, we have a problem...") + msg_box.setText("Whoops. A critical error has occured. " + "This is most likely a bug in Qubes Manager.

" + "%s" % error + + "
at line %d
of file %s.

" + % (line, filename)) + + msg_box.exec_() + + +def loop_shutdown(): + pending = asyncio.Task.all_tasks() + for task in pending: + with suppress(asyncio.CancelledError): + task.cancel() + + +def main(): + qt_app = QtGui.QApplication(sys.argv) + qt_app.setOrganizationName("The Qubes Project") + qt_app.setOrganizationDomain("http://qubes-os.org") + qt_app.setApplicationName("Qube Manager") + qt_app.setWindowIcon(QtGui.QIcon.fromTheme("qubes-manager")) + qt_app.lastWindowClosed.connect(loop_shutdown) + + qubes_app = Qubes() + + loop = quamash.QEventLoop(qt_app) + asyncio.set_event_loop(loop) + dispatcher = events.EventsDispatcher(qubes_app) + + manager_window = TemplateManagerWindow(qt_app, qubes_app, dispatcher) + manager_window.show() + + try: + loop.run_until_complete( + asyncio.ensure_future(dispatcher.listen_for_events())) + except asyncio.CancelledError: + pass + except Exception: # pylint: disable=broad-except + loop_shutdown() + exc_type, exc_value, exc_traceback = sys.exc_info()[:3] + handle_exception(exc_type, exc_value, exc_traceback) + + +if __name__ == "__main__": + main() diff --git a/rpm_spec/qmgr.spec.in b/rpm_spec/qmgr.spec.in index b00e42e..86b07be 100644 --- a/rpm_spec/qmgr.spec.in +++ b/rpm_spec/qmgr.spec.in @@ -47,6 +47,7 @@ cp qubes-vm-create.desktop $RPM_BUILD_ROOT/usr/share/applications/ cp qubes-backup.desktop $RPM_BUILD_ROOT/usr/share/applications/ cp qubes-backup-restore.desktop $RPM_BUILD_ROOT/usr/share/applications/ cp qubes-qube-manager.desktop $RPM_BUILD_ROOT/usr/share/applications/ +cp qubes-template-manager.desktop $RPM_BUILD_ROOT/usr/share/applications/ %post update-desktop-database &> /dev/null || : @@ -67,6 +68,7 @@ rm -rf $RPM_BUILD_ROOT /usr/bin/qubes-backup-restore /usr/bin/qubes-qube-manager /usr/bin/qubes-log-viewer +/usr/bin/qubes-template-manager /usr/libexec/qubes-manager/mount_for_backup.sh /usr/libexec/qubes-manager/qvm_about.sh @@ -94,6 +96,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/utils.py %{python3_sitelib}/qubesmanager/bootfromdevice.py %{python3_sitelib}/qubesmanager/device_list.py +%{python3_sitelib}/qubesmanager/template_manager.py %{python3_sitelib}/qubesmanager/resources_rc.py @@ -111,6 +114,7 @@ rm -rf $RPM_BUILD_ROOT %{python3_sitelib}/qubesmanager/ui_informationnotes.py %{python3_sitelib}/qubesmanager/ui_qubemanager.py %{python3_sitelib}/qubesmanager/ui_devicelist.py +%{python3_sitelib}/qubesmanager/ui_templatemanager.py %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.qm %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.ts @@ -128,6 +132,7 @@ rm -rf $RPM_BUILD_ROOT /usr/share/applications/qubes-backup.desktop /usr/share/applications/qubes-backup-restore.desktop /usr/share/applications/qubes-qube-manager.desktop +/usr/share/applications/qubes-template-manager.desktop %changelog @CHANGELOG@ diff --git a/setup.py b/setup.py index 721284c..c6d3728 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ if __name__ == '__main__': 'qubes-backup = qubesmanager.backup:main', 'qubes-backup-restore = qubesmanager.restore:main', 'qubes-qube-manager = qubesmanager.qube_manager:main', - 'qubes-log-viewer = qubesmanager.log_dialog:main' + 'qubes-log-viewer = qubesmanager.log_dialog:main', + 'qubes-template-manager = qubesmanager.template_manager:main' ], }) From 13c4c748f1e8b97f7d23740e809baf1882e6b3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Fri, 9 Nov 2018 15:51:22 +0100 Subject: [PATCH 2/5] Added links to template manager to qube manager Added a shortcut to template manager to qube manager main window and some minor pylint fixes. --- qubesmanager/qube_manager.py | 6 ++++++ qubesmanager/template_manager.py | 11 +++++++---- ui/qubemanager.ui | 11 ++++++++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/qubesmanager/qube_manager.py b/qubesmanager/qube_manager.py index 1693064..ef18c13 100644 --- a/qubesmanager/qube_manager.py +++ b/qubesmanager/qube_manager.py @@ -1041,6 +1041,12 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QtGui.QMainWindow): self.qubes_app) global_settings_window.exec_() + # noinspection PyArgumentList + @QtCore.pyqtSlot(name='on_action_manage_templates_triggered') + def action_manage_templates_triggered(self): + # pylint: disable=invalid-name, no-self-use + subprocess.check_call('qubes-template-manager') + # noinspection PyArgumentList @QtCore.pyqtSlot(name='on_action_show_network_triggered') def action_show_network_triggered(self): diff --git a/qubesmanager/template_manager.py b/qubesmanager/template_manager.py index f8666bc..5af7830 100644 --- a/qubesmanager/template_manager.py +++ b/qubesmanager/template_manager.py @@ -34,10 +34,10 @@ from qubesadmin import events from PyQt4 import QtGui # pylint: disable=import-error from PyQt4 import QtCore # pylint: disable=import-error -from PyQt4 import Qt +from PyQt4 import Qt # pylint: disable=import-error -from . import ui_templatemanager +from . import ui_templatemanager # pylint: disable=no-name-in-module column_names = ['Qube', 'State', 'Current template', 'New template'] @@ -202,6 +202,7 @@ class TemplateManagerWindow( class VMNameItem(QtGui.QTableWidgetItem): + # pylint: disable=too-few-public-methods def __init__(self, vm): super(VMNameItem, self).__init__() self.vm = vm @@ -234,6 +235,7 @@ class StatusItem(QtGui.QTableWidgetItem): class CurrentTemplateItem(QtGui.QTableWidgetItem): + # pylint: disable=too-few-public-methods def __init__(self, vm): super(CurrentTemplateItem, self).__init__() self.vm = vm @@ -253,8 +255,8 @@ class NewTemplateItem(QtGui.QComboBox): self.table_widget = table_widget self.changed = False - for t in templates: - self.addItem(t) + for template in templates: + self.addItem(template) self.setCurrentIndex(self.findText(vm.template.name)) self.start_value = self.currentText() @@ -282,6 +284,7 @@ class NewTemplateItem(QtGui.QComboBox): class VMRow: + # pylint: disable=too-few-public-methods def __init__(self, vm, row_no, table_widget, columns, templates): self.vm = vm diff --git a/ui/qubemanager.ui b/ui/qubemanager.ui index 07ba425..0dea278 100644 --- a/ui/qubemanager.ui +++ b/ui/qubemanager.ui @@ -244,7 +244,7 @@ 0 0 1100 - 46 + 28 @@ -256,6 +256,7 @@ + @@ -821,6 +822,14 @@ &Exit Qube Manager + + + Manage templates for qubes + + + Launch a tool that allows multiple templates to be changed at once + + From 21224a6cbda70142139b54d2efa2e49863e05ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Fri, 9 Nov 2018 22:07:17 +0100 Subject: [PATCH 3/5] Missing file added I might have forgotten to include the .ui file. --- qubesmanager/template_manager.py | 2 +- ui/templatemanager.ui | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 ui/templatemanager.ui diff --git a/qubesmanager/template_manager.py b/qubesmanager/template_manager.py index 5af7830..92f8b8a 100644 --- a/qubesmanager/template_manager.py +++ b/qubesmanager/template_manager.py @@ -37,7 +37,7 @@ from PyQt4 import QtCore # pylint: disable=import-error from PyQt4 import Qt # pylint: disable=import-error -from . import ui_templatemanager # pylint: disable=no-name-in-module +import ui_templatemanager # pylint: disable=no-name-in-module column_names = ['Qube', 'State', 'Current template', 'New template'] diff --git a/ui/templatemanager.ui b/ui/templatemanager.ui new file mode 100644 index 0000000..7a69ba8 --- /dev/null +++ b/ui/templatemanager.ui @@ -0,0 +1,84 @@ + + + MainWindow + + + + 0 + 0 + 574 + 718 + + + + Template Manager + + + + + + + QLayout::SetDefaultConstraint + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::MultiSelection + + + QAbstractItemView::SelectRows + + + Qt::DotLine + + + true + + + false + + + 20 + + + 4 + + + false + + + false + + + + + + + Select multiple qubes to change template in all of them at once. +To select all qubes with a given template, double-click the template name in any row. + + + true + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::Reset + + + + + + + pushButton_2 + verticalLayoutWidget + + + + + From c0eb8b55ab3c5a4df8caa03ec4111cbce62a0b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Tue, 4 Dec 2018 00:56:24 +0100 Subject: [PATCH 4/5] Improved template manager UI changed it to be more intuitive and less annoying. --- qubesmanager/template_manager.py | 134 ++++++++++++++++++++++--------- ui/templatemanager.ui | 63 ++++++++++++--- 2 files changed, 147 insertions(+), 50 deletions(-) diff --git a/qubesmanager/template_manager.py b/qubesmanager/template_manager.py index 92f8b8a..0401672 100644 --- a/qubesmanager/template_manager.py +++ b/qubesmanager/template_manager.py @@ -37,9 +37,9 @@ from PyQt4 import QtCore # pylint: disable=import-error from PyQt4 import Qt # pylint: disable=import-error -import ui_templatemanager # pylint: disable=no-name-in-module +from . import ui_templatemanager # pylint: disable=no-name-in-module -column_names = ['Qube', 'State', 'Current template', 'New template'] +column_names = ['State', 'Qube', 'Current template', 'New template'] class TemplateManagerWindow( @@ -58,7 +58,7 @@ class TemplateManagerWindow( self.templates = [] self.timers = [] - self.prepare_vm_list() + self.prepare_lists() self.initialize_table_events() self.buttonBox.button(QtGui.QDialogButtonBox.Ok).clicked.connect( @@ -68,11 +68,20 @@ class TemplateManagerWindow( self.buttonBox.button(QtGui.QDialogButtonBox.Reset).clicked.connect( self.reset) + self.change_all_combobox.currentIndexChanged.connect( + self.change_all_changed) + self.clear_selection_button.clicked.connect(self.clear_selection) + self.vm_list.show() - def prepare_vm_list(self): + def prepare_lists(self): self.templates = [vm.name for vm in self.qubes_app.domains - if vm.klass == 'TemplateVM'] + if vm.klass == 'TemplateVM'] + + self.change_all_combobox.addItem('(select template)') + for template in self.templates: + self.change_all_combobox.addItem(template) + vms_with_templates = [vm for vm in self.qubes_app.domains if getattr(vm, 'template', None)] @@ -86,11 +95,13 @@ class TemplateManagerWindow( self.rows_in_table[vm.name] = row row_count += 1 - self.vm_list.setHorizontalHeaderLabels(['Qube', '', 'Current', 'New']) + self.vm_list.setHorizontalHeaderLabels(['', 'Qube', 'Current', 'New']) self.vm_list.resizeColumnsToContents() def initialize_table_events(self): self.vm_list.cellDoubleClicked.connect(self.table_double_click) + self.vm_list.cellClicked.connect(self.table_click) + self.vm_list.horizontalHeader().sortIndicatorChanged.connect( self.sorting_changed) @@ -153,11 +164,27 @@ class TemplateManagerWindow( def sorting_changed(self, index, _order): # this is very much not perfect, but QTableWidget does not # want to be sorted on custom widgets - # possible fix - try to set data of dummy items. - if index == column_names.index('New template'): + if index == column_names.index('New template') or \ + index == column_names.index('State'): self.vm_list.horizontalHeader().setSortIndicator( -1, QtCore.Qt.AscendingOrder) + def clear_selection(self): + for row in self.rows_in_table.values(): + row.checkbox.setChecked(False) + + def change_all_changed(self): + if self.change_all_combobox.currentIndex() == 0: + return + selected_template = self.change_all_combobox.currentText() + + for row in self.rows_in_table.values(): + if row.checkbox.isChecked(): + row.new_item.setCurrentIndex( + row.new_item.findText(selected_template)) + + self.change_all_combobox.setCurrentIndex(0) + def table_double_click(self, row, column): template_column = column_names.index('Current template') @@ -166,16 +193,33 @@ class TemplateManagerWindow( template_name = self.vm_list.item(row, column).text() - self.vm_list.clearSelection() - for row_number in range(0, self.vm_list.rowCount()): if self.vm_list.item( row_number, template_column).text() == template_name: - self.vm_list.selectRow(row_number) + checkbox = self.vm_list.cellWidget( + row_number, column_names.index('State')) + if checkbox: + if row_number == row: + # this is because double click registers as a + # single click and a double click + checkbox.setChecked(False) + else: + checkbox.setChecked(True) + + def table_click(self, row, column): + if column == column_names.index('New template'): + return + + checkbox = self.vm_list.cellWidget(row, column_names.index('State')) + if not checkbox: + return + + checkbox.setChecked(not checkbox.isChecked()) def reset(self): for row in self.rows_in_table.values(): row.new_item.reset_choice() + row.checkbox.setChecked(False) def cancel(self): self.close() @@ -197,7 +241,6 @@ class TemplateManagerWindow( self.tr( "Errors encountered on template change in the following " "qubes:
{}.").format("
".join(error_messages))) - self.close() @@ -270,15 +313,6 @@ class NewTemplateItem(QtGui.QComboBox): self.changed = False self.setStyleSheet('font-weight: normal') - for row_index in self.table_widget.selectionModel().selectedRows(): - widget = self.table_widget.cellWidget( - row_index.row(), column_names.index('New template')) - if widget.isEnabled() and widget.currentText() !=\ - self.currentText(): - widget.setCurrentIndex(widget.findText(self.currentText())) - - self.table_widget.clearSelection() - def reset_choice(self): self.setCurrentIndex(self.findText(self.start_value)) @@ -287,14 +321,17 @@ class VMRow: # pylint: disable=too-few-public-methods def __init__(self, vm, row_no, table_widget, columns, templates): self.vm = vm - - # icon and name - self.name_item = VMNameItem(self.vm) - table_widget.setItem(row_no, columns.index('Qube'), self.name_item) + self.table_widget = table_widget + self.templates = templates # state self.state_item = StatusItem(self.vm) table_widget.setItem(row_no, columns.index('State'), self.state_item) + self.checkbox = QtGui.QCheckBox() + + # icon and name + self.name_item = VMNameItem(self.vm) + table_widget.setItem(row_no, columns.index('Qube'), self.name_item) # current template self.current_item = CurrentTemplateItem(self.vm) @@ -302,29 +339,48 @@ class VMRow: self.current_item) # new template - # this is needed to make the cell correctly selectable/non-selectable - self.dummy_new_item = QtGui.QTableWidgetItem() + self.dummy_new_item = QtGui.QTableWidgetItem("qube is running") self.new_item = NewTemplateItem(self.vm, templates, table_widget) - table_widget.setCellWidget(row_no, columns.index('New template'), - self.new_item) table_widget.setItem(row_no, columns.index('New template'), self.dummy_new_item) - self.vm_state_change(self.vm.is_running()) + self.vm_state_change(self.vm.is_running(), row_no) - def vm_state_change(self, is_running): - self.new_item.setEnabled(not is_running) + def vm_state_change(self, is_running, row=None): self.state_item.set_state(is_running) - items = [self.name_item, self.state_item, self.current_item, - self.dummy_new_item] + if not row: + row = 0 + while row < self.table_widget.rowCount(): + if self.table_widget.item( + row, column_names.index('Qube')).text() == \ + self.name_item.text(): + break + row += 1 - for item in items: - if is_running: - item.setFlags(item.flags() & ~QtCore.Qt.ItemIsSelectable) - else: - item.setFlags(item.flags() | QtCore.Qt.ItemIsSelectable) + # hiding cellWidgets does not work in a qTableWidget + if not is_running: + self.new_item = NewTemplateItem(self.vm, self.templates, + self.table_widget) + self.checkbox = QtGui.QCheckBox() + + self.table_widget.setCellWidget( + row, column_names.index('New template'), self.new_item) + self.table_widget.setCellWidget( + row, column_names.index('State'), self.checkbox) + else: + new_template = self.table_widget.cellWidget( + row, column_names.index('New template')) + if new_template: + self.table_widget.removeCellWidget( + row, column_names.index('New template')) + + checkbox = self.table_widget.cellWidget( + row, column_names.index('State')) + if checkbox: + self.table_widget.removeCellWidget( + row, column_names.index('State')) # Bases on the original code by: # Copyright (c) 2002-2007 Pascal Varet diff --git a/ui/templatemanager.ui b/ui/templatemanager.ui index 7a69ba8..c100446 100644 --- a/ui/templatemanager.ui +++ b/ui/templatemanager.ui @@ -14,6 +14,12 @@ Template Manager + + + 0 + 0 + + @@ -26,7 +32,7 @@ QAbstractItemView::NoEditTriggers - QAbstractItemView::MultiSelection + QAbstractItemView::NoSelection QAbstractItemView::SelectRows @@ -47,18 +53,44 @@ 4 - false + true false + + + + + + + 0 + 0 + + + + Change all selected to: + + + + + + + + 0 + 0 + + + + + + - Select multiple qubes to change template in all of them at once. -To select all qubes with a given template, double-click the template name in any row. + <html><head/><body><p>To select all qubes with a given template, double-click the template name in any row.</p><p><span style=" font-weight:600;">IMPORTANT</span>: Changes will be applied only when you click OK.</p></body></html> true @@ -66,17 +98,26 @@ To select all qubes with a given template, double-click the template name in any - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::Reset - - + + + + + Clear Selection + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::Reset + + + + - pushButton_2 - verticalLayoutWidget From 24adad8094803957fccfe709390c162c60fca619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Fri, 7 Dec 2018 18:07:45 +0100 Subject: [PATCH 5/5] Fixed bug in handling state changes --- qubesmanager/template_manager.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/qubesmanager/template_manager.py b/qubesmanager/template_manager.py index 0401672..7e646c4 100644 --- a/qubesmanager/template_manager.py +++ b/qubesmanager/template_manager.py @@ -171,7 +171,8 @@ class TemplateManagerWindow( def clear_selection(self): for row in self.rows_in_table.values(): - row.checkbox.setChecked(False) + if row.checkbox: + row.checkbox.setChecked(False) def change_all_changed(self): if self.change_all_combobox.currentIndex() == 0: @@ -179,7 +180,7 @@ class TemplateManagerWindow( selected_template = self.change_all_combobox.currentText() for row in self.rows_in_table.values(): - if row.checkbox.isChecked(): + if row.checkbox and row.checkbox.isChecked(): row.new_item.setCurrentIndex( row.new_item.findText(selected_template)) @@ -218,8 +219,10 @@ class TemplateManagerWindow( def reset(self): for row in self.rows_in_table.values(): - row.new_item.reset_choice() - row.checkbox.setChecked(False) + if row.new_item: + row.new_item.reset_choice() + if row.checkbox: + row.checkbox.setChecked(False) def cancel(self): self.close() @@ -227,7 +230,7 @@ class TemplateManagerWindow( def apply(self): errors = {} for vm, row in self.rows_in_table.items(): - if row.new_item.changed: + if row.new_item and row.new_item.changed: try: setattr(self.qubes_app.domains[vm], 'template', row.new_item.currentText()) @@ -375,12 +378,14 @@ class VMRow: if new_template: self.table_widget.removeCellWidget( row, column_names.index('New template')) + self.new_item = None checkbox = self.table_widget.cellWidget( row, column_names.index('State')) if checkbox: self.table_widget.removeCellWidget( row, column_names.index('State')) + self.checkbox = None # Bases on the original code by: # Copyright (c) 2002-2007 Pascal Varet