diff --git a/openlp/core/api/deploy.py b/openlp/core/api/deploy.py index 48343712c..2aef03ec3 100644 --- a/openlp/core/api/deploy.py +++ b/openlp/core/api/deploy.py @@ -21,11 +21,16 @@ """ Download and "install" the remote web client """ +import json +import logging from zipfile import ZipFile -from PyQt5 import QtWidgets from openlp.core.common.applocation import AppLocation -from openlp.core.common.httputils import download_file, get_url_file_size, get_web_page +from openlp.core.common.httputils import download_file, get_web_page, get_openlp_user_agent + +REMOTE_URL = 'https://get.openlp.org/remote/' + +log = logging.getLogger(__name__) def deploy_zipfile(app_root_path, zip_name): @@ -42,28 +47,32 @@ def deploy_zipfile(app_root_path, zip_name): web_zip.extractall(app_root_path) -def download_sha256(): +def download_version_info(): """ - Download the config file to extract the sha256 and version number + Download the version information file """ - user_agent = 'OpenLP/' + QtWidgets.QApplication.applicationVersion() try: - web_config = get_web_page('https://get.openlp.org/webclient/download.cfg', headers={'User-Agent': user_agent}) + file_contents = get_web_page(REMOTE_URL + 'version.json', headers={'User-Agent': get_openlp_user_agent()}) except ConnectionError: return False - if not web_config: + if not file_contents: return None - file_bits = web_config.split() - return file_bits[0], file_bits[2] + return json.loads(file_contents) def download_and_check(callback=None): """ Download the web site and deploy it. """ - sha256, version = download_sha256() - file_size = get_url_file_size('https://get.openlp.org/webclient/site.zip') + version_info = download_version_info() + if not version_info: + log.warning('Unable to access the version information, abandoning download') + # Show the user an error message + return None + file_size = version_info['latest']['size'] callback.setRange(0, file_size) - if download_file(callback, 'https://get.openlp.org/webclient/site.zip', - AppLocation.get_section_data_path('remotes') / 'site.zip'): - deploy_zipfile(AppLocation.get_section_data_path('remotes'), 'site.zip') + if download_file(callback, REMOTE_URL + '{version}/{filename}'.format(**version_info['latest']), + AppLocation.get_section_data_path('remotes') / 'remote.zip'): + deploy_zipfile(AppLocation.get_section_data_path('remotes'), 'remote.zip') + return version_info['latest']['version'] + return None diff --git a/openlp/core/api/http/server.py b/openlp/core/api/http/server.py index e279c5d47..45660abcd 100644 --- a/openlp/core/api/http/server.py +++ b/openlp/core/api/http/server.py @@ -23,16 +23,12 @@ The :mod:`http` module contains the API web server. This is a lightweight web se with OpenLP. It uses JSON to communicate with the remotes. """ import logging -import time from secrets import token_hex -from PyQt5 import QtCore, QtWidgets from waitress.server import create_server -from openlp.core.api.deploy import download_and_check, download_sha256 from openlp.core.api.poll import Poller from openlp.core.common.applocation import AppLocation -from openlp.core.common.i18n import UiStrings, translate from openlp.core.common.mixins import LogMixin, RegistryProperties from openlp.core.common.path import create_paths from openlp.core.common.registry import Registry, RegistryBase @@ -86,9 +82,6 @@ class HttpServer(RegistryBase, RegistryProperties, LogMixin): if not Registry().get_flag('no_web_server'): worker = HttpWorker() run_thread(worker, 'http_server') - Registry().register_function('download_website', self.first_time) - Registry().register_function('get_website_version', self.website_version) - Registry().set_flag('website_version', '0.0') def bootstrap_post_set_up(self): """ @@ -97,66 +90,3 @@ class HttpServer(RegistryBase, RegistryProperties, LogMixin): create_paths(AppLocation.get_section_data_path('remotes')) self.poller = Poller() Registry().register('poller', self.poller) - - def first_time(self): - """ - Import web site code if active - """ - self.application.process_events() - progress = DownloadProgressDialog(self) - progress.forceShow() - self.application.process_events() - time.sleep(1) - download_and_check(progress) - self.application.process_events() - time.sleep(1) - progress.close() - self.application.process_events() - self.settings.setValue('remotes/download version', self.version) - - def website_version(self): - """ - Download and save the website version and sha256 - :return: None - """ - sha256, self.version = download_sha256() - Registry().set_flag('website_sha256', sha256) - Registry().set_flag('website_version', self.version) - - -class DownloadProgressDialog(QtWidgets.QProgressDialog): - """ - Local class to handle download display based and supporting httputils:get_web_page - """ - def __init__(self, parent): - super(DownloadProgressDialog, self).__init__(parent.main_window) - self.parent = parent - self.setWindowModality(QtCore.Qt.WindowModal) - self.setWindowTitle(translate('RemotePlugin', 'Importing Website')) - self.setLabelText(UiStrings().StartingImport) - self.setCancelButton(None) - self.setRange(0, 1) - self.setMinimumDuration(0) - self.was_cancelled = False - self.previous_size = 0 - - def update_progress(self, count, block_size): - """ - Calculate and display the download progress. - """ - increment = (count * block_size) - self.previous_size - self._increment_progress_bar(None, increment) - self.previous_size = count * block_size - - def _increment_progress_bar(self, status_text, increment=1): - """ - Update the wizard progress page. - - :param status_text: Current status information to display. - :param increment: The value to increment the progress bar by. - """ - if status_text: - self.setText(status_text) - if increment > 0: - self.setValue(self.value() + increment) - self.parent.application.process_events() diff --git a/openlp/core/api/tab.py b/openlp/core/api/tab.py index caa3219e5..0cc63610f 100644 --- a/openlp/core/api/tab.py +++ b/openlp/core/api/tab.py @@ -21,13 +21,17 @@ """ The :mod:`~openlp.core.api.tab` module contains the settings tab for the API """ +from time import sleep + from PyQt5 import QtCore, QtGui, QtWidgets +from openlp.core.api.deploy import download_and_check, download_version_info from openlp.core.common import get_network_interfaces -from openlp.core.common.i18n import UiStrings, translate +from openlp.core.common.i18n import translate from openlp.core.common.registry import Registry from openlp.core.lib.settingstab import SettingsTab from openlp.core.ui.icons import UiIcons +from openlp.core.widgets.dialogs import DownloadProgressDialog ZERO_URL = '0.0.0.0' @@ -39,7 +43,8 @@ class ApiTab(SettingsTab): """ def __init__(self, parent): self.icon_path = UiIcons().remote - advanced_translated = translate('OpenLP.AdvancedTab', 'Advanced') + advanced_translated = translate('OpenLP.APITab', 'API') + self.master_version = None super(ApiTab, self).__init__(parent, 'api', advanced_translated) def setup_ui(self): @@ -115,23 +120,30 @@ class ApiTab(SettingsTab): self.password.setObjectName('password') self.user_login_layout.addRow(self.password_label, self.password) self.left_layout.addWidget(self.user_login_group_box) - self.update_site_group_box = QtWidgets.QGroupBox(self.left_column) - self.update_site_group_box.setCheckable(True) - self.update_site_group_box.setChecked(False) - self.update_site_group_box.setObjectName('update_site_group_box') - self.update_site_layout = QtWidgets.QFormLayout(self.update_site_group_box) - self.update_site_layout.setObjectName('update_site_layout') - self.current_version_label = QtWidgets.QLabel(self.update_site_group_box) + self.web_remote_group_box = QtWidgets.QGroupBox(self.left_column) + self.web_remote_group_box.setObjectName('web_remote_group_box') + self.web_remote_layout = QtWidgets.QGridLayout(self.web_remote_group_box) + self.web_remote_layout.setObjectName('web_remote_layout') + self.current_version_label = QtWidgets.QLabel(self.web_remote_group_box) + self.web_remote_layout.addWidget(self.current_version_label, 0, 0) self.current_version_label.setObjectName('current_version_label') - self.current_version_value = QtWidgets.QLabel(self.update_site_group_box) + self.current_version_value = QtWidgets.QLabel(self.web_remote_group_box) self.current_version_value.setObjectName('current_version_value') - self.update_site_layout.addRow(self.current_version_label, self.current_version_value) - self.master_version_label = QtWidgets.QLabel(self.update_site_group_box) + self.web_remote_layout.addWidget(self.current_version_value, 0, 1) + self.upgrade_button = QtWidgets.QPushButton(self.web_remote_group_box) + self.upgrade_button.setEnabled(False) + self.upgrade_button.setObjectName('upgrade_button') + self.web_remote_layout.addWidget(self.upgrade_button, 0, 2) + self.master_version_label = QtWidgets.QLabel(self.web_remote_group_box) self.master_version_label.setObjectName('master_version_label') - self.master_version_value = QtWidgets.QLabel(self.update_site_group_box) + self.web_remote_layout.addWidget(self.master_version_label, 1, 0) + self.master_version_value = QtWidgets.QLabel(self.web_remote_group_box) self.master_version_value.setObjectName('master_version_value') - self.update_site_layout.addRow(self.master_version_label, self.master_version_value) - self.left_layout.addWidget(self.update_site_group_box) + self.web_remote_layout.addWidget(self.master_version_value, 1, 1) + self.check_version_button = QtWidgets.QPushButton(self.web_remote_group_box) + self.check_version_button.setObjectName('check_version_button') + self.web_remote_layout.addWidget(self.check_version_button, 1, 2) + self.left_layout.addWidget(self.web_remote_group_box) self.app_group_box = QtWidgets.QGroupBox(self.right_column) self.app_group_box.setObjectName('app_group_box') self.right_layout.addWidget(self.app_group_box) @@ -152,11 +164,13 @@ class ApiTab(SettingsTab): self.twelve_hour_check_box.stateChanged.connect(self.on_twelve_hour_check_box_changed) self.thumbnails_check_box.stateChanged.connect(self.on_thumbnails_check_box_changed) self.address_edit.textChanged.connect(self.set_urls) + self.upgrade_button.clicked.connect(self.on_upgrade_button_clicked) + self.check_version_button.clicked.connect(self.on_check_version_button_clicked) def retranslate_ui(self): self.tab_title_visible = translate('RemotePlugin.RemoteTab', 'Remote Interface') self.server_settings_group_box.setTitle(translate('RemotePlugin.RemoteTab', 'Server Settings')) - self.address_label.setText(translate('RemotePlugin.RemoteTab', 'Serve on IP address:')) + self.address_label.setText(translate('RemotePlugin.RemoteTab', 'IP address:')) self.port_label.setText(translate('RemotePlugin.RemoteTab', 'Port number:')) self.remote_url_label.setText(translate('RemotePlugin.RemoteTab', 'Remote URL:')) self.stage_url_label.setText(translate('RemotePlugin.RemoteTab', 'Stage view URL:')) @@ -171,12 +185,14 @@ class ApiTab(SettingsTab): 'Scan the QR code or click download to download an app for your mobile device' ).format(qr='https://openlp.org/#mobile-app-downloads')) self.user_login_group_box.setTitle(translate('RemotePlugin.RemoteTab', 'User Authentication')) - self.aa = UiStrings() - self.update_site_group_box.setTitle(UiStrings().WebDownloadText) + self.web_remote_group_box.setTitle(translate('RemotePlugin.RemoteTab', 'Web Remote')) + self.check_version_button.setText(translate('RemotePlugin.RemoteTab', 'Check for Updates')) + self.upgrade_button.setText(translate('RemotePlugin.RemoteTab', 'Upgrade')) self.user_id_label.setText(translate('RemotePlugin.RemoteTab', 'User id:')) self.password_label.setText(translate('RemotePlugin.RemoteTab', 'Password:')) - self.current_version_label.setText(translate('RemotePlugin.RemoteTab', 'Current Version number:')) - self.master_version_label.setText(translate('RemotePlugin.RemoteTab', 'Latest Version number:')) + self.current_version_label.setText(translate('RemotePlugin.RemoteTab', 'Current version:')) + self.master_version_label.setText(translate('RemotePlugin.RemoteTab', 'Latest version:')) + self.unknown_version = translate('RemotePlugin.RemoteTab', '(unknown)') def set_urls(self): """ @@ -206,6 +222,13 @@ class ApiTab(SettingsTab): break return ip_address + def can_enable_upgrade_button(self): + """ + Do a couple checks to set the upgrade button state + """ + return self.master_version_value.text() != self.unknown_version and \ + self.master_version_value.text() != self.current_version_value.text() + def load(self): """ Load the configuration and update the server configuration if necessary @@ -219,10 +242,9 @@ class ApiTab(SettingsTab): self.user_login_group_box.setChecked(self.settings.value(self.settings_section + '/authentication enabled')) self.user_id.setText(self.settings.value(self.settings_section + '/user id')) self.password.setText(self.settings.value(self.settings_section + '/password')) - self.current_version_value.setText(self.settings.value('remotes/download version')) - self.master_version_value.setText(Registry().get_flag('website_version')) - if self.master_version_value.text() == self.current_version_value.text(): - self.update_site_group_box.setEnabled(False) + self.current_version_value.setText(self.settings.value(self.settings_section + '/download version')) + self.master_version_value.setText(self.master_version or self.unknown_version) + self.upgrade_button.setEnabled(self.can_enable_upgrade_button()) self.set_urls() def save(self): @@ -237,8 +259,6 @@ class ApiTab(SettingsTab): self.settings.setValue(self.settings_section + '/authentication enabled', self.user_login_group_box.isChecked()) self.settings.setValue(self.settings_section + '/user id', self.user_id.text()) self.settings.setValue(self.settings_section + '/password', self.password.text()) - if self.update_site_group_box.isChecked(): - self.settings_form.register_post_process('download_website') def on_twelve_hour_check_box_changed(self, check_state): """ @@ -257,3 +277,39 @@ class ApiTab(SettingsTab): # we have a set value convert to True/False if check_state == QtCore.Qt.Checked: self.thumbnails = True + + def on_check_version_button_clicked(self): + """ + Check for the latest version on the server + """ + app = Registry().get('application') + app.set_busy_cursor() + app.process_events() + version_info = download_version_info() + app.process_events() + self.master_version_value.setText(version_info['latest']['version']) + self.upgrade_button.setEnabled(self.can_enable_upgrade_button()) + app.process_events() + app.set_normal_cursor() + app.process_events() + if self.can_enable_upgrade_button(): + Registry().get('main_window').information_message('New version available!', + 'There\'s a new version of the web remote available.') + + def on_upgrade_button_clicked(self): + """ + Download/upgrade the web remote + """ + app = Registry().get('application') + progress = DownloadProgressDialog(self, app) + progress.show() + app.process_events() + sleep(0.5) + downloaded_version = download_and_check(progress) + app.process_events() + sleep(0.5) + progress.close() + app.process_events() + self.current_version_value.setText(downloaded_version) + self.settings.setValue(self.settings_section + '/download version', downloaded_version) + self.upgrade_button.setEnabled(self.can_enable_upgrade_button()) diff --git a/openlp/core/api/zeroconf.py b/openlp/core/api/zeroconf.py index 7a8615a20..8871b424c 100644 --- a/openlp/core/api/zeroconf.py +++ b/openlp/core/api/zeroconf.py @@ -25,28 +25,41 @@ RESTful API for devices on the network to discover. import socket from time import sleep -from zeroconf import ServiceInfo, Zeroconf +from zeroconf import ServiceInfo, Zeroconf, Error, NonUniqueNameException from openlp.core.common import get_network_interfaces +from openlp.core.common.i18n import UiStrings from openlp.core.common.registry import Registry from openlp.core.threading import ThreadWorker, run_thread +def _get_error_message(exc): + """ + Zeroconf doesn't have error messages, so we have to make up our own + """ + error_message = UiStrings().ZeroconfErrorIntro + '\n\n' + if isinstance(exc, NonUniqueNameException): + error_message += UiStrings().ZeroconfNonUniqueError + else: + error_message += UiStrings().ZeroconfGenericError + return error_message + + class ZeroconfWorker(ThreadWorker): """ This thread worker runs a Zeroconf service """ - address = None + ip_address = None http_port = 4316 ws_port = 4317 _can_run = False - def __init__(self, ip_address, http_port=4316, ws_port=4317): + def __init__(self, addresses, http_port=4316, ws_port=4317): """ Create the worker for the Zeroconf service """ super().__init__() - self.address = socket.inet_aton(ip_address) + self.addresses = addresses self.http_port = http_port self.ws_port = ws_port @@ -61,20 +74,24 @@ class ZeroconfWorker(ThreadWorker): """ Start the service """ + addresses = [socket.inet_aton(addr) for addr in self.addresses] http_info = ServiceInfo('_http._tcp.local.', 'OpenLP._http._tcp.local.', - address=self.address, port=self.http_port, properties={}) + addresses=addresses, port=self.http_port, properties={}) ws_info = ServiceInfo('_ws._tcp.local.', 'OpenLP._ws._tcp.local.', - address=self.address, port=self.ws_port, properties={}) + addresses=addresses, port=self.ws_port, properties={}) zc = Zeroconf() - zc.register_service(http_info) - zc.register_service(ws_info) - self._can_run = True - while self.can_run(): - sleep(0.1) - zc.unregister_service(http_info) - zc.unregister_service(ws_info) - zc.close() - self.quit.emit() + try: + zc.register_service(http_info) + zc.register_service(ws_info) + self._can_run = True + while self.can_run(): + sleep(0.1) + except Error as e: + self.error.emit('Cannot start Zeroconf service', _get_error_message(e)) + finally: + zc.unregister_all_services() + zc.close() + self.quit.emit() def stop(self): """ @@ -92,6 +109,5 @@ def start_zeroconf(): return http_port = Registry().get('settings').value('api/port') ws_port = Registry().get('settings').value('api/websocket port') - for name, interface in get_network_interfaces().items(): - worker = ZeroconfWorker(interface['ip'], http_port, ws_port) - run_thread(worker, 'api_zeroconf_{name}'.format(name=name)) + worker = ZeroconfWorker([iface['ip'] for iface in get_network_interfaces().values()], http_port, ws_port) + run_thread(worker, 'api_zeroconf') diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py index 6a2dbf7b9..7cd948430 100644 --- a/openlp/core/common/httputils.py +++ b/openlp/core/common/httputils.py @@ -102,9 +102,9 @@ def get_proxy_settings(mode=None): return {'http': http_value, 'https': https_value} -def get_user_agent(): +def get_random_user_agent(): """ - Return a user agent customised for the platform the user is on. + Return a random user agent customised for the platform the user is on. """ browser_list = USER_AGENTS.get(sys.platform, None) if not browser_list: @@ -113,6 +113,13 @@ def get_user_agent(): return browser_list[random_index] +def get_openlp_user_agent(): + """ + Return the OpenLP user agent + """ + return 'OpenLP/' + Registry().get('application-qt').applicationVersion() + + def get_web_page(url, headers=None, update_openlp=False, proxy=None): """ Attempts to download the webpage at url and returns that page or None. @@ -128,7 +135,7 @@ def get_web_page(url, headers=None, update_openlp=False, proxy=None): if not headers: headers = {} if 'user-agent' not in [key.lower() for key in headers.keys()]: - headers['User-Agent'] = get_user_agent() + headers['User-Agent'] = get_random_user_agent() if not isinstance(proxy, dict): proxy = get_proxy_settings(mode=proxy) log.debug('Downloading URL = %s' % url) @@ -207,7 +214,7 @@ def download_file(update_object, url, file_path, sha256=None, proxy=None): hasher = hashlib.sha256() # Download until finished or canceled. for chunk in response.iter_content(chunk_size=block_size): - if hasattr(update_object, 'was_cancelled') and update_object.was_cancelled: + if hasattr(update_object, 'is_cancelled') and update_object.is_cancelled: break saved_file.write(chunk) if sha256: @@ -233,7 +240,7 @@ def download_file(update_object, url, file_path, sha256=None, proxy=None): retries += 1 time.sleep(0.1) continue - if hasattr(update_object, 'was_cancelled') and update_object.was_cancelled and file_path.exists(): + if hasattr(update_object, 'is_cancelled') and update_object.is_cancelled and file_path.exists(): file_path.unlink() return True @@ -251,21 +258,21 @@ class DownloadWorker(ThreadWorker): """ self._base_url = base_url self._file_name = file_name - self.was_cancelled = False + self.is_cancelled = False super().__init__() def start(self): """ Download the url to the temporary directory """ - if self.was_cancelled: + if self.is_cancelled: self.quit.emit() return try: dest_path = Path(gettempdir()) / 'openlp' / self._file_name url = '{url}{name}'.format(url=self._base_url, name=self._file_name) is_success = download_file(self, url, dest_path) - if is_success and not self.was_cancelled: + if is_success and not self.is_cancelled: self.download_succeeded.emit(dest_path) else: self.download_failed.emit() @@ -280,4 +287,4 @@ class DownloadWorker(ThreadWorker): """ A slot to allow the download to be cancelled from outside of the thread """ - self.was_cancelled = True + self.is_cancelled = True diff --git a/openlp/core/common/i18n.py b/openlp/core/common/i18n.py index 6df83d7d5..180215934 100644 --- a/openlp/core/common/i18n.py +++ b/openlp/core/common/i18n.py @@ -452,6 +452,10 @@ class UiStrings(metaclass=Singleton): self.ViewMode = translate('OpenLP.Ui', 'View Mode') self.Video = translate('OpenLP.Ui', 'Video') self.WebDownloadText = translate('OpenLP.Ui', 'Web Interface, Download and Install latest Version') + self.ZeroconfErrorIntro = translate('OpenLP.Ui', 'There was a problem avertising OpenLP\'s remote ' + 'interface on the network:') + self.ZeroconfGenericError = translate('OpenLP.Ui', 'An unknown error occurred') + self.ZeroconfNonUniqueError = translate('OpenLP.Ui', 'OpenLP already seems to be advertising itself') book_chapter = translate('OpenLP.Ui', 'Book Chapter') chapter = translate('OpenLP.Ui', 'Chapter') verse = translate('OpenLP.Ui', 'Verse') diff --git a/openlp/core/common/settings.py b/openlp/core/common/settings.py index 63dd0399b..d1709db8c 100644 --- a/openlp/core/common/settings.py +++ b/openlp/core/common/settings.py @@ -194,6 +194,7 @@ class Settings(QtCore.QSettings): 'api/authentication enabled': False, 'api/ip address': '0.0.0.0', 'api/thumbnails': True, + 'api/download version': '0.0', 'bibles/db type': 'sqlite', 'bibles/db username': '', 'bibles/db password': '', @@ -271,7 +272,6 @@ class Settings(QtCore.QSettings): 'media/vlc arguments': '', 'media/live volume': 50, 'media/preview volume': 0, - 'remotes/download version': '0.0', 'players/background color': '#000000', 'planningcenter/status': PluginStatus.Inactive, 'planningcenter/application_id': '', diff --git a/openlp/core/threading.py b/openlp/core/threading.py index b9a9c6a8b..b2e4d7579 100644 --- a/openlp/core/threading.py +++ b/openlp/core/threading.py @@ -32,6 +32,7 @@ class ThreadWorker(QtCore.QObject, LogMixin): The :class:`~openlp.core.threading.ThreadWorker` class provides a base class for all worker objects """ quit = QtCore.pyqtSignal() + error = QtCore.pyqtSignal(str, str) def start(self): """ @@ -51,6 +52,7 @@ def run_thread(worker, thread_name, can_start=True): if not thread_name: raise ValueError('A thread_name is required when calling the "run_thread" function') application = Registry().get('application') + main_window = Registry().get('main_window') if thread_name in application.worker_threads: raise KeyError('A thread with the name "{}" has already been created, please use another'.format(thread_name)) # Create the thread and add the thread and the worker to the parent @@ -65,6 +67,7 @@ def run_thread(worker, thread_name, can_start=True): thread.started.connect(worker.start) worker.quit.connect(thread.quit) worker.quit.connect(worker.deleteLater) + worker.error.connect(main_window.error_message) thread.finished.connect(thread.deleteLater) thread.finished.connect(make_remove_thread(thread_name)) if can_start: diff --git a/openlp/core/widgets/dialogs.py b/openlp/core/widgets/dialogs.py index 68cb93533..7ee5729b1 100644 --- a/openlp/core/widgets/dialogs.py +++ b/openlp/core/widgets/dialogs.py @@ -19,9 +19,10 @@ # along with this program. If not, see . # ########################################################################## """ Patch the QFileDialog so it accepts and returns Path objects""" -from PyQt5 import QtWidgets +from PyQt5 import QtCore, QtWidgets from openlp.core.common.path import path_to_str, replace_params, str_to_path +from openlp.core.common.i18n import UiStrings, translate class FileDialog(QtWidgets.QFileDialog): @@ -107,3 +108,42 @@ class FileDialog(QtWidgets.QFileDialog): # getSaveFileName returns a tuple. The first item represents the path as a str. The string is empty if the user # cancels the dialog. return str_to_path(file_name), selected_filter + + +class DownloadProgressDialog(QtWidgets.QProgressDialog): + """ + Local class to handle download display based and supporting httputils:get_web_page + """ + def __init__(self, parent, app): + super(DownloadProgressDialog, self).__init__(parent) + self.parent = parent + self.app = app + self.setWindowModality(QtCore.Qt.WindowModal) + self.setWindowTitle(translate('OpenLP.RemotePlugin', 'Importing Website')) + self.setLabelText(UiStrings().StartingImport) + self.setCancelButton(None) + self.setRange(0, 1) + self.setMinimumDuration(0) + self.was_cancelled = False + self.previous_size = 0 + + def update_progress(self, count, block_size): + """ + Calculate and display the download progress. + """ + increment = (count * block_size) - self.previous_size + self._increment_progress_bar(None, increment) + self.previous_size = count * block_size + + def _increment_progress_bar(self, status_text, increment=1): + """ + Update the wizard progress page. + + :param status_text: Current status information to display. + :param increment: The value to increment the progress bar by. + """ + if status_text: + self.setText(status_text) + if increment > 0: + self.setValue(self.value() + increment) + self.app.process_events() diff --git a/tests/functional/openlp_core/api/test_deploy.py b/tests/functional/openlp_core/api/test_deploy.py index 0975ea9d8..19ff20d1e 100644 --- a/tests/functional/openlp_core/api/test_deploy.py +++ b/tests/functional/openlp_core/api/test_deploy.py @@ -18,130 +18,142 @@ # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # ########################################################################## -import os -import shutil +import json from pathlib import Path -from tempfile import mkdtemp -from unittest import TestCase, skip from unittest.mock import MagicMock, patch -from openlp.core.api.deploy import deploy_zipfile, download_and_check, download_sha256 +from openlp.core.api.deploy import REMOTE_URL, deploy_zipfile, download_version_info, download_and_check -CONFIG_FILE = '2c266badff1e3d140664c50fd1460a2b332b24d5ad8c267fa62e506b5eb6d894 deploy/site.zip\n2017_06_27' +CONFIG_FILE = '{"latest": {"version": "0.1", "filename": "remote-0.1.zip", "sha256": "", "size": 854039}}' +CONFIG_DICT = json.loads(CONFIG_FILE) -class TestRemoteDeploy(TestCase): +@patch('openlp.core.api.deploy.ZipFile') +def test_deploy_zipfile(MockZipFile): """ - Test the Remote plugin deploy functions + Remote Deploy tests - test the dummy zip file is processed correctly """ + # GIVEN: A new downloaded zip file + mocked_zipfile = MagicMock() + MockZipFile.return_value = mocked_zipfile + root_path = Path('/') / 'tmp' / 'remotes' - def setUp(self): - """ - Setup for tests - """ - self.app_root_path = Path(mkdtemp()) + # WHEN: deploy_zipfile() is called + deploy_zipfile(root_path, 'site.zip') - def tearDown(self): - """ - Clean up after tests - """ - shutil.rmtree(self.app_root_path) + # THEN: the zip file should have been extracted to the right location + MockZipFile.assert_called_once_with(Path('/tmp/remotes/site.zip')) + mocked_zipfile.extractall.assert_called_once_with(Path('/tmp/remotes')) - @patch('openlp.core.api.deploy.ZipFile') - def test_deploy_zipfile(self, MockZipFile): - """ - Remote Deploy tests - test the dummy zip file is processed correctly - """ - # GIVEN: A new downloaded zip file - mocked_zipfile = MagicMock() - MockZipFile.return_value = mocked_zipfile - root_path_str = '{sep}tmp{sep}remotes'.format(sep=os.sep) - root_path = Path(root_path_str) - # WHEN: deploy_zipfile() is called - deploy_zipfile(root_path, 'site.zip') +@patch('openlp.core.api.deploy.get_openlp_user_agent') +@patch('openlp.core.api.deploy.get_web_page') +def test_download_version_info_connection_error(mocked_get_web_page, mocked_get_openlp_user_agent): + """ + Test that if a ConnectionError occurs while downloading a sha256 False is returned + """ + # GIVEN: A bunch of mocks + mocked_get_web_page.side_effect = ConnectionError() + mocked_get_openlp_user_agent.return_value = 'OpenLP' - # THEN: the zip file should have been extracted to the right location - MockZipFile.assert_called_once_with(Path('/tmp/remotes/site.zip')) - mocked_zipfile.extractall.assert_called_once_with(Path('/tmp/remotes')) + # WHEN: download_sha256() is called + result = download_version_info() - @skip('Broken and being refactored') - @patch('openlp.core.api.deploy.Registry') - @patch('openlp.core.api.deploy.get_web_page') - def test_download_sha256_connection_error(self, mocked_get_web_page, MockRegistry): - """ - Test that if a ConnectionError occurs while downloading a sha256 False is returned - """ - # GIVEN: A bunch of mocks - MockRegistry.return_value.get.return_value.applicationVersion.return_value = '1.0' - mocked_get_web_page.side_effect = ConnectionError() + # THEN: The result should be False + assert result is False, 'download_version_info() should return False when encountering ConnectionError' - # WHEN: download_sha256() is called - result = download_sha256() - # THEN: The result should be False - assert result is False, 'download_sha256() should return False when encountering ConnectionError' +@patch('openlp.core.api.deploy.get_openlp_user_agent') +@patch('openlp.core.api.deploy.get_web_page') +def test_download_version_info_empty_file(mocked_get_web_page, mocked_get_openlp_user_agent): + """ + Test that if there's no config when downloading a sha256 None is returned + """ + # GIVEN: A bunch of mocks + mocked_get_web_page.return_value = None + mocked_get_openlp_user_agent.return_value = 'OpenLP' - @skip('Broken and being refactored') - @patch('openlp.core.api.deploy.Registry') - @patch('openlp.core.api.deploy.get_web_page') - def test_download_sha256_no_config(self, mocked_get_web_page, MockRegistry): - """ - Test that if there's no config when downloading a sha256 None is returned - """ - # GIVEN: A bunch of mocks - MockRegistry.return_value.get.return_value.applicationVersion.return_value = '1.0' - mocked_get_web_page.return_value = None + # WHEN: download_sha256() is called + result = download_version_info() - # WHEN: download_sha256() is called - result = download_sha256() + # THEN: The result should be Nonw + assert result is None, 'download_version_info() should return None when there is a problem downloading the page' - # THEN: The result should be Nonw - assert result is None, 'download_sha256() should return None when there is a problem downloading the page' - @skip('Broken and being refactored') - @patch('openlp.core.api.deploy.Registry') - @patch('openlp.core.api.deploy.get_web_page') - def test_download_sha256(self, mocked_get_web_page, MockRegistry): - """ - Test that the sha256 and the version are returned - """ - # GIVEN: A bunch of mocks - MockRegistry.return_value.get.return_value.applicationVersion.return_value = '1.0' - mocked_get_web_page.return_value = CONFIG_FILE +@patch('openlp.core.api.deploy.get_openlp_user_agent') +@patch('openlp.core.api.deploy.get_web_page') +def test_download_version_info(mocked_get_web_page, mocked_get_openlp_user_agent): + """ + Test that the sha256 and the version are returned + """ + # GIVEN: A bunch of mocks + mocked_get_web_page.return_value = CONFIG_FILE + mocked_get_openlp_user_agent.return_value = 'OpenLP' - # WHEN: download_sha256() is called - result = download_sha256() + # WHEN: download_sha256() is called + result = download_version_info() - # THEN: The result should be Nonw - assert result == ('2c266badff1e3d140664c50fd1460a2b332b24d5ad8c267fa62e506b5eb6d894', '2017_06_27'), \ - 'download_sha256() should return a tuple of sha256 and version' + # THEN: The result should be Nonw + assert result == CONFIG_DICT, 'download_version_info() should return a dictionary of version information' - @skip('Broken and being refactored') - @patch('openlp.core.api.deploy.Registry') - @patch('openlp.core.api.deploy.download_sha256') - @patch('openlp.core.api.deploy.get_url_file_size') - @patch('openlp.core.api.deploy.download_file') - @patch('openlp.core.api.deploy.AppLocation.get_section_data_path') - @patch('openlp.core.api.deploy.deploy_zipfile') - def test_download_and_check(self, mocked_deploy_zipfile, mocked_get_data_path, mocked_download_file, - mocked_get_url_file_size, mocked_download_sha256, MockRegistry): - # GIVEN: A bunch of mocks - mocked_get_data_path.return_value = Path('/tmp/remotes') - mocked_download_file.return_value = True - mocked_get_url_file_size.return_value = 5 - mocked_download_sha256.return_value = ('asdfgh', '0.1') - MockRegistry.return_value.get.return_value.applicationVersion.return_value = '1.0' - mocked_callback = MagicMock() - # WHEN: download_and_check() is called - download_and_check(mocked_callback) +@patch('openlp.core.api.deploy.log.warning') +@patch('openlp.core.api.deploy.download_version_info') +def test_download_and_check_log_warning(mocked_download_version_info, mocked_warning): + """ + Test that when the version info fails, a warning is logged + """ + # GIVEN: A few mocks, and a version info of None + mocked_download_version_info.return_value = None - # THEN: The correct things should have been done - mocked_download_sha256.assert_called_once_with() - mocked_get_url_file_size.assert_called_once_with('https://get.openlp.org/webclient/site.zip') - mocked_callback.setRange.assert_called_once_with(0, 5) - mocked_download_file.assert_called_once_with(mocked_callback, 'https://get.openlp.org/webclient/site.zip', - Path('/tmp/remotes/site.zip'), sha256='asdfgh') - mocked_deploy_zipfile.assert_called_once_with(Path('/tmp/remotes'), 'site.zip') + # WHEN: download_and_check is run + result = download_and_check(None) + + # THEN: None is returned and a warning is logged + assert result is None, 'The result should be None' + mocked_warning.assert_called_once_with('Unable to access the version information, abandoning download') + + +@patch('openlp.core.api.deploy.AppLocation.get_section_data_path') +@patch('openlp.core.api.deploy.download_file') +@patch('openlp.core.api.deploy.download_version_info') +def test_download_and_check_download_fails(mocked_download_version_info, mocked_download_file, + mocked_get_section_data_path): + """ + Test that when the version info fails, a warning is logged + """ + # GIVEN: A few mocks + mocked_callback = MagicMock() + mocked_download_version_info.return_value = CONFIG_DICT + mocked_download_file.return_value = False + mocked_get_section_data_path.return_value = Path('.') + + # WHEN: download_and_check is run + result = download_and_check(mocked_callback) + + # THEN: None is returned and a warning is logged + assert result is None, 'The result should be None' + + +@patch('openlp.core.api.deploy.AppLocation.get_section_data_path') +@patch('openlp.core.api.deploy.deploy_zipfile') +@patch('openlp.core.api.deploy.download_file') +@patch('openlp.core.api.deploy.download_version_info') +def test_download_and_check(mocked_download_version_info, mocked_download_file, mocked_deploy_zipfile, + mocked_get_section_data_path): + # GIVEN: A bunch of mocks + mocked_callback = MagicMock() + mocked_download_version_info.return_value = CONFIG_DICT + mocked_download_file.return_value = True + mocked_remote_path = Path('/') / 'tmp' / 'remotes' + mocked_remote_zip = mocked_remote_path / 'remote.zip' + mocked_get_section_data_path.return_value = mocked_remote_path + + # WHEN: download_and_check() is called + result = download_and_check(mocked_callback) + + # THEN: The correct things should have been done + assert result == CONFIG_DICT['latest']['version'], 'The correct version is returned' + mocked_download_file.assert_called_once_with(mocked_callback, REMOTE_URL + '0.1/remote-0.1.zip', mocked_remote_zip) + mocked_deploy_zipfile.assert_called_once_with(mocked_remote_path, 'remote.zip') diff --git a/tests/functional/openlp_core/common/test_httputils.py b/tests/functional/openlp_core/common/test_httputils.py index 59ab0bd21..49ea37964 100644 --- a/tests/functional/openlp_core/common/test_httputils.py +++ b/tests/functional/openlp_core/common/test_httputils.py @@ -28,7 +28,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch from openlp.core.common.httputils import ProxyMode, download_file, get_proxy_settings, get_url_file_size, \ - get_user_agent, get_web_page + get_random_user_agent, get_web_page @pytest.yield_fixture @@ -39,7 +39,7 @@ def temp_file(settings): os.remove(tmp_file) -def test_get_user_agent_linux(): +def test_get_random_user_agent_linux(): """ Test that getting a user agent on Linux returns a user agent suitable for Linux """ @@ -48,15 +48,15 @@ def test_get_user_agent_linux(): # GIVEN: The system is Linux mocked_sys.platform = 'linux2' - # WHEN: We call get_user_agent() - user_agent = get_user_agent() + # WHEN: We call get_random_user_agent() + user_agent = get_random_user_agent() # THEN: The user agent is a Linux (or ChromeOS) user agent result = 'Linux' in user_agent or 'CrOS' in user_agent assert result is True, 'The user agent should be a valid Linux user agent' -def test_get_user_agent_windows(): +def test_get_random_user_agent_windows(): """ Test that getting a user agent on Windows returns a user agent suitable for Windows """ @@ -65,14 +65,14 @@ def test_get_user_agent_windows(): # GIVEN: The system is Windows mocked_sys.platform = 'win32' - # WHEN: We call get_user_agent() - user_agent = get_user_agent() + # WHEN: We call get_random_user_agent() + user_agent = get_random_user_agent() # THEN: The user agent is a Linux (or ChromeOS) user agent assert 'Windows' in user_agent, 'The user agent should be a valid Windows user agent' -def test_get_user_agent_macos(): +def test_get_random_user_agent_macos(): """ Test that getting a user agent on OS X returns a user agent suitable for OS X """ @@ -81,14 +81,14 @@ def test_get_user_agent_macos(): # GIVEN: The system is macOS mocked_sys.platform = 'darwin' - # WHEN: We call get_user_agent() - user_agent = get_user_agent() + # WHEN: We call get_random_user_agent() + user_agent = get_random_user_agent() # THEN: The user agent is a Linux (or ChromeOS) user agent assert 'Mac OS X' in user_agent, 'The user agent should be a valid OS X user agent' -def test_get_user_agent_default(): +def test_get_random_user_agent_default(): """ Test that getting a user agent on a non-Linux/Windows/OS X platform returns the default user agent """ @@ -97,8 +97,8 @@ def test_get_user_agent_default(): # GIVEN: The system is something else mocked_sys.platform = 'freebsd' - # WHEN: We call get_user_agent() - user_agent = get_user_agent() + # WHEN: We call get_random_user_agent() + user_agent = get_random_user_agent() # THEN: The user agent is a Linux (or ChromeOS) user agent assert 'NetBSD'in user_agent, 'The user agent should be the default user agent' @@ -119,15 +119,15 @@ def test_get_web_page_no_url(): @patch('openlp.core.common.httputils.requests') -@patch('openlp.core.common.httputils.get_user_agent') +@patch('openlp.core.common.httputils.get_random_user_agent') @patch('openlp.core.common.httputils.Registry') -def test_get_web_page(MockRegistry, mocked_get_user_agent, mocked_requests): +def test_get_web_page(MockRegistry, mocked_get_random_user_agent, mocked_requests): """ Test that the get_web_page method works correctly """ # GIVEN: Mocked out objects and a fake URL mocked_requests.get.return_value = MagicMock(text='text') - mocked_get_user_agent.return_value = 'user_agent' + mocked_get_random_user_agent.return_value = 'user_agent' fake_url = 'this://is.a.fake/url' # WHEN: The get_web_page() method is called @@ -136,20 +136,20 @@ def test_get_web_page(MockRegistry, mocked_get_user_agent, mocked_requests): # THEN: The correct methods are called with the correct arguments and a web page is returned mocked_requests.get.assert_called_once_with(fake_url, headers={'User-Agent': 'user_agent'}, proxies=None, timeout=30.0) - mocked_get_user_agent.assert_called_once_with() + mocked_get_random_user_agent.assert_called_once_with() assert MockRegistry.call_count == 1, 'The Registry() object should have been called once' assert returned_page == 'text', 'The returned page should be the mock object' @patch('openlp.core.common.httputils.requests') -@patch('openlp.core.common.httputils.get_user_agent') -def test_get_web_page_with_header(mocked_get_user_agent, mocked_requests, settings): +@patch('openlp.core.common.httputils.get_random_user_agent') +def test_get_web_page_with_header(mocked_get_random_user_agent, mocked_requests, settings): """ Test that adding a header to the call to get_web_page() adds the header to the request """ # GIVEN: Mocked out objects, a fake URL and a fake header mocked_requests.get.return_value = MagicMock(text='text') - mocked_get_user_agent.return_value = 'user_agent' + mocked_get_random_user_agent.return_value = 'user_agent' fake_url = 'this://is.a.fake/url' fake_headers = {'Fake-Header': 'fake value'} @@ -161,13 +161,13 @@ def test_get_web_page_with_header(mocked_get_user_agent, mocked_requests, settin expected_headers.update({'User-Agent': 'user_agent'}) mocked_requests.get.assert_called_once_with(fake_url, headers=expected_headers, proxies=None, timeout=30.0) - mocked_get_user_agent.assert_called_with() + mocked_get_random_user_agent.assert_called_with() assert returned_page == 'text', 'The returned page should be the mock object' @patch('openlp.core.common.httputils.requests') -@patch('openlp.core.common.httputils.get_user_agent') -def test_get_web_page_with_user_agent_in_headers(mocked_get_user_agent, mocked_requests, settings): +@patch('openlp.core.common.httputils.get_random_user_agent') +def test_get_web_page_with_user_agent_in_headers(mocked_get_random_user_agent, mocked_requests, settings): """ Test that adding a user agent in the header when calling get_web_page() adds that user agent to the request """ @@ -182,20 +182,20 @@ def test_get_web_page_with_user_agent_in_headers(mocked_get_user_agent, mocked_r # THEN: The correct methods are called with the correct arguments and a web page is returned mocked_requests.get.assert_called_once_with(fake_url, headers=user_agent_headers, proxies=None, timeout=30.0) - assert mocked_get_user_agent.call_count == 0, 'get_user_agent() should not have been called' + assert mocked_get_random_user_agent.call_count == 0, 'get_random_user_agent() should not have been called' assert returned_page == 'text', 'The returned page should be "test"' @patch('openlp.core.common.httputils.requests') -@patch('openlp.core.common.httputils.get_user_agent') +@patch('openlp.core.common.httputils.get_random_user_agent') @patch('openlp.core.common.httputils.Registry') -def test_get_web_page_update_openlp(MockRegistry, mocked_get_user_agent, mocked_requests): +def test_get_web_page_update_openlp(MockRegistry, mocked_get_random_user_agent, mocked_requests): """ Test that passing "update_openlp" as true to get_web_page calls Registry().get('app').process_events() """ # GIVEN: Mocked out objects, a fake URL mocked_requests.get.return_value = MagicMock(text='text') - mocked_get_user_agent.return_value = 'user_agent' + mocked_get_random_user_agent.return_value = 'user_agent' mocked_registry_object = MagicMock() mocked_application_object = MagicMock() mocked_registry_object.get.return_value = mocked_application_object @@ -208,7 +208,7 @@ def test_get_web_page_update_openlp(MockRegistry, mocked_get_user_agent, mocked_ # THEN: The correct methods are called with the correct arguments and a web page is returned mocked_requests.get.assert_called_once_with(fake_url, headers={'User-Agent': 'user_agent'}, proxies=None, timeout=30.0) - mocked_get_user_agent.assert_called_once_with() + mocked_get_random_user_agent.assert_called_once_with() mocked_registry_object.get.assert_called_with('application') mocked_application_object.process_events.assert_called_with() assert returned_page == 'text', 'The returned page should be the mock object' diff --git a/tests/interfaces/openlp_core/ui/test_firsttimeform.py b/tests/interfaces/openlp_core/ui/test_firsttimeform.py index 61a5c57e9..5424d3a50 100644 --- a/tests/interfaces/openlp_core/ui/test_firsttimeform.py +++ b/tests/interfaces/openlp_core/ui/test_firsttimeform.py @@ -40,8 +40,10 @@ def mocked_set_icon(mock_settings): q_thread_patcher = patch('openlp.core.ui.firsttimeform.QtCore.QThread').start() mocked_app = MagicMock() mocked_app.worker_threads = {} + mocked_main_window = MagicMock() Registry().remove('application') Registry().register('application', mocked_app) + Registry().register('main_window', mocked_main_window) yield set_icon_patcher move_to_thread_patcher.stop() set_icon_patcher.stop() diff --git a/tests/openlp_core/api/test_zeroconf.py b/tests/openlp_core/api/test_zeroconf.py index 96cf3ee34..92fb9a2d7 100644 --- a/tests/openlp_core/api/test_zeroconf.py +++ b/tests/openlp_core/api/test_zeroconf.py @@ -23,18 +23,14 @@ from unittest.mock import MagicMock, call, patch from openlp.core.api.zeroconf import ZeroconfWorker, start_zeroconf -@patch('openlp.core.api.zeroconf.socket.inet_aton') -def test_zeroconf_worker_constructor(mocked_inet_aton): +def test_zeroconf_worker_constructor(): """Test creating the Zeroconf worker object""" - # GIVEN: A ZeroconfWorker class and a mocked inet_aton - mocked_inet_aton.return_value = 'processed_ip' - + # GIVEN: A ZeroconfWorker class # WHEN: An instance of the ZeroconfWorker is created - worker = ZeroconfWorker('127.0.0.1', 8000, 8001) + worker = ZeroconfWorker(['127.0.0.1'], 8000, 8001) # THEN: The inet_aton function should have been called and the attrs should be set - mocked_inet_aton.assert_called_once_with('127.0.0.1') - assert worker.address == 'processed_ip' + assert worker.addresses == ['127.0.0.1'] assert worker.http_port == 8000 assert worker.ws_port == 8001 @@ -49,7 +45,7 @@ def test_zeroconf_worker_start(MockedZeroconf, MockedServiceInfo): mocked_zc = MagicMock() MockedServiceInfo.side_effect = [mocked_http_info, mocked_ws_info] MockedZeroconf.return_value = mocked_zc - worker = ZeroconfWorker('127.0.0.1', 8000, 8001) + worker = ZeroconfWorker(['127.0.0.1'], 8000, 8001) # WHEN: The start() method is called with patch.object(worker, 'can_run') as mocked_can_run: @@ -58,20 +54,22 @@ def test_zeroconf_worker_start(MockedZeroconf, MockedServiceInfo): # THEN: The correct calls are made assert MockedServiceInfo.call_args_list == [ - call('_http._tcp.local.', 'OpenLP._http._tcp.local.', address=b'\x7f\x00\x00\x01', port=8000, properties={}), - call('_ws._tcp.local.', 'OpenLP._ws._tcp.local.', address=b'\x7f\x00\x00\x01', port=8001, properties={}) + call('_http._tcp.local.', 'OpenLP._http._tcp.local.', addresses=[b'\x7f\x00\x00\x01'], port=8000, + properties={}), + call('_ws._tcp.local.', 'OpenLP._ws._tcp.local.', addresses=[b'\x7f\x00\x00\x01'], port=8001, + properties={}) ] assert MockedZeroconf.call_count == 1 assert mocked_zc.register_service.call_args_list == [call(mocked_http_info), call(mocked_ws_info)] assert mocked_can_run.call_count == 2 - assert mocked_zc.unregister_service.call_args_list == [call(mocked_http_info), call(mocked_ws_info)] - assert mocked_zc.close.call_count == 1 + mocked_zc.unregister_all_services.assert_called_once_with() + mocked_zc.close.assert_called_once_with() def test_zeroconf_worker_stop(): """Test that the ZeroconfWorker.stop() method correctly stops the service""" # GIVEN: A worker object with _can_run set to True - worker = ZeroconfWorker('127.0.0.1', 8000, 8001) + worker = ZeroconfWorker(['127.0.0.1'], 8000, 8001) worker._can_run = True # WHEN: stop() is called @@ -106,4 +104,4 @@ def test_start_zeroconf(mocked_run_thread, MockedZeroconfWorker, MockedRegistry, start_zeroconf() # THEN: A worker is added to the list of threads - mocked_run_thread.assert_called_once_with(mocked_worker, 'api_zeroconf_eth0') + mocked_run_thread.assert_called_once_with(mocked_worker, 'api_zeroconf')