diff --git a/res/test_plugin/plugin/__init__.py b/res/test_plugin/plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2ed03dffc10489701fe7d86f740a787330d377fd --- /dev/null +++ b/res/test_plugin/plugin/__init__.py @@ -0,0 +1,5 @@ +from PyQt5.QtWidgets import QMessageBox + + +def display_messagebox(): + QMessageBox.about(None, "About", "Sakia") \ No newline at end of file diff --git a/src/sakia/app.py b/src/sakia/app.py index 85ab35e4427492467faaa18f54dc5cfcb225fb1c..e38c3caf828ff3cf3b4cc03ddc471df88343da9f 100644 --- a/src/sakia/app.py +++ b/src/sakia/app.py @@ -14,7 +14,7 @@ from sakia.data.repositories import SakiaDatabase from sakia.data.entities import Transaction, Connection, Identity, Dividend from sakia.data.processors import BlockchainProcessor, NodesProcessor, IdentitiesProcessor, \ CertificationsProcessor, SourcesProcessor, TransactionsProcessor, ConnectionsProcessor, DividendsProcessor -from sakia.data.files import AppDataFile, UserParametersFile +from sakia.data.files import AppDataFile, UserParametersFile, PluginsDirectory from sakia.decorators import asyncify from sakia.money import * import asyncio @@ -60,6 +60,7 @@ class Application(QObject): parameters = attr.ib() db = attr.ib() currency = attr.ib() + plugins_dir = attr.ib() network_service = attr.ib(default=None) blockchain_service = attr.ib(default=None) identities_service = attr.ib(default=None) @@ -81,7 +82,7 @@ class Application(QObject): qapp.setAttribute(Qt.AA_EnableHighDpiScaling, True) options = SakiaOptions.from_arguments(argv) app_data = AppDataFile.in_config_path(options.config_path).load_or_init() - app = cls(qapp, loop, options, app_data, None, None, options.currency) + app = cls(qapp, loop, options, app_data, None, None, options.currency, None) #app.set_proxy() app.get_last_version() app.load_profile(app_data.default) @@ -96,6 +97,7 @@ class Application(QObject): :param profile_name: :return: """ + self.plugins_dir = PluginsDirectory.in_config_path(self.options.config_path, profile_name).load_or_init() self.parameters = UserParametersFile.in_config_path(self.options.config_path, profile_name).load_or_init() self.db = SakiaDatabase.load_or_init(self.options, profile_name) diff --git a/src/sakia/data/entities/__init__.py b/src/sakia/data/entities/__init__.py index 3224769090cbcebac2b3c5dfd960ddd2f761ec3d..40190cdb6e4e8e50ac33f73958b5ce8170dc4ad4 100644 --- a/src/sakia/data/entities/__init__.py +++ b/src/sakia/data/entities/__init__.py @@ -9,3 +9,4 @@ from .app_data import AppData from .source import Source from .dividend import Dividend from .contact import Contact +from .plugin import Plugin diff --git a/src/sakia/data/entities/plugin.py b/src/sakia/data/entities/plugin.py new file mode 100644 index 0000000000000000000000000000000000000000..1e39b44c56b54e06a43959441cb57c67f6d971c2 --- /dev/null +++ b/src/sakia/data/entities/plugin.py @@ -0,0 +1,10 @@ +import attr + + +@attr.s(frozen=True) +class Plugin: + name = attr.ib() + description = attr.ib() + version = attr.ib() + imported = attr.ib() + module = attr.ib() diff --git a/src/sakia/data/files/__init__.py b/src/sakia/data/files/__init__.py index cc136098a6d0981bebaaf8a3b1fca6fc76655beb..efff6b0c2807e6a94c9a0a300b5c3db9d3841ad1 100644 --- a/src/sakia/data/files/__init__.py +++ b/src/sakia/data/files/__init__.py @@ -1,2 +1,3 @@ from .user_parameters import UserParametersFile from .app_data import AppDataFile +from .plugins import PluginsDirectory, Plugin \ No newline at end of file diff --git a/src/sakia/data/files/plugins.py b/src/sakia/data/files/plugins.py new file mode 100644 index 0000000000000000000000000000000000000000..a909089fbe8ce6a67218eafad942e369807b9d2d --- /dev/null +++ b/src/sakia/data/files/plugins.py @@ -0,0 +1,47 @@ +import attr +import os +import sys +import logging +import importlib +from ..entities import Plugin + + +@attr.s(frozen=True) +class PluginsDirectory: + """ + The repository for UserParameters + """ + _path = attr.ib() + plugins = attr.ib(default=[]) + _logger = attr.ib(default=attr.Factory(lambda: logging.getLogger('sakia'))) + + @classmethod + def in_config_path(cls, config_path, profile_name): + plugins_path = os.path.join(config_path, profile_name, "plugins") + if not os.path.exists(plugins_path): + os.makedirs(plugins_path) + return cls(plugins_path) + + def load_or_init(self): + """ + Init plugins + """ + try: + for file in os.listdir(self._path): + if file.endswith(".zip"): + sys.path.append(os.path.join(self._path, file)) + module_name = os.path.splitext(os.path.basename(file))[0] + try: + plugin_module = importlib.import_module(module_name) + self.plugins.append(Plugin(plugin_module.PLUGIN_NAME, + plugin_module.PLUGIN_DESCRIPTION, + plugin_module.PLUGIN_VERSION, + True, + plugin_module)) + except ImportError as e: + self.plugins.append(Plugin(module_name, "", "", + False, None)) + self._logger.debug(str(e) + " with sys.path " + str(sys.path)) + except OSError as e: + self._logger.debug(str(e)) + return self diff --git a/src/sakia/gui/dialogs/plugins_manager/__init__.py b/src/sakia/gui/dialogs/plugins_manager/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/sakia/gui/dialogs/plugins_manager/controller.py b/src/sakia/gui/dialogs/plugins_manager/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..26de71c027b8d6e371a6971d7c74c1278f13c397 --- /dev/null +++ b/src/sakia/gui/dialogs/plugins_manager/controller.py @@ -0,0 +1,63 @@ +import asyncio + +from PyQt5.QtCore import QObject +from .model import PluginsManagerModel +from .view import PluginsManagerView +import attr + + +@attr.s() +class PluginsManagerController(QObject): + """ + The PluginManager view + """ + + view = attr.ib() + model = attr.ib() + + def __attrs_post_init__(self): + super().__init__() + self.view.button_box.rejected.connect(self.view.close) + + @classmethod + def create(cls, parent, app): + """ + Instanciate a PluginManager component + :param sakia.gui.component.controller.ComponentController parent: + :param sakia.app.Application app: sakia application + :return: a new PluginManager controller + :rtype: PluginManagerController + """ + view = PluginsManagerView(parent.view if parent else None) + model = PluginsManagerModel(app) + plugin = cls(view, model) + view.set_table_plugins_model(model.init_plugins_table()) + view.button_delete.clicked.connect(plugin.delete_plugin) + return plugin + + @classmethod + def open_dialog(cls, parent, app): + """ + Certify and identity + :param sakia.gui.component.controller.ComponentController parent: the parent + :param sakia.core.Application app: the application + :param sakia.core.Account account: the account certifying the identity + :param sakia.core.Community community: the community + :return: + """ + dialog = cls.create(parent, app) + return dialog.exec() + + def delete_plugin(self): + plugin_index = self.view.selected_plugin_index() + plugin = self.model.plugin(plugin_index) + self.model.delete_plugin(plugin) + + def async_exec(self): + future = asyncio.Future() + self.view.finished.connect(lambda r: future.set_result(r)) + self.view.open() + return future + + def exec(self): + self.view.exec() diff --git a/src/sakia/gui/dialogs/plugins_manager/model.py b/src/sakia/gui/dialogs/plugins_manager/model.py new file mode 100644 index 0000000000000000000000000000000000000000..52dfd84dae184e426f3ff8cf69b801b6f4bdd774 --- /dev/null +++ b/src/sakia/gui/dialogs/plugins_manager/model.py @@ -0,0 +1,41 @@ +from PyQt5.QtCore import QObject, Qt +from .table_model import PluginsTableModel, PluginsFilterProxyModel +import attr + + +@attr.s() +class PluginsManagerModel(QObject): + """ + The model of Plugin component + """ + + app = attr.ib() + + def __attrs_post_init__(self): + super().__init__() + + def init_plugins_table(self): + """ + Generates a plugins table model + :return: + """ + self._model = PluginsTableModel(self, self.app) + self._proxy = PluginsFilterProxyModel(self) + self._proxy.setSourceModel(self._model) + self._proxy.setDynamicSortFilter(True) + self._proxy.setSortRole(Qt.DisplayRole) + self._model.init_plugins() + return self._proxy + + def delete_plugin(self, plugin): + self.app.plugins_dir.uninstall_plugin(plugin) + self._model.remove_plugin(plugin) + + def plugin(self, index): + plugin_name = self._proxy.plugin_name(index) + if plugin_name: + try: + return next(p for p in self.app.plugins_dir.plugins if p.name == plugin_name) + except StopIteration: + pass + return None diff --git a/src/sakia/gui/dialogs/plugins_manager/plugins_manager.ui b/src/sakia/gui/dialogs/plugins_manager/plugins_manager.ui new file mode 100644 index 0000000000000000000000000000000000000000..554fea28591736f959b60d027cf1d03cf934af12 --- /dev/null +++ b/src/sakia/gui/dialogs/plugins_manager/plugins_manager.ui @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>PluginDialog</class> + <widget class="QDialog" name="PluginDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>629</width> + <height>316</height> + </rect> + </property> + <property name="windowTitle"> + <string>Plugins manager</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QGroupBox" name="groupBox_2"> + <property name="title"> + <string>Installed plugins list</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <widget class="QTableView" name="table_plugins"> + <attribute name="horizontalHeaderStretchLastSection"> + <bool>true</bool> + </attribute> + <attribute name="verticalHeaderStretchLastSection"> + <bool>true</bool> + </attribute> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"> + <property name="topMargin"> + <number>6</number> + </property> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="button_save"> + <property name="text"> + <string>Install a new plugin</string> + </property> + <property name="iconSize"> + <size> + <width>16</width> + <height>16</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="button_delete"> + <property name="text"> + <string>Uninstall selected plugin</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="button_box"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Close</set> + </property> + </widget> + </item> + </layout> + </widget> + <resources> + <include location="../../../../../res/icons/icons.qrc"/> + </resources> + <connections/> + <slots> + <slot>open_manage_wallet_coins()</slot> + <slot>change_displayed_wallet(int)</slot> + <slot>transfer_mode_changed(bool)</slot> + <slot>recipient_mode_changed(bool)</slot> + <slot>change_current_community(int)</slot> + <slot>amount_changed()</slot> + <slot>relative_amount_changed()</slot> + </slots> +</ui> diff --git a/src/sakia/gui/dialogs/plugins_manager/table_model.py b/src/sakia/gui/dialogs/plugins_manager/table_model.py new file mode 100644 index 0000000000000000000000000000000000000000..fdf0ee1c1ebeaad801e25a8127e500f2ad9365f4 --- /dev/null +++ b/src/sakia/gui/dialogs/plugins_manager/table_model.py @@ -0,0 +1,130 @@ +from PyQt5.QtCore import QAbstractTableModel, Qt, QVariant, QSortFilterProxyModel,\ + QModelIndex, QT_TRANSLATE_NOOP +from sakia.data.entities import Plugin + + +class PluginsFilterProxyModel(QSortFilterProxyModel): + def __init__(self, parent): + """ + History of all transactions + :param PyQt5.QtWidgets.QWidget parent: parent widget + """ + super().__init__(parent) + self.app = None + + def columnCount(self, parent): + return self.sourceModel().columnCount(None) - 1 + + def setSourceModel(self, source_model): + self.app = source_model.app + super().setSourceModel(source_model) + + def lessThan(self, left, right): + """ + Sort table by given column number. + """ + source_model = self.sourceModel() + left_data = source_model.data(left, Qt.DisplayRole) + right_data = source_model.data(right, Qt.DisplayRole) + return left_data < right_data + + def plugin_name(self, index): + """ + Gets available table data at given index + :param index: + :return: tuple containing (Identity, Transfer) + """ + if index.isValid() and index.row() < self.rowCount(QModelIndex()): + source_index = self.mapToSource(index) + contact_name_col = PluginsTableModel.columns_types.index('name') + contact_name = self.sourceModel().contacts_data[source_index.row()][contact_name_col] + return contact_name + return None + + def data(self, index, role): + source_index = self.mapToSource(index) + model = self.sourceModel() + source_data = model.data(source_index, role) + return source_data + + +class PluginsTableModel(QAbstractTableModel): + """ + A Qt abstract item model to display plugins in a table view + """ + + columns_types = ( + 'name', + 'description', + 'version', + 'imported' + ) + + columns_headers = ( + QT_TRANSLATE_NOOP("PluginsTableModel", 'Name'), + QT_TRANSLATE_NOOP("PluginsTableModel", 'Description'), + QT_TRANSLATE_NOOP("PluginsTableModel", 'Version'), + QT_TRANSLATE_NOOP("PluginsTableModel", 'Imported'), + ) + + def __init__(self, parent, app): + """ + History of all transactions + :param PyQt5.QtWidgets.QWidget parent: parent widget + :param sakia.app.Application app: the main application + """ + super().__init__(parent) + self.app = app + self.plugins_data = [] + + def add_plugin(self, plugin): + self.beginInsertRows(QModelIndex(), len(self.plugins_data), len(self.plugins_data)) + self.plugins_data.append(self.data_plugin(plugin)) + self.endInsertRows() + + def remove_plugin(self, plugin): + for i, data in enumerate(self.plugins_data): + if data[PluginsTableModel.columns_types.index('name')] == plugin.name: + self.beginRemoveRows(QModelIndex(), i, i) + self.plugins_data.pop(i) + self.endRemoveRows() + return + + def data_plugin(self, plugin): + """ + Converts a plugin to table data + :param sakia.data.entities.Plugin plugin: the plugin + :return: data as tuple + """ + return plugin.name, plugin.description, plugin.version, plugin.imported + + def init_plugins(self): + self.beginResetModel() + self.plugins_data = [] + for plugin in self.app.plugins_dir.plugins: + self.plugins_data.append(self.data_plugin(plugin)) + self.endResetModel() + + def rowCount(self, parent): + return len(self.plugins_data) + + def columnCount(self, parent): + return len(PluginsTableModel.columns_types) + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return PluginsTableModel.columns_headers[section] + + def data(self, index, role): + row = index.row() + col = index.column() + + if not index.isValid(): + return QVariant() + + if role in (Qt.DisplayRole, Qt.ForegroundRole, Qt.ToolTipRole): + return self.plugins_data[row][col] + + def flags(self, index): + return Qt.ItemIsSelectable | Qt.ItemIsEnabled + diff --git a/src/sakia/gui/dialogs/plugins_manager/view.py b/src/sakia/gui/dialogs/plugins_manager/view.py new file mode 100644 index 0000000000000000000000000000000000000000..f2530b6868daaefb48b41530e05ad8eaa14ec46b --- /dev/null +++ b/src/sakia/gui/dialogs/plugins_manager/view.py @@ -0,0 +1,36 @@ +from PyQt5.QtWidgets import QDialog, QAbstractItemView, QHeaderView +from PyQt5.QtCore import QModelIndex +from .plugins_manager_uic import Ui_PluginDialog + + +class PluginsManagerView(QDialog, Ui_PluginDialog): + """ + The view of the plugins manager component + """ + + def __init__(self, parent): + """ + + :param parent: + """ + super().__init__(parent) + self.setupUi(self) + + def set_table_plugins_model(self, model): + """ + Define the table history model + :param QAbstractItemModel model: + :return: + """ + self.table_plugins.setModel(model) + self.table_plugins.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table_plugins.setSortingEnabled(True) + self.table_plugins.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.table_plugins.resizeRowsToContents() + self.table_plugins.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + + def selected_plugin_index(self): + indexes = self.table_plugins.selectedIndexes() + if indexes: + return indexes[0] + return QModelIndex() diff --git a/src/sakia/gui/main_window/controller.py b/src/sakia/gui/main_window/controller.py index 2681c3f83c7cdd0060f93534589ff19a06c4b41c..b53ab1704e89bfdc9f471d03842b20b95c4df2f2 100644 --- a/src/sakia/gui/main_window/controller.py +++ b/src/sakia/gui/main_window/controller.py @@ -24,9 +24,9 @@ class MainWindowController(QObject): Init :param MainWindowView view: the ui of the mainwindow component :param sakia.gui.main_window.model.MainWindowModel: the model of the mainwindow component - :param sakia.gui.status_bar.controller.StatusBarController status_bar: the controller of the status bar component - :param sakia.gui.toolbar.controller.ToolbarController toolbar: the controller of the toolbar component - :param sakia.gui.navigation.contoller.NavigationController navigation: the controller of the navigation + :param sakia.gui.main_window.status_bar.controller.StatusBarController status_bar: the controller of the status bar component + :param sakia.gui.main_window.toolbar.controller.ToolbarController toolbar: the controller of the toolbar component + :param sakia.gui.navigation.controller.NavigationController navigation: the controller of the navigation :param PasswordAsker password_asker: the password asker of the application :type: sakia.core.app.Application @@ -83,6 +83,7 @@ class MainWindowController(QObject): main_window.view.showMaximized() else: main_window.view.show() + main_window.model.load_plugins(main_window) main_window.refresh(app.currency) return main_window diff --git a/src/sakia/gui/main_window/model.py b/src/sakia/gui/main_window/model.py index 81714afea5e593e32bdb1aea37d63e4d47cef4f4..fa6f19a799bc5768961aeaf90280163e4cf36710 100644 --- a/src/sakia/gui/main_window/model.py +++ b/src/sakia/gui/main_window/model.py @@ -10,3 +10,7 @@ class MainWindowModel(QObject): super().__init__(parent) self.app = app + def load_plugins(self, main_window): + for plugin in self.app.plugins_dir.plugins: + if plugin.imported: + plugin.module.plugin_exec(self.app, main_window) diff --git a/src/sakia/gui/main_window/toolbar/controller.py b/src/sakia/gui/main_window/toolbar/controller.py index 94ae960c37eb4874cec53deec1ce99c3e5e1c155..5cb8a8f6b38661284851c1660a84fcedd0849347 100644 --- a/src/sakia/gui/main_window/toolbar/controller.py +++ b/src/sakia/gui/main_window/toolbar/controller.py @@ -5,9 +5,11 @@ from sakia.gui.dialogs.connection_cfg.controller import ConnectionConfigControll from sakia.gui.dialogs.revocation.controller import RevocationController from sakia.gui.dialogs.transfer.controller import TransferController from sakia.gui.dialogs.contact.controller import ContactController +from sakia.gui.dialogs.plugins_manager.controller import PluginsManagerController from sakia.gui.preferences import PreferencesDialog from .model import ToolbarModel from .view import ToolbarView +import sys class ToolbarController(QObject): @@ -28,6 +30,7 @@ class ToolbarController(QObject): self.view.button_send_money.clicked.connect(self.open_transfer_money_dialog) self.view.action_add_connection.triggered.connect(self.open_add_connection_dialog) self.view.action_parameters.triggered.connect(self.open_settings_dialog) + self.view.action_plugins.triggered.connect(self.open_plugins_manager_dialog) self.view.action_about.triggered.connect(self.open_about_dialog) self.view.action_revoke_uid.triggered.connect(self.open_revocation_dialog) self.view.button_contacts.clicked.connect(self.open_contacts_dialog) @@ -66,6 +69,9 @@ class ToolbarController(QObject): def open_settings_dialog(self): PreferencesDialog(self.model.app).exec() + def open_plugins_manager_dialog(self): + PluginsManagerController.open_dialog(self, self.model.app) + def open_add_connection_dialog(self): connection_config = ConnectionConfigController.create_connection(self, self.model.app) connection_config.exec() diff --git a/src/sakia/gui/main_window/toolbar/view.py b/src/sakia/gui/main_window/toolbar/view.py index 11d406ca07bf36c242cffda03c0ddfd24a7b277c..df886770090fa6fdc02232e79a1790560b570365 100644 --- a/src/sakia/gui/main_window/toolbar/view.py +++ b/src/sakia/gui/main_window/toolbar/view.py @@ -27,6 +27,9 @@ class ToolbarView(QFrame, Ui_SakiaToolbar): self.action_parameters = QAction(self.tr("Settings"), tool_menu) tool_menu.addAction(self.action_parameters) + self.action_plugins = QAction(self.tr("Plugins manager"), tool_menu) + tool_menu.addAction(self.action_plugins) + self.action_about = QAction(self.tr("About"), tool_menu) tool_menu.addAction(self.action_about)