diff --git a/requirements.txt b/requirements.txt index d1868e55bc17fca9bf03489f5d65044fe2129ec8..145df061d3a4c6fa1c9cb843d1b39244403a12bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ duniterpy>=0.20.dev0 git+https://github.com/Insoleet/quamash.git@master asynctest -networkx -attr \ No newline at end of file +networkx \ No newline at end of file diff --git a/src/sakia/gui/dialogs/revocation/__init__.py b/src/sakia/gui/dialogs/revocation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/sakia/gui/dialogs/revocation/controller.py b/src/sakia/gui/dialogs/revocation/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..37a25b2fdb1b9e5519e2864ccf9103da7b92e810 --- /dev/null +++ b/src/sakia/gui/dialogs/revocation/controller.py @@ -0,0 +1,132 @@ +from sakia.gui.component.controller import ComponentController +from .view import RevocationView +from .model import RevocationModel +from duniterpy.documents import MalformedDocumentError +from sakia.tools.decorators import asyncify +import asyncio + + +class RevocationController(ComponentController): + """ + The revocation view + """ + + def __init__(self, parent, view, model): + """ + Constructor of the revocation component + + :param sakia.gui.revocation.view.revocationView: the view + :param sakia.gui.revocation.model.revocationModel model: the model + """ + super().__init__(parent, view, model) + + self.handle_next_step(init=True) + self.view.button_next.clicked.connect(lambda checked: self.handle_next_step(False)) + self._steps = ( + { + 'page': self.ui.page_load_file, + 'init': self.init_dialog, + 'next': self.revocation_selected + }, + { + 'page': self.ui.page_destination, + 'init': self.init_publication_page, + 'next': self.publish + } + ) + self._current_step = 0 + + @classmethod + def create(cls, parent, app, **kwargs): + """ + Instanciate a revocation component + :param sakia.gui.component.controller.ComponentController parent: + :param sakia.core.Application app: + :return: a new revocation controller + :rtype: revocationController + """ + account = kwargs['account'] + view = RevocationView(parent.view) + model = RevocationModel(None, app, account) + revocation = cls(parent, view, model) + model.setParent(revocation) + return revocation + + @classmethod + def open_dialog(cls, parent, app, account): + """ + 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 + :return: + """ + dialog = cls.create(parent, app, account=account) + dialog.refresh() + return dialog.exec() + + @property + def view(self) -> RevocationView: + return self._view + + @property + def model(self) -> RevocationModel: + return self._model + + def handle_next_step(self, init=False): + if self._current_step < len(self._steps) - 1: + if not init: + self.view.button_next.clicked.disconnect(self._steps[self._current_step]['next']) + self._current_step += 1 + self._steps[self._current_step]['init']() + self.view.stackedWidget.setCurrentWidget(self._steps[self._current_step]['page']) + self.view.button_next.clicked.connect(self._steps[self._current_step]['next']) + + def load_from_file(self): + selected_file = self.view.select_revocation_file() + try: + self.model.load_revocation(selected_file) + except FileNotFoundError: + pass + except MalformedDocumentError: + self.view.malformed_file_error() + self.button_next.setEnabled(False) + + def revocation_selected(self): + pass + + def init_publication_page(self): + communities_names = self.model.communities_names() + self.view.set_communities_names(communities_names) + + def publish(self): + self.view.button_next.setEnabled(False) + if self.view.ask_for_confirmation(): + self.accept() + else: + self.view.button_next.setEnabled(True) + + @asyncify + async def accept(self): + if self.ui.radio_community.isChecked(): + index = self.view.combo_community.currentIndex() + result, error = await self.model.send_to_community(index) + else: + server = self.ui.edit_address.text() + port = self.ui.spinbox_port.value() + result, error = await self.model.send_to_node(server, port) + + if result: + self.view.accept() + else: + await self.view.revocation_broadcast_error(error) + + def async_exec(self): + future = asyncio.Future() + self.widget.finished.connect(lambda r: future.set_result(r)) + self.widget.open() + self.refresh() + return future + + def exec(self): + self.widget.exec() diff --git a/src/sakia/gui/dialogs/revocation/model.py b/src/sakia/gui/dialogs/revocation/model.py new file mode 100644 index 0000000000000000000000000000000000000000..8647369bed24cd4d5efea375be2fbb7108e02709 --- /dev/null +++ b/src/sakia/gui/dialogs/revocation/model.py @@ -0,0 +1,67 @@ +from sakia.gui.component.model import ComponentModel +from duniterpy.documents.certification import Revocation +from duniterpy.api import bma, errors +from sakia.core.net import Node +import aiohttp + + +class RevocationModel(ComponentModel): + """ + The model of HomeScreen component + """ + + def __init__(self, parent, app, account): + super().__init__(parent) + self.app = app + self.account = account + + self.revocation_document = None + self.revoked_selfcert = None + + def load_revocation(self, path): + """ + Load a revocation document from a file + :param str path: + """ + with open(path, 'r') as file: + file_content = file.read() + self.revocation_document = Revocation.from_signed_raw(file_content) + self.revoked_selfcert = Revocation.extract_self_cert(file_content) + + def communities_names(self): + return [c.name for c in self.account.communities] + + async def send_to_community(self, index): + community = self.account.communities[index] + responses = await community.bma_access.broadcast(bma.wot.Revoke, {}, + { + 'revocation': self.revocation_document.signed_raw( + self.revoked_selfcert) + }) + + result = False, "" + for r in responses: + if r.status == 200: + result = True, (await r.json()) + elif not result[0]: + result = False, (await r.text()) + else: + await r.release() + + return result + + async def send_to_node(self, server, port): + session = aiohttp.ClientSession() + try: + node = await Node.from_address(None, server, port, session=session) + conn_handler = node.endpoint.conn_handler() + await bma.wot.Revoke(conn_handler).post(session, + revocation=self.revocation_document.signed_raw( + self.revoked_selfcert)) + except (ValueError, errors.DuniterError, + aiohttp.errors.ClientError, aiohttp.errors.DisconnectedError, + aiohttp.errors.TimeoutError) as e: + return False, str(e) + finally: + session.close() + return True, "" diff --git a/res/ui/revocation.ui b/src/sakia/gui/dialogs/revocation/revocation.ui similarity index 97% rename from res/ui/revocation.ui rename to src/sakia/gui/dialogs/revocation/revocation.ui index d7edc4990e334b82dbe71405c249084bd05ad234..21641ab85afbae8571c4904f07b91166987cbf16 100644 --- a/res/ui/revocation.ui +++ b/src/sakia/gui/dialogs/revocation/revocation.ui @@ -166,6 +166,13 @@ QGroupBox::title { </property> </widget> </item> + <item> + <widget class="QLabel" name="label_target"> + <property name="text"> + <string/> + </property> + </widget> + </item> <item> <spacer name="verticalSpacer"> <property name="orientation"> diff --git a/src/sakia/gui/dialogs/revocation/view.py b/src/sakia/gui/dialogs/revocation/view.py new file mode 100644 index 0000000000000000000000000000000000000000..d296bf4e472246e69fb8f33b91925ec2d69ea907 --- /dev/null +++ b/src/sakia/gui/dialogs/revocation/view.py @@ -0,0 +1,105 @@ +from PyQt5.QtWidgets import QDialog, QFileDialog, QMessageBox +from .revocation_uic import Ui_RevocationDialog +from enum import Enum +from sakia.gui.widgets.dialogs import QAsyncMessageBox +from sakia.tools.decorators import asyncify + + +class RevocationView(QDialog, Ui_RevocationDialog): + """ + Home screen view + """ + + class PublicationMode(Enum): + ADDRESS = 0 + COMMUNITY = 1 + + def __init__(self, parent): + """ + Constructor + """ + super().__init__(parent) + self.setupUi(self) + + self.button_next.setEnabled(False) + self.button_load.clicked.connect(self.load_from_file) + + self.radio_address.toggled.connect(lambda c: self.publication_mode_changed(RevocationView.PublicationMode.ADDRESS)) + self.radio_community.toggled.connect(lambda c: self.publication_mode_changed(RevocationView.PublicationMode.COMMUNITY)) + self.edit_address.textChanged.connect(self.refresh) + self.spinbox_port.valueChanged.connect(self.refresh) + self.combo_community.currentIndexChanged.connect(self.refresh) + + def publication_mode_changed(self, radio): + self.edit_address.setEnabled(radio == RevocationView.PublicationMode.ADDRESS) + self.spinbox_port.setEnabled(radio == RevocationView.PublicationMode.ADDRESS) + self.combo_community.setEnabled(radio == RevocationView.PublicationMode.COMMUNITY) + self.refresh_revocation_label() + + def refresh_target(self): + if self.radio_community.isChecked(): + target = self.tr( + "All nodes of community {name}".format(name=self.combo_community.currentText())) + elif self.radio_address.isChecked(): + target = self.tr("Address {address}:{port}".format(address=self.edit_address.text(), + port=self.spinbox_port.value())) + else: + target = "" + self.label_target.setText(""" +<h4>Publication address</h4> +<div>{target}</div> +""".format(target=target)) + + def select_revocation_file(self): + """ + Get a revocation file using a file dialog + :rtype: str + """ + selected_files = QFileDialog.getOpenFileName(self.widget, + self.tr("Load a revocation file"), + "", + self.tr("All text files (*.txt)")) + selected_file = selected_files[0] + return selected_file + + def malformed_file_error(self): + QMessageBox.critical(self, self.tr("Error loading document"), + self.tr("Loaded document is not a revocation document"), + buttons=QMessageBox.Ok) + + async def revocation_broadcast_error(self, error): + await QAsyncMessageBox.critical(self, self.tr("Error broadcasting document"), + error) + + def show_revoked_selfcert(self, selfcert): + text = self.tr(""" + <div>Identity revoked : {uid} (public key : {pubkey}...)</div> + <div>Identity signed on block : {timestamp}</div> + """.format(uid=selfcert.uid, + pubkey=selfcert.pubkey[:12], + timestamp=selfcert.timestamp)) + self.label_revocation_content.setText(text) + + def set_communities_names(self, names): + self.combo_community.clear() + for name in names: + self.combo_community.addItem(name) + self.radio_community.setChecked(True) + + def ask_for_confirmation(self): + answer = QMessageBox.warning(self.widget, self.tr("Revocation"), + self.tr("""<h4>The publication of this document will remove your identity from the network.</h4> + <li> + <li> <b>This identity won't be able to join the targeted community anymore.</b> </li> + <li> <b>This identity won't be able to generate Universal Dividends anymore.</b> </li> + <li> <b>This identity won't be able to certify individuals anymore.</b> </li> + </li> + Please think twice before publishing this document. + """), QMessageBox.Ok | QMessageBox.Cancel) + return answer == QMessageBox.Ok + + @asyncify + async def accept(self): + await QAsyncMessageBox.information(self.widget, self.tr("Revocation broadcast"), + self.tr("The document was successfully broadcasted.")) + super().accept() diff --git a/src/sakia/gui/revocation.py b/src/sakia/gui/revocation.py deleted file mode 100644 index 31b3d6b31beb8d14df7a67f1372334eb5bf3911b..0000000000000000000000000000000000000000 --- a/src/sakia/gui/revocation.py +++ /dev/null @@ -1,208 +0,0 @@ -import asyncio -import aiohttp -import logging -from duniterpy.api import errors -from duniterpy.api import bma -from duniterpy.documents import MalformedDocumentError -from duniterpy.documents.certification import Revocation -from sakia.core.net import Node -from PyQt5.QtWidgets import QDialog, QFileDialog, QMessageBox -from PyQt5.QtCore import QObject - -from .widgets.dialogs import QAsyncMessageBox -from ..tools.decorators import asyncify -from ..presentation.revocation_uic import Ui_RevocationDialog - - -class RevocationDialog(QObject): - """ - A dialog to revoke an identity - """ - - def __init__(self, app, account, widget, ui): - """ - Constructor if a certification dialog - - :param sakia.core.Application app: - :param sakia.core.Account account: - :param PyQt5.QtWidgets widget: the widget of the dialog - :param sakia.gen_resources.revocation_uic.Ui_RevocationDialog ui: the view of the certification dialog - :return: - """ - super().__init__() - self.widget = widget - self.ui = ui - self.ui.setupUi(self.widget) - self.app = app - self.account = account - self.revocation_document = None - self.revoked_selfcert = None - self._steps = ( - { - 'page': self.ui.page_load_file, - 'init': self.init_dialog, - 'next': self.revocation_selected - }, - { - 'page': self.ui.page_destination, - 'init': self.init_publication_page, - 'next': self.publish - } - ) - self._current_step = 0 - self.handle_next_step(init=True) - self.ui.button_next.clicked.connect(lambda checked: self.handle_next_step(False)) - - def handle_next_step(self, init=False): - if self._current_step < len(self._steps) - 1: - if not init: - self.ui.button_next.clicked.disconnect(self._steps[self._current_step]['next']) - self._current_step += 1 - self._steps[self._current_step]['init']() - self.ui.stackedWidget.setCurrentWidget(self._steps[self._current_step]['page']) - self.ui.button_next.clicked.connect(self._steps[self._current_step]['next']) - - def init_dialog(self): - self.ui.button_next.setEnabled(False) - self.ui.button_load.clicked.connect(self.load_from_file) - - self.ui.radio_address.toggled.connect(lambda c: self.publication_mode_changed("address")) - self.ui.radio_community.toggled.connect(lambda c: self.publication_mode_changed("community")) - self.ui.edit_address.textChanged.connect(self.refresh) - self.ui.spinbox_port.valueChanged.connect(self.refresh) - self.ui.combo_community.currentIndexChanged.connect(self.refresh) - - def publication_mode_changed(self, radio): - self.ui.edit_address.setEnabled(radio == "address") - self.ui.spinbox_port.setEnabled(radio == "address") - self.ui.combo_community.setEnabled(radio == "community") - self.refresh() - - def load_from_file(self): - selected_files = QFileDialog.getOpenFileName(self.widget, - self.tr("Load a revocation file"), - "", - self.tr("All text files (*.txt)")) - selected_file = selected_files[0] - try: - with open(selected_file, 'r') as file: - file_content = file.read() - self.revocation_document = Revocation.from_signed_raw(file_content) - self.revoked_selfcert = Revocation.extract_self_cert(file_content) - self.refresh() - self.ui.button_next.setEnabled(True) - except FileNotFoundError: - pass - except MalformedDocumentError: - QMessageBox.critical(self.widget, self.tr("Error loading document"), - self.tr("Loaded document is not a revocation document"), - QMessageBox.Ok) - self.ui.button_next.setEnabled(False) - - def revocation_selected(self): - pass - - def init_publication_page(self): - self.ui.combo_community.clear() - if self.account: - for community in self.account.communities: - self.ui.combo_community.addItem(community.currency) - self.ui.radio_community.setChecked(True) - else: - self.ui.radio_address.setChecked(True) - self.ui.radio_community.setEnabled(False) - - def publish(self): - self.ui.button_next.setEnabled(False) - answer = QMessageBox.warning(self.widget, self.tr("Revocation"), - self.tr("""<h4>The publication of this document will remove your identity from the network.</h4> -<li> - <li> <b>This identity won't be able to join the targeted community anymore.</b> </li> - <li> <b>This identity won't be able to generate Universal Dividends anymore.</b> </li> - <li> <b>This identity won't be able to certify individuals anymore.</b> </li> -</li> -Please think twice before publishing this document. -"""), QMessageBox.Ok | QMessageBox.Cancel) - if answer == QMessageBox.Ok: - self.accept() - else: - self.ui.button_next.setEnabled(True) - - @asyncify - async def accept(self): - try: - session = aiohttp.ClientSession() - if self.ui.radio_community.isChecked(): - community = self.account.communities[self.ui.combo_community.currentIndex()] - await community.bma_access.broadcast(bma.wot.Revoke, {}, - { - 'revocation': self.revocation_document.signed_raw(self.revoked_selfcert) - }) - elif self.ui.radio_address.isChecked(): - server = self.ui.edit_address.text() - port = self.ui.spinbox_port.value() - node = await Node.from_address(None, server, port, session=session) - conn_handler = node.endpoint.conn_handler() - await bma.wot.Revoke(conn_handler).post(session, - revocation=self.revocation_document.signed_raw(self.revoked_selfcert)) - except (MalformedDocumentError, ValueError, errors.DuniterError, - aiohttp.errors.ClientError, aiohttp.errors.DisconnectedError, - aiohttp.errors.TimeoutError) as e: - await QAsyncMessageBox.critical(self.widget, self.tr("Error broadcasting document"), - str(e)) - else: - await QAsyncMessageBox.information(self.widget, self.tr("Revocation broadcast"), - self.tr("The document was successfully broadcasted.")) - self.widget.accept() - finally: - session.close() - - @classmethod - def open_dialog(cls, app, account): - """ - Certify and identity - :param sakia.core.Application app: the application - :param sakia.core.Account account: the account certifying the identity - :return: - """ - dialog = cls(app, account, QDialog(), Ui_RevocationDialog()) - dialog.refresh() - return dialog.exec() - - def refresh(self): - if self.revoked_selfcert: - text = self.tr(""" -<div>Identity revoked : {uid} (public key : {pubkey}...)</div> -<div>Identity signed on block : {timestamp}</div> - """.format(uid=self.revoked_selfcert.uid, - pubkey=self.revoked_selfcert.pubkey[:12], - timestamp=self.revoked_selfcert.timestamp)) - - self.ui.label_revocation_content.setText(text) - - if self.ui.radio_community.isChecked(): - target = self.tr("All nodes of community {name}".format(name=self.ui.combo_community.currentText())) - elif self.ui.radio_address.isChecked(): - target = self.tr("Address {address}:{port}".format(address=self.ui.edit_address.text(), - port=self.ui.spinbox_port.value())) - else: - target = "" - self.ui.label_revocation_info.setText(""" -<h4>Revocation document</h4> -<div>{text}</div> -<h4>Publication address</h4> -<div>{target}</div> -""".format(text=text, - target=target)) - else: - self.ui.label_revocation_content.setText("") - - def async_exec(self): - future = asyncio.Future() - self.widget.finished.connect(lambda r: future.set_result(r)) - self.widget.open() - self.refresh() - return future - - def exec(self): - self.widget.exec()