diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py index 5eab07047..741dc23ee 100644 --- a/openlp/core/common/httputils.py +++ b/openlp/core/common/httputils.py @@ -27,12 +27,16 @@ import logging import sys import time from random import randint +from tempfile import gettempdir import requests +from PyQt5 import QtCore from openlp.core.common import trace_error_handler +from openlp.core.common.path import Path from openlp.core.common.registry import Registry from openlp.core.common.settings import ProxyMode, Settings +from openlp.core.threading import ThreadWorker log = logging.getLogger(__name__ + '.__init__') @@ -227,4 +231,46 @@ def download_file(update_object, url, file_path, sha256=None): return True -__all__ = ['get_web_page'] +class DownloadWorker(ThreadWorker): + """ + This worker allows a file to be downloaded in a thread + """ + download_failed = QtCore.pyqtSignal() + download_succeeded = QtCore.pyqtSignal(Path) + + def __init__(self, base_url, file_name): + """ + Set up the worker object + """ + self._base_url = base_url + self._file_name = file_name + self._download_cancelled = False + super().__init__() + + def start(self): + """ + Download the url to the temporary directory + """ + if self._download_cancelled: + self.quit.emit() + return + try: + dest_path = Path(gettempdir()) / 'openlp' / self._file_name + url = f'{self._base_url}{self._file_name}' + is_success = download_file(self, url, dest_path) + if is_success and not self._download_cancelled: + self.download_succeeded.emit(dest_path) + else: + self.download_failed.emit() + except: # noqa + log.exception('Unable to download %s', url) + self.download_failed.emit() + finally: + self.quit.emit() + + @QtCore.pyqtSlot() + def cancel_download(self): + """ + A slot to allow the download to be cancelled from outside of the thread + """ + self._download_cancelled = True diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index 99af2decc..abb1efe97 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -22,19 +22,19 @@ """ This module contains the first time wizard. """ +import json import logging import time import urllib.error import urllib.parse import urllib.request -from configparser import ConfigParser, MissingSectionHeaderError, NoOptionError, NoSectionError from tempfile import gettempdir from PyQt5 import QtCore, QtWidgets from openlp.core.common import clean_button_text, trace_error_handler 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 DownloadWorker, download_file, get_url_file_size, get_web_page from openlp.core.common.i18n import translate from openlp.core.common.mixins import RegistryProperties from openlp.core.common.path import Path, create_paths @@ -43,58 +43,50 @@ from openlp.core.common.settings import Settings from openlp.core.lib import build_icon from openlp.core.lib.plugin import PluginStatus from openlp.core.lib.ui import critical_error_message_box -from openlp.core.threading import ThreadWorker, get_thread_worker, is_thread_finished, run_thread +from openlp.core.threading import get_thread_worker, is_thread_finished, run_thread from openlp.core.ui.firsttimewizard import FirstTimePage, UiFirstTimeWizard +from openlp.core.ui.icons import UiIcons log = logging.getLogger(__name__) -class ThemeScreenshotWorker(ThreadWorker): +class ThemeListWidgetItem(QtWidgets.QListWidgetItem): """ - This thread downloads a theme's screenshot + Subclass a QListWidgetItem to allow dynamic loading of thumbnails from an online resource """ - screenshot_downloaded = QtCore.pyqtSignal(str, str, str) + def __init__(self, themes_url, sample_theme_data, ftw, *args, **kwargs): + super().__init__(*args, **kwargs) + title = sample_theme_data['title'] + thumbnail = sample_theme_data['thumbnail'] + self.file_name = sample_theme_data['file_name'] + self.sha256 = sample_theme_data['sha256'] + self.setIcon(UiIcons().picture) # Set a place holder icon whilst the thumbnails download + self.setText(title) + self.setToolTip(title) + worker = DownloadWorker(themes_url, thumbnail) + worker.download_failed.connect(self._on_download_failed) + worker.download_succeeded.connect(self._on_thumbnail_downloaded) + thread_name = f'thumbnail_download_{thumbnail}' + run_thread(worker, thread_name) + ftw.thumbnail_download_threads.append(thread_name) # TODO: Already in the application que - def __init__(self, themes_url, title, filename, sha256, screenshot): + def _on_download_failed(self): """ - Set up the worker object - """ - self.was_cancelled = False - self.themes_url = themes_url - self.title = title - self.filename = filename - self.sha256 = sha256 - self.screenshot = screenshot - super().__init__() + Set an icon to indicate that the thumbnail download has failed. - def start(self): + :rtype: None """ - Run the worker - """ - if self.was_cancelled: - return - try: - download_path = Path(gettempdir()) / 'openlp' / self.screenshot - is_success = download_file(self, '{host}{name}'.format(host=self.themes_url, name=self.screenshot), - download_path) - if is_success and not self.was_cancelled: - # Signal that the screenshot has been downloaded - self.screenshot_downloaded.emit(self.title, self.filename, self.sha256) - except: # noqa - log.exception('Unable to download screenshot') - finally: - self.quit.emit() + self.setIcon(UiIcons().exception) - @QtCore.pyqtSlot(bool) - def set_download_canceled(self, toggle): + def _on_thumbnail_downloaded(self, thumbnail_path): """ - Externally set if the download was canceled + Load the thumbnail as the icon when it has downloaded. - :param toggle: Set if the download was canceled or not + :param Path thumbnail_path: Path to the file to use as a thumbnail + :rtype: None """ - self.was_download_cancelled = toggle - + self.setIcon(build_icon(thumbnail_path)) class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): """ @@ -110,6 +102,9 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self.web_access = True self.web = '' self.setup_ui(self) + self.themes_list_widget.itemSelectionChanged.connect(self.on_themes_list_widget_selection_changed) + self.themes_deselect_all_button.clicked.connect(self.themes_list_widget.clearSelection) + self.themes_select_all_button.clicked.connect(self.themes_list_widget.selectAll) def get_next_page_id(self): """ @@ -144,18 +139,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): return -1 elif self.currentId() == FirstTimePage.NoInternet: return FirstTimePage.Progress - elif self.currentId() == FirstTimePage.Themes: - self.application.set_busy_cursor() - while not all([is_thread_finished(thread_name) for thread_name in self.theme_screenshot_threads]): - time.sleep(0.1) - self.application.process_events() - # Build the screenshot icons, as this can not be done in the thread. - self._build_theme_screenshots() - self.application.set_normal_cursor() - self.theme_screenshot_threads = [] - return self.get_next_page_id() - else: - return self.get_next_page_id() + return self.get_next_page_id() def exec(self): """ @@ -172,104 +156,83 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): """ self.screens = screens self.was_cancelled = False - self.theme_screenshot_threads = [] + self.thumbnail_download_threads = [] self.has_run_wizard = False - self.themes_list_widget.itemChanged.connect(self.on_theme_selected) - def _download_index(self): """ Download the configuration file and kick off the theme screenshot download threads """ # check to see if we have web access self.web_access = False - self.config = ConfigParser() + self.config = '' + web_config = None user_agent = 'OpenLP/' + Registry().get('application').applicationVersion() self.application.process_events() try: - web_config = get_web_page('{host}{name}'.format(host=self.web, name='download.cfg'), + web_config = get_web_page('{host}{name}'.format(host=self.web, name='download_3.0.json'), headers={'User-Agent': user_agent}) - except ConnectionError: + web_config = Path( + 'C:\\Users\\sroom\\Documents\\Phill Ridout\\play_ground\\openlp\\ftw-json\\download_3.0.json' + ).read_text(encoding='utf-8') # TODO: Remove!!!!! + except ConnectionError as e: QtWidgets.QMessageBox.critical(self, translate('OpenLP.FirstTimeWizard', 'Network Error'), translate('OpenLP.FirstTimeWizard', 'There was a network error attempting ' 'to connect to retrieve initial configuration information'), QtWidgets.QMessageBox.Ok) - web_config = False - if web_config: - try: - self.config.read_string(web_config) - self.web = self.config.get('general', 'base url') - self.songs_url = self.web + self.config.get('songs', 'directory') + '/' - self.bibles_url = self.web + self.config.get('bibles', 'directory') + '/' - self.themes_url = self.web + self.config.get('themes', 'directory') + '/' - self.web_access = True - except (NoSectionError, NoOptionError, MissingSectionHeaderError): - log.debug('A problem occurred while parsing the downloaded config file') - trace_error_handler(log) + if web_config and self._parse_config(web_config): + self.web_access = True self.application.process_events() self.downloading = translate('OpenLP.FirstTimeWizard', 'Downloading {name}...') - if self.has_run_wizard: - self.songs_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songs').is_active()) - self.bible_check_box.setChecked(self.plugin_manager.get_plugin_by_name('bibles').is_active()) - self.presentation_check_box.setChecked(self.plugin_manager.get_plugin_by_name('presentations').is_active()) - self.image_check_box.setChecked(self.plugin_manager.get_plugin_by_name('images').is_active()) - self.media_check_box.setChecked(self.plugin_manager.get_plugin_by_name('media').is_active()) - self.custom_check_box.setChecked(self.plugin_manager.get_plugin_by_name('custom').is_active()) - self.song_usage_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songusage').is_active()) - self.alert_check_box.setChecked(self.plugin_manager.get_plugin_by_name('alerts').is_active()) self.application.set_normal_cursor() - # Sort out internet access for downloads - if self.web_access: - songs = self.config.get('songs', 'languages') - songs = songs.split(',') - for song in songs: + + def _parse_config(self, web_config): + try: + config = json.loads(web_config) + meta = config['_meta'] + self.web = meta['base_url'] + + self.songs_url = self.web + meta['songs_dir'] + '/' + self.bibles_url = self.web + meta['bibles_dir'] + '/' + self.themes_url = self.web + meta['themes_dir'] + '/' + + for song in config['songs'].values(): self.application.process_events() - title = self.config.get('songs_{song}'.format(song=song), 'title') - filename = self.config.get('songs_{song}'.format(song=song), 'filename') - sha256 = self.config.get('songs_{song}'.format(song=song), 'sha256', fallback='') - item = QtWidgets.QListWidgetItem(title, self.songs_list_widget) - item.setData(QtCore.Qt.UserRole, (filename, sha256)) + item = QtWidgets.QListWidgetItem(song['title'], self.songs_list_widget) + item.setData(QtCore.Qt.UserRole, (song['file_name'], song['sha256'])) item.setCheckState(QtCore.Qt.Unchecked) item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable) - bible_languages = self.config.get('bibles', 'languages') - bible_languages = bible_languages.split(',') - for lang in bible_languages: + + for lang in config['bibles'].values(): self.application.process_events() - language = self.config.get('bibles_{lang}'.format(lang=lang), 'title') - lang_item = QtWidgets.QTreeWidgetItem(self.bibles_tree_widget, [language]) - bibles = self.config.get('bibles_{lang}'.format(lang=lang), 'translations') - bibles = bibles.split(',') - for bible in bibles: + lang_item = QtWidgets.QTreeWidgetItem(self.bibles_tree_widget, [lang['title']]) + for translation in lang['translations'].values(): self.application.process_events() - title = self.config.get('bible_{bible}'.format(bible=bible), 'title') - filename = self.config.get('bible_{bible}'.format(bible=bible), 'filename') - sha256 = self.config.get('bible_{bible}'.format(bible=bible), 'sha256', fallback='') - item = QtWidgets.QTreeWidgetItem(lang_item, [title]) - item.setData(0, QtCore.Qt.UserRole, (filename, sha256)) + item = QtWidgets.QTreeWidgetItem(lang_item, [translation['title']]) + item.setData(0, QtCore.Qt.UserRole, (translation['file_name'], translation['sha256'])) item.setCheckState(0, QtCore.Qt.Unchecked) item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable) self.bibles_tree_widget.expandAll() self.application.process_events() - # Download the theme screenshots - themes = self.config.get('themes', 'files').split(',') - for theme in themes: - title = self.config.get('theme_{theme}'.format(theme=theme), 'title') - filename = self.config.get('theme_{theme}'.format(theme=theme), 'filename') - sha256 = self.config.get('theme_{theme}'.format(theme=theme), 'sha256', fallback='') - screenshot = self.config.get('theme_{theme}'.format(theme=theme), 'screenshot') - worker = ThemeScreenshotWorker(self.themes_url, title, filename, sha256, screenshot) - worker.screenshot_downloaded.connect(self.on_screenshot_downloaded) - thread_name = 'theme_screenshot_{title}'.format(title=title) - run_thread(worker, thread_name) - self.theme_screenshot_threads.append(thread_name) + + for theme in config['themes'].values(): + ThemeListWidgetItem(self.themes_url, theme, self, self.themes_list_widget) self.application.process_events() + except Exception: + log.exception('Unable to parse sample config file %s', web_config) + critical_error_message_box( + translate('OpenLP.FirstTimeWizard', 'Invalid index file'), + translate('OpenLP.FirstTimeWizard', 'OpenLP was unable to read the resource index file. ' + 'Please try again later.')) + return False + return True def set_defaults(self): """ Set up display at start of theme edit. """ self.restart() - self.web = 'http://openlp.org/files/frw/' + self.web = 'https://openlp.org/files/frw/' self.cancel_button.clicked.connect(self.on_cancel_button_clicked) self.no_internet_finish_button.clicked.connect(self.on_no_internet_finish_button_clicked) self.no_internet_cancel_button.clicked.connect(self.on_no_internet_cancel_button_clicked) @@ -282,9 +245,18 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): create_paths(Path(gettempdir(), 'openlp')) self.theme_combo_box.clear() if self.has_run_wizard: + self.songs_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songs').is_active()) + self.bible_check_box.setChecked(self.plugin_manager.get_plugin_by_name('bibles').is_active()) + self.presentation_check_box.setChecked( + self.plugin_manager.get_plugin_by_name('presentations').is_active()) + self.image_check_box.setChecked(self.plugin_manager.get_plugin_by_name('images').is_active()) + self.media_check_box.setChecked(self.plugin_manager.get_plugin_by_name('media').is_active()) + self.custom_check_box.setChecked(self.plugin_manager.get_plugin_by_name('custom').is_active()) + self.song_usage_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songusage').is_active()) + self.alert_check_box.setChecked(self.plugin_manager.get_plugin_by_name('alerts').is_active()) # Add any existing themes to list. - for theme in self.theme_manager.get_themes(): - self.theme_combo_box.addItem(theme) + self.theme_combo_box.insertSeparator(0) + self.theme_combo_box.addItems(sorted(self.theme_manager.get_themes())) default_theme = Settings().value('themes/global theme') # Pre-select the current default theme. index = self.theme_combo_box.findText(default_theme) @@ -335,49 +307,34 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): Process the triggering of the cancel button. """ self.was_cancelled = True - if self.theme_screenshot_threads: - for thread_name in self.theme_screenshot_threads: + if self.thumbnail_download_threads: # TODO: Use main thread list + for thread_name in self.thumbnail_download_threads: worker = get_thread_worker(thread_name) if worker: - worker.set_download_canceled(True) + worker.cancel_download() # Was the thread created. - if self.theme_screenshot_threads: - while any([not is_thread_finished(thread_name) for thread_name in self.theme_screenshot_threads]): + if self.thumbnail_download_threads: + while any([not is_thread_finished(thread_name) for thread_name in self.thumbnail_download_threads]): time.sleep(0.1) self.application.set_normal_cursor() - def on_screenshot_downloaded(self, title, filename, sha256): + def on_themes_list_widget_selection_changed(self): """ - Add an item to the list when a theme has been downloaded + Update the `theme_combo_box` with the selected items - :param title: The title of the theme - :param filename: The filename of the theme - """ - self.themes_list_widget.blockSignals(True) - item = QtWidgets.QListWidgetItem(title, self.themes_list_widget) - item.setData(QtCore.Qt.UserRole, (filename, sha256)) - item.setCheckState(QtCore.Qt.Unchecked) - item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable) - self.themes_list_widget.blockSignals(False) - - def on_theme_selected(self, item): - """ - Add or remove a de/selected sample theme from the theme_combo_box - - :param QtWidgets.QListWidgetItem item: The item that has been de/selected :rtype: None """ - theme_name = item.text() - if self.theme_manager and theme_name in self.theme_manager.get_themes(): - return True - if item.checkState() == QtCore.Qt.Checked: - self.theme_combo_box.addItem(theme_name) - return True - else: - index = self.theme_combo_box.findText(theme_name) - if index != -1: - self.theme_combo_box.removeItem(index) - return True + existing_themes = [] + if self.theme_manager: + existing_themes = self.theme_manager.get_themes() + for list_index in range(self.themes_list_widget.count()): + item = self.themes_list_widget.item(list_index) + if item.text() not in existing_themes: + cbox_index = self.theme_combo_box.findText(item.text()) + if item.isSelected() and cbox_index == -1: + self.theme_combo_box.insertItem(0, item.text()) + elif not item.isSelected() and cbox_index != -1: + self.theme_combo_box.removeItem(cbox_index) def on_no_internet_finish_button_clicked(self): """ @@ -396,18 +353,6 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self.was_cancelled = True self.close() - def _build_theme_screenshots(self): - """ - This method builds the theme screenshots' icons for all items in the ``self.themes_list_widget``. - """ - themes = self.config.get('themes', 'files') - themes = themes.split(',') - for index, theme in enumerate(themes): - screenshot = self.config.get('theme_{theme}'.format(theme=theme), 'screenshot') - item = self.themes_list_widget.item(index) - if item: - item.setIcon(build_icon(Path(gettempdir(), 'openlp', screenshot))) - def update_progress(self, count, block_size): """ Calculate and display the download progress. This method is called by download_file(). @@ -456,13 +401,9 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self.max_progress += size iterator += 1 # Loop through the themes list and increase for each selected item - for i in range(self.themes_list_widget.count()): - self.application.process_events() - item = self.themes_list_widget.item(i) - if item.checkState() == QtCore.Qt.Checked: - filename, sha256 = item.data(QtCore.Qt.UserRole) - size = get_url_file_size('{path}{name}'.format(path=self.themes_url, name=filename)) - self.max_progress += size + for item in self.themes_list_widget.selectedItems(): + size = get_url_file_size(f'{self.themes_url}{item.file_name}') + self.max_progress += size except urllib.error.URLError: trace_error_handler(log) critical_error_message_box(translate('OpenLP.FirstTimeWizard', 'Download Error'), @@ -579,15 +520,12 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): missed_files.append('Bible: {name}'.format(name=bible)) bibles_iterator += 1 # Download themes - for i in range(self.themes_list_widget.count()): - item = self.themes_list_widget.item(i) - if item.checkState() == QtCore.Qt.Checked: - theme, sha256 = item.data(QtCore.Qt.UserRole) - self._increment_progress_bar(self.downloading.format(name=theme), 0) - self.previous_size = 0 - if not download_file(self, '{path}{name}'.format(path=self.themes_url, name=theme), - themes_destination_path / theme, sha256): - missed_files.append('Theme: {name}'.format(name=theme)) + for item in self.themes_list_widget.selectedItems(): + self._increment_progress_bar(self.downloading.format(name=item.file_name), 0) + self.previous_size = 0 + if not download_file( + self, f'{self.themes_url}{item.file_name}', themes_destination_path / item.file_name, item.sha256): + missed_files.append(f'Theme: {item.file_name}') if missed_files: file_list = '' for entry in missed_files: diff --git a/openlp/core/ui/firsttimewizard.py b/openlp/core/ui/firsttimewizard.py index 4b4499236..5fe65d8f1 100644 --- a/openlp/core/ui/firsttimewizard.py +++ b/openlp/core/ui/firsttimewizard.py @@ -49,6 +49,40 @@ class FirstTimePage(object): Progress = 8 +class ThemeListWidget(QtWidgets.QListWidget): + """ + Subclass a QListWidget so we can make it look better when it resizes. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.setIconSize(QtCore.QSize(133, 100)) + self.setMovement(QtWidgets.QListView.Static) + self.setFlow(QtWidgets.QListView.LeftToRight) + self.setProperty("isWrapping", True) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setViewMode(QtWidgets.QListView.IconMode) + self.setUniformItemSizes(True) + self.setWordWrap(True) + + def resizeEvent(self, event): + """ + Resize the grid so the list looks better when its resized/ + + :param QtGui.QResizeEvent event: Not used + :return: None + """ + nominal_width = 141 # Icon width of 133 + 4 each side + max_items_per_row = self.viewport().width() // nominal_width or 1 # or 1 to avoid divide by 0 errors + col_size = (self.viewport().width() - 1) / max_items_per_row + self.setGridSize(QtCore.QSize(col_size, 140)) + + class UiFirstTimeWizard(object): """ The UI widgets for the first time wizard. @@ -175,27 +209,26 @@ class UiFirstTimeWizard(object): self.themes_page = QtWidgets.QWizardPage() self.themes_page.setObjectName('themes_page') self.themes_layout = QtWidgets.QVBoxLayout(self.themes_page) - self.themes_layout.setContentsMargins(20, 50, 20, 60) self.themes_layout.setObjectName('themes_layout') - self.themes_list_widget = QtWidgets.QListWidget(self.themes_page) - self.themes_list_widget.setViewMode(QtWidgets.QListView.IconMode) - self.themes_list_widget.setMovement(QtWidgets.QListView.Static) - self.themes_list_widget.setFlow(QtWidgets.QListView.LeftToRight) - self.themes_list_widget.setSpacing(4) - self.themes_list_widget.setUniformItemSizes(True) - self.themes_list_widget.setIconSize(QtCore.QSize(133, 100)) - self.themes_list_widget.setWrapping(False) - self.themes_list_widget.setObjectName('themes_list_widget') + self.themes_list_widget = ThemeListWidget(self.themes_page) self.themes_layout.addWidget(self.themes_list_widget) + self.theme_options_layout = QtWidgets.QHBoxLayout() self.default_theme_layout = QtWidgets.QHBoxLayout() self.theme_label = QtWidgets.QLabel(self.themes_page) self.default_theme_layout.addWidget(self.theme_label) self.theme_combo_box = QtWidgets.QComboBox(self.themes_page) self.theme_combo_box.setEditable(False) - self.theme_combo_box.setInsertPolicy(QtWidgets.QComboBox.NoInsert) - self.theme_combo_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) - self.default_theme_layout.addWidget(self.theme_combo_box) - self.themes_layout.addLayout(self.default_theme_layout) + self.default_theme_layout.addWidget(self.theme_combo_box, stretch=1) + self.theme_options_layout.addLayout(self.default_theme_layout, stretch=1) + self.select_buttons_layout = QtWidgets.QHBoxLayout(self.themes_page) + self.themes_select_all_button = QtWidgets.QToolButton(self.themes_page) + self.themes_select_all_button.setIcon(UiIcons().plus) + self.select_buttons_layout.addWidget(self.themes_select_all_button, stretch=1, alignment=QtCore.Qt.AlignRight) + self.themes_deselect_all_button = QtWidgets.QToolButton(self.themes_page) + self.themes_deselect_all_button.setIcon(UiIcons().minus) + self.select_buttons_layout.addWidget(self.themes_deselect_all_button) + self.theme_options_layout.addLayout(self.select_buttons_layout, stretch=1) + self.themes_layout.addLayout(self.theme_options_layout) first_time_wizard.setPage(FirstTimePage.Themes, self.themes_page) # Progress page self.progress_page = QtWidgets.QWizardPage() @@ -271,9 +304,12 @@ class UiFirstTimeWizard(object): self.songs_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download public domain songs.')) self.bibles_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Sample Bibles')) self.bibles_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download free Bibles.')) + # Themes Page self.themes_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Sample Themes')) self.themes_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download sample themes.')) - self.theme_label.setText(translate('OpenLP.FirstTimeWizard', 'Select default theme:')) + self.theme_label.setText(translate('OpenLP.FirstTimeWizard', 'Default theme:')) + self.themes_select_all_button.setToolTip(translate('OpenLP.FirstTimeWizard', 'Select all')) + self.themes_deselect_all_button.setToolTip(translate('OpenLP.FirstTimeWizard', 'Deselect all')) self.progress_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Downloading and Configuring')) self.progress_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Please wait while resources are downloaded ' 'and OpenLP is configured.')) diff --git a/tests/functional/openlp_core/ui/test_firsttimeform.py b/tests/functional/openlp_core/ui/test_firsttimeform.py index a6bdd99a1..1a0299f46 100644 --- a/tests/functional/openlp_core/ui/test_firsttimeform.py +++ b/tests/functional/openlp_core/ui/test_firsttimeform.py @@ -25,40 +25,70 @@ Package to test the openlp.core.ui.firsttimeform package. import os import tempfile from unittest import TestCase -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, call, patch, DEFAULT from openlp.core.common.path import Path from openlp.core.common.registry import Registry -from openlp.core.ui.firsttimeform import FirstTimeForm +from openlp.core.ui.firsttimeform import FirstTimeForm, ThemeListWidgetItem from tests.helpers.testmixin import TestMixin -FAKE_CONFIG = """ -[general] -base url = http://example.com/frw/ -[songs] -directory = songs -[bibles] -directory = bibles -[themes] -directory = themes +INVALID_CONFIG = """ +{ + "_comments": "The most recent version should be added to https://openlp.org/files/frw/download_3.0.json", + "_meta": { +} """ -FAKE_BROKEN_CONFIG = """ -[general] -base url = http://example.com/frw/ -[songs] -directory = songs -[bibles] -directory = bibles -""" -FAKE_INVALID_CONFIG = """ - -This is not a config file -Some text - -""" +class TestThemeListWidgetItem(TestCase): + """ + Test the :class:`ThemeListWidgetItem` class + """ + def setUp(self): + self.sample_theme_data = {'file_name': 'BlueBurst.otz', 'sha256': 'sha_256_hash', + 'thumbnail': 'BlueBurst.png', 'title': 'Blue Burst'} + + download_worker_patcher = patch('openlp.core.ui.firsttimeform.DownloadWorker') + self.addCleanup(download_worker_patcher.stop) + self.mocked_download_worker = download_worker_patcher.start() + run_thread_patcher = patch('openlp.core.ui.firsttimeform.run_thread') + self.addCleanup(run_thread_patcher.stop) + self.mocked_run_thread = run_thread_patcher.start() + + def test_init_sample_data(self): + """ + Test that the theme data is loaded correctly in to a ThemeListWidgetItem object when instantiated + """ + # GIVEN: A sample theme dictanary object + # WHEN: Creating an instance of `ThemeListWidgetItem` + instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock()) + + # THEN: The data should have been set correctly + assert instance.file_name == 'BlueBurst.otz' + assert instance.sha256 == 'sha_256_hash' + assert instance.text() == 'Blue Burst' + assert instance.toolTip() == 'Blue Burst' + self.mocked_download_worker.assert_called_once_with('url', 'BlueBurst.png') + + def test_init_download_worker(self): + """ + Test that the `DownloadWorker` worker is set up correctly and that the thread is started. + """ + # GIVEN: A sample theme dictanary object + mocked_ftw = MagicMock(spec=FirstTimeForm) + mocked_ftw.thumbnail_download_threads = [] + + # WHEN: Creating an instance of `ThemeListWidgetItem` + instance = ThemeListWidgetItem('url', self.sample_theme_data, mocked_ftw) + + # THEN: The `DownloadWorker` should have been set up with the appropriate data + self.mocked_download_worker.assert_called_once_with('url', 'BlueBurst.png') + self.mocked_download_worker.download_failed.connect.called_once_with(instance._on_download_failed()) + self.mocked_download_worker.download_succeeded.connect.called_once_with(instance._on_thumbnail_downloaded) + self.mocked_run_thread.assert_called_once_with( + self.mocked_download_worker(), 'thumbnail_download_BlueBurst.png') + assert mocked_ftw.thumbnail_download_threads == ['thumbnail_download_BlueBurst.png'] class TestFirstTimeForm(TestCase, TestMixin): @@ -92,7 +122,7 @@ class TestFirstTimeForm(TestCase, TestMixin): assert expected_screens == frw.screens, 'The screens should be correct' assert frw.web_access is True, 'The default value of self.web_access should be True' assert frw.was_cancelled is False, 'The default value of self.was_cancelled should be False' - assert [] == frw.theme_screenshot_threads, 'The list of threads should be empty' + assert [] == frw.thumbnail_download_threads, 'The list of threads should be empty' assert frw.has_run_wizard is False, 'has_run_wizard should be False' def test_set_defaults(self): @@ -109,6 +139,7 @@ class TestFirstTimeForm(TestCase, TestMixin): patch.object(frw, 'no_internet_finish_button') as mocked_no_internet_finish_btn, \ patch.object(frw, 'currentIdChanged') as mocked_currentIdChanged, \ patch.object(frw, 'theme_combo_box') as mocked_theme_combo_box, \ + patch.object(frw, 'songs_check_box') as mocked_songs_check_box, \ patch.object(Registry, 'register_function') as mocked_register_function, \ patch('openlp.core.ui.firsttimeform.Settings', return_value=mocked_settings), \ patch('openlp.core.ui.firsttimeform.gettempdir', return_value='temp') as mocked_gettempdir, \ @@ -122,7 +153,7 @@ class TestFirstTimeForm(TestCase, TestMixin): # THEN: The default values should have been set mocked_restart.assert_called_once() - assert 'http://openlp.org/files/frw/' == frw.web, 'The default URL should be set' + assert 'https://openlp.org/files/frw/' == frw.web, 'The default URL should be set' mocked_cancel_button.clicked.connect.assert_called_once_with(frw.on_cancel_button_clicked) mocked_no_internet_finish_btn.clicked.connect.assert_called_once_with( frw.on_no_internet_finish_button_clicked) @@ -134,6 +165,7 @@ class TestFirstTimeForm(TestCase, TestMixin): mocked_create_paths.assert_called_once_with(Path('temp', 'openlp')) mocked_theme_combo_box.clear.assert_called_once() mocked_theme_manager.assert_not_called() + mocked_songs_check_box.assert_not_called() def test_set_defaults_rerun(self): """ @@ -150,12 +182,17 @@ class TestFirstTimeForm(TestCase, TestMixin): patch.object(frw, 'no_internet_finish_button') as mocked_no_internet_finish_btn, \ patch.object(frw, 'currentIdChanged') as mocked_currentIdChanged, \ patch.object(frw, 'theme_combo_box', **{'findText.return_value': 3}) as mocked_theme_combo_box, \ + patch.multiple(frw, songs_check_box=DEFAULT, bible_check_box=DEFAULT, presentation_check_box=DEFAULT, + image_check_box=DEFAULT, media_check_box=DEFAULT, custom_check_box=DEFAULT, + song_usage_check_box=DEFAULT, alert_check_box=DEFAULT) as mocked_check_boxes, \ patch.object(Registry, 'register_function') as mocked_register_function, \ patch('openlp.core.ui.firsttimeform.Settings', return_value=mocked_settings), \ patch('openlp.core.ui.firsttimeform.gettempdir', return_value='temp') as mocked_gettempdir, \ patch('openlp.core.ui.firsttimeform.create_paths') as mocked_create_paths, \ patch.object(frw.application, 'set_normal_cursor'): - mocked_theme_manager = MagicMock(**{'get_themes.return_value': ['a', 'b', 'c']}) + mocked_plugin_manager = MagicMock() + mocked_theme_manager = MagicMock(**{'get_themes.return_value': ['b', 'a', 'c']}) + Registry().register('plugin_manager', mocked_plugin_manager) Registry().register('theme_manager', mocked_theme_manager) # WHEN: The set_defaults() method is run @@ -163,7 +200,7 @@ class TestFirstTimeForm(TestCase, TestMixin): # THEN: The default values should have been set mocked_restart.assert_called_once() - assert 'http://openlp.org/files/frw/' == frw.web, 'The default URL should be set' + assert 'https://openlp.org/files/frw/' == frw.web, 'The default URL should be set' mocked_cancel_button.clicked.connect.assert_called_once_with(frw.on_cancel_button_clicked) mocked_no_internet_finish_btn.clicked.connect.assert_called_once_with( frw.on_no_internet_finish_button_clicked) @@ -173,9 +210,13 @@ class TestFirstTimeForm(TestCase, TestMixin): mocked_settings.value.assert_has_calls([call('core/has run wizard'), call('themes/global theme')]) mocked_gettempdir.assert_called_once() mocked_create_paths.assert_called_once_with(Path('temp', 'openlp')) - mocked_theme_manager.assert_not_called() + mocked_theme_manager.get_themes.assert_called_once() mocked_theme_combo_box.clear.assert_called_once() - mocked_theme_combo_box.addItem.assert_has_calls([call('a'), call('b'), call('c')]) + mocked_plugin_manager.get_plugin_by_name.assert_has_calls( + [call('songs'), call('bibles'), call('presentations'), call('images'), call('media'), call('custom'), + call('songusage'), call('alerts')], any_order=True) + mocked_plugin_manager.get_plugin_by_name.assert_has_calls([call().is_active()] * 8, any_order=True) + mocked_theme_combo_box.addItems.assert_called_once_with(['a', 'b', 'c']) mocked_theme_combo_box.findText.assert_called_once_with('Default Theme') mocked_theme_combo_box.setCurrentIndex(3) @@ -192,7 +233,7 @@ class TestFirstTimeForm(TestCase, TestMixin): mocked_is_thread_finished.side_effect = [False, True] frw = FirstTimeForm(None) frw.initialize(MagicMock()) - frw.theme_screenshot_threads = ['test_thread'] + frw.thumbnail_download_threads = ['test_thread'] with patch.object(frw.application, 'set_normal_cursor') as mocked_set_normal_cursor: # WHEN: on_cancel_button_clicked() is called @@ -201,43 +242,26 @@ class TestFirstTimeForm(TestCase, TestMixin): # THEN: The right things should be called in the right order assert frw.was_cancelled is True, 'The was_cancelled property should have been set to True' mocked_get_thread_worker.assert_called_once_with('test_thread') - mocked_worker.set_download_canceled.assert_called_with(True) + mocked_worker.cancel_download.assert_called_once() mocked_is_thread_finished.assert_called_with('test_thread') assert mocked_is_thread_finished.call_count == 2, 'isRunning() should have been called twice' mocked_time.sleep.assert_called_once_with(0.1) mocked_set_normal_cursor.assert_called_once_with() - def test_broken_config(self): + @patch('openlp.core.ui.firsttimeform.critical_error_message_box') + def test__parse_config_invalid_config(self, mocked_critical_error_message_box): """ - Test if we can handle an config file with missing data + Test `FirstTimeForm._parse_config` when called with invalid data """ - # GIVEN: A mocked get_web_page, a First Time Wizard, an expected screen object, and a mocked broken config file - with patch('openlp.core.ui.firsttimeform.get_web_page') as mocked_get_web_page: - first_time_form = FirstTimeForm(None) - first_time_form.initialize(MagicMock()) - mocked_get_web_page.return_value = FAKE_BROKEN_CONFIG + # GIVEN: An instance of `FirstTimeForm` + first_time_form = FirstTimeForm(None) - # WHEN: The First Time Wizard is downloads the config file - first_time_form._download_index() + # WHEN: Calling _parse_config with a string containing invalid data + result = first_time_form._parse_config(INVALID_CONFIG) - # THEN: The First Time Form should not have web access - assert first_time_form.web_access is False, 'There should not be web access with a broken config file' - - def test_invalid_config(self): - """ - Test if we can handle an config file in invalid format - """ - # GIVEN: A mocked get_web_page, a First Time Wizard, an expected screen object, and a mocked invalid config file - with patch('openlp.core.ui.firsttimeform.get_web_page') as mocked_get_web_page: - first_time_form = FirstTimeForm(None) - first_time_form.initialize(MagicMock()) - mocked_get_web_page.return_value = FAKE_INVALID_CONFIG - - # WHEN: The First Time Wizard is downloads the config file - first_time_form._download_index() - - # THEN: The First Time Form should not have web access - assert first_time_form.web_access is False, 'There should not be web access with an invalid config file' + # THEN: _parse_data should return False and the user should have should have been informed. + assert result is False + mocked_critical_error_message_box.assert_called_once() @patch('openlp.core.ui.firsttimeform.get_web_page') @patch('openlp.core.ui.firsttimeform.QtWidgets.QMessageBox') diff --git a/tests/interfaces/openlp_core/ui/test_firsttimeform.py b/tests/interfaces/openlp_core/ui/test_firsttimeform.py new file mode 100644 index 000000000..62242e8a2 --- /dev/null +++ b/tests/interfaces/openlp_core/ui/test_firsttimeform.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2017 OpenLP Developers # +# --------------------------------------------------------------------------- # +# 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; version 2 of the License. # +# # +# 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 General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +Package to test the openlp.core.ui.firsttimeform package. +""" +from unittest import TestCase +from unittest.mock import MagicMock, call, patch + +from openlp.core.common.path import Path +from openlp.core.common.registry import Registry +from openlp.core.ui.firsttimeform import ThemeListWidgetItem +from openlp.core.ui.icons import UiIcons +from tests.helpers.testmixin import TestMixin + +# TODO: BZR ADD!!!!!!!!!!!!! + + +class TestThemeListWidgetItem(TestCase, TestMixin): + def setUp(self): + self.sample_theme_data = {'file_name': 'BlueBurst.otz', 'sha256': 'sha_256_hash', + 'thumbnail': 'BlueBurst.png', 'title': 'Blue Burst'} + + Registry.create() + self.registry = Registry() + mocked_app = MagicMock() + mocked_app.worker_threads = {} + Registry().register('application', mocked_app) + self.setup_application() + + move_to_thread_patcher = patch('openlp.core.ui.firsttimeform.DownloadWorker.moveToThread') + self.addCleanup(move_to_thread_patcher.stop) + move_to_thread_patcher.start() + set_icon_patcher = patch('openlp.core.ui.firsttimeform.ThemeListWidgetItem.setIcon') + self.addCleanup(set_icon_patcher.stop) + self.mocked_set_icon = set_icon_patcher.start() + q_thread_patcher = patch('openlp.core.ui.firsttimeform.QtCore.QThread') + self.addCleanup(q_thread_patcher.stop) + q_thread_patcher.start() + + def test_failed_download(self): + """ + Test that icon get set to indicate a failure when `DownloadWorker` emits the download_failed signal + """ + # GIVEN: An instance of `DownloadWorker` + instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock()) + worker_threads = Registry().get('application').worker_threads + worker = worker_threads['thumbnail_download_BlueBurst.png']['worker'] + + # WHEN: `DownloadWorker` emits the `download_failed` signal + worker.download_failed.emit() + + # THEN: Then the initial loading icon should have been replaced by the exception icon + self.mocked_set_icon.assert_has_calls([call(UiIcons().picture), call(UiIcons().exception)]) + + @patch('openlp.core.ui.firsttimeform.build_icon') + def test_successful_download(self, mocked_build_icon): + """ + Test that the downloaded thumbnail is set as the icon when `DownloadWorker` emits the `download_succeeded` + signal + """ + # GIVEN: An instance of `DownloadWorker` + instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock()) + worker_threads = Registry().get('application').worker_threads + worker = worker_threads['thumbnail_download_BlueBurst.png']['worker'] + test_path = Path('downlaoded', 'file') + + # WHEN: `DownloadWorker` emits the `download_succeeded` signal + worker.download_succeeded.emit(test_path) + + # THEN: An icon should have been built from the downloaded file and used to replace the loading icon + mocked_build_icon.assert_called_once_with(test_path) + self.mocked_set_icon.assert_has_calls([call(UiIcons().picture), call(mocked_build_icon())])