From 141c07d42ae2a9071f2791e5132352b99cb9e6b5 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Fri, 30 Sep 2016 04:06:51 +0300 Subject: [PATCH 01/75] Fixed bug 1512040 --- openlp/core/ui/slidecontroller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index 7121e5227..dbac40ba7 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -732,8 +732,10 @@ class SlideController(DisplayController, RegistryProperties): # Reset the button self.play_slides_once.setChecked(False) self.play_slides_once.setIcon(build_icon(':/media/media_time.png')) + self.play_slides_once.setText(UiStrings().PlaySlidesToEnd) self.play_slides_loop.setChecked(False) self.play_slides_loop.setIcon(build_icon(':/media/media_time.png')) + self.play_slides_loop.setText(UiStrings().PlaySlidesInLoop) if item.is_text(): if (Settings().value(self.main_window.songs_settings_section + '/display songbar') and not self.song_menu.menu().isEmpty()): @@ -1326,7 +1328,6 @@ class SlideController(DisplayController, RegistryProperties): else: self.play_slides_once.setIcon(build_icon(':/media/media_time')) self.play_slides_once.setText(UiStrings().PlaySlidesToEnd) - self.on_toggle_loop() def set_audio_items_visibility(self, visible): """ @@ -1385,7 +1386,6 @@ class SlideController(DisplayController, RegistryProperties): [self.service_item, self.is_live]) if self.service_item.is_media(): self.on_media_close() - self.on_go_live() # If ('advanced/double click live') is not enabled, double clicking preview adds the item to Service. # Prevent same item in preview from being sent to Service multiple times. # Changing the preview slide resets this flag to False. From d716f62d578725880071703e3a90fb86e9650026 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Fri, 30 Sep 2016 04:11:41 +0300 Subject: [PATCH 02/75] - reverted some other changes made while trying to find the fix --- openlp/core/ui/slidecontroller.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openlp/core/ui/slidecontroller.py b/openlp/core/ui/slidecontroller.py index dbac40ba7..f2549eb42 100644 --- a/openlp/core/ui/slidecontroller.py +++ b/openlp/core/ui/slidecontroller.py @@ -1328,6 +1328,7 @@ class SlideController(DisplayController, RegistryProperties): else: self.play_slides_once.setIcon(build_icon(':/media/media_time')) self.play_slides_once.setText(UiStrings().PlaySlidesToEnd) + self.on_toggle_loop() def set_audio_items_visibility(self, visible): """ @@ -1386,6 +1387,7 @@ class SlideController(DisplayController, RegistryProperties): [self.service_item, self.is_live]) if self.service_item.is_media(): self.on_media_close() + self.on_go_live() # If ('advanced/double click live') is not enabled, double clicking preview adds the item to Service. # Prevent same item in preview from being sent to Service multiple times. # Changing the preview slide resets this flag to False. From 337edf039f7da621627c847e9095c4643c5ca9ea Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Fri, 30 Sep 2016 05:26:04 +0300 Subject: [PATCH 03/75] Fixed bug 1487788 --- openlp/core/ui/lib/treewidgetwithdnd.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/openlp/core/ui/lib/treewidgetwithdnd.py b/openlp/core/ui/lib/treewidgetwithdnd.py index c49fc144e..bf7209822 100644 --- a/openlp/core/ui/lib/treewidgetwithdnd.py +++ b/openlp/core/ui/lib/treewidgetwithdnd.py @@ -26,7 +26,7 @@ import os from PyQt5 import QtCore, QtGui, QtWidgets -from openlp.core.common import Registry +from openlp.core.common import Registry, is_win class TreeWidgetWithDnD(QtWidgets.QTreeWidget): @@ -44,6 +44,7 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): self.default_indentation = self.indentation() self.setIndentation(0) self.setAnimated(True) + window_flags = QtCore.Qt.FramelessWindowHint | QtCore.Qt.Tool | QtCore.Qt.WindowStaysOnTopHint def activateDnD(self): """ @@ -108,6 +109,11 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): :param event: Handle of the event pint passed """ + # If we are on Windows, OpenLP window will not be set on top. For example, user can drag images to Library and + # the folder stays on top of the group creation box. This piece of code fixes this issue. + if is_win(): + self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + QtWidgets.QWidget().raise_() if event.mimeData().hasUrls(): event.setDropAction(QtCore.Qt.CopyAction) event.accept() @@ -125,8 +131,18 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): event.setDropAction(QtCore.Qt.CopyAction) event.accept() Registry().execute('%s_dnd_internal' % self.mime_data_text, self.itemAt(event.pos())) - else: - event.ignore() + + #QtWidgets.QWidget.activateWindow(self) + #QtWidgets.QWidget().raise_() + #QtWidgets.QApplication.setActiveWindow() + + #self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + + # this will activate the window + #self.activateWindow() + + # this will activate the window + # Convenience methods for emulating a QListWidget. This helps keeping MediaManagerItem simple. def addItem(self, item): From 8dbd79dba07c333af6b50cb9224c19df13afc6b3 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Fri, 30 Sep 2016 05:36:32 +0300 Subject: [PATCH 04/75] - cleanup.. The drag&drop focus solution only works for the 1st drag&drop... Why? --- openlp/core/ui/lib/treewidgetwithdnd.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/openlp/core/ui/lib/treewidgetwithdnd.py b/openlp/core/ui/lib/treewidgetwithdnd.py index bf7209822..bf710c354 100644 --- a/openlp/core/ui/lib/treewidgetwithdnd.py +++ b/openlp/core/ui/lib/treewidgetwithdnd.py @@ -44,7 +44,6 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): self.default_indentation = self.indentation() self.setIndentation(0) self.setAnimated(True) - window_flags = QtCore.Qt.FramelessWindowHint | QtCore.Qt.Tool | QtCore.Qt.WindowStaysOnTopHint def activateDnD(self): """ @@ -113,7 +112,6 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): # the folder stays on top of the group creation box. This piece of code fixes this issue. if is_win(): self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) - QtWidgets.QWidget().raise_() if event.mimeData().hasUrls(): event.setDropAction(QtCore.Qt.CopyAction) event.accept() @@ -131,18 +129,8 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): event.setDropAction(QtCore.Qt.CopyAction) event.accept() Registry().execute('%s_dnd_internal' % self.mime_data_text, self.itemAt(event.pos())) - - #QtWidgets.QWidget.activateWindow(self) - #QtWidgets.QWidget().raise_() - #QtWidgets.QApplication.setActiveWindow() - - #self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) - - # this will activate the window - #self.activateWindow() - - # this will activate the window - + else: + event.ignore() # Convenience methods for emulating a QListWidget. This helps keeping MediaManagerItem simple. def addItem(self, item): From 238397fa3c70d9fd0ba9b96b31096e461ee85a09 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Fri, 30 Sep 2016 05:57:44 +0300 Subject: [PATCH 05/75] - Made a better fix for giving OpenLP focus, it now works more than once. --- openlp/core/ui/lib/treewidgetwithdnd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openlp/core/ui/lib/treewidgetwithdnd.py b/openlp/core/ui/lib/treewidgetwithdnd.py index bf710c354..f410e453a 100644 --- a/openlp/core/ui/lib/treewidgetwithdnd.py +++ b/openlp/core/ui/lib/treewidgetwithdnd.py @@ -112,6 +112,7 @@ class TreeWidgetWithDnD(QtWidgets.QTreeWidget): # the folder stays on top of the group creation box. This piece of code fixes this issue. if is_win(): self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + self.setWindowState(QtCore.Qt.WindowNoState) if event.mimeData().hasUrls(): event.setDropAction(QtCore.Qt.CopyAction) event.accept() From 4471338a19b5d638e2f71419ea70f6884f1fbec9 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Sun, 2 Oct 2016 14:31:35 +0300 Subject: [PATCH 06/75] - Fixed bug 1624661 --- openlp/core/__init__.py | 8 ++++---- openlp/core/lib/db.py | 19 +++++++++++++------ openlp/core/ui/advancedtab.py | 33 +++++++++++++++++++++------------ 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 6f6addbbd..852bf5424 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -208,8 +208,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): # If data_version is different from the current version ask if we should backup the data folder elif data_version != openlp_version: if QtWidgets.QMessageBox.question(None, translate('OpenLP', 'Backup'), - translate('OpenLP', 'OpenLP has been upgraded, do you want to create ' - 'a backup of OpenLPs data folder?'), + translate('OpenLP', 'OpenLP has been upgraded, do you want to\ncreate ' + 'a backup of the old data folder?'), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: # Create copy of data folder @@ -223,8 +223,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): translate('OpenLP', 'Backup of the data folder failed!')) return message = translate('OpenLP', - 'A backup of the data folder has been created' - 'at {text}').format(text=data_folder_backup_path) + 'A backup of the data folder has been created in:\n\n' + '{text}').format(text=data_folder_backup_path) QtWidgets.QMessageBox.information(None, translate('OpenLP', 'Backup'), message) # Update the version in the settings diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index f42c3b5fc..c1b9fb5cf 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -75,19 +75,26 @@ def get_db_path(plugin_name, db_file_name=None): name=db_file_name) -def handle_db_error(plugin_name, db_file_name): +def handle_db_error(self, plugin_name, db_file_name): """ Log and report to the user that a database cannot be loaded + :param self: Allows the usage of other functions. :param plugin_name: Name of plugin :param db_file_name: File name of database :return: None """ - db_path = get_db_path(plugin_name, db_file_name) - log.exception('Error loading database: {db}'.format(db=db_path)) - critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), - translate('OpenLP.Manager', - 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) + # Check if the path (Eg. C:/ or D:/) exists in the system, if not: 'pass' so def load will handle the missing + # drive in advancedtab.py. Otherwise check for "Normal" database errors. + self.current_data_path = AppLocation.get_data_path() + if not os.path.exists(self.current_data_path): + pass + else: + db_path = get_db_path(plugin_name, db_file_name) + log.exception('Error loading database: {db}'.format(db=db_path)) + critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), + translate('OpenLP.Manager', + 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) def init_url(plugin_name, db_file_name=None): diff --git a/openlp/core/ui/advancedtab.py b/openlp/core/ui/advancedtab.py index ca91e882a..be4c9bcd6 100644 --- a/openlp/core/ui/advancedtab.py +++ b/openlp/core/ui/advancedtab.py @@ -401,23 +401,32 @@ class AdvancedTab(SettingsTab): log.error('Data path not found {path}'.format(path=self.current_data_path)) answer = QtWidgets.QMessageBox.critical( self, translate('OpenLP.AdvancedTab', 'Data Directory Error'), - translate('OpenLP.AdvancedTab', 'OpenLP data directory was not found\n\n{path}\n\n' - 'This data directory was previously changed from the OpenLP ' - 'default location. If the new location was on removable ' - 'media, that media needs to be made available.\n\n' - 'Click "No" to stop loading OpenLP. allowing you to fix the the problem.\n\n' - 'Click "Yes" to reset the data directory to the default ' - 'location.').format(path=self.current_data_path), + translate('OpenLP.AdvancedTab', 'OpenLP data folder was not found in:\n\n{path}\n\n' + 'The location of the data folder was previously changed from the OpenLP\'s\n' + 'default location. If the data was stored on removable device, that device\nneeds to ' + 'be made available.\n\n You may reset the data location ' + 'back to the default settings, or you can try to make the current ' + 'location available.\n\n' + 'Do you want to reset the default data location?\n\n' + 'If you click "No" you can try to fix the the problem.\n' + 'If you click "Yes" the data will be reset to the default location. \n\n' + 'You will need to re-start OpenLP after this decision.').format(path=self.current_data_path), QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No), QtWidgets.QMessageBox.No) if answer == QtWidgets.QMessageBox.No: log.info('User requested termination') - self.main_window.clean_up() + # self.main_window.clean_up() is causing tracebacks, not sure if it's required in some cases. + try: + self.main_window.clean_up() + except: + pass + sys.exit() + # If answer was yes, Set data location to default and shut down OpenLP. + if answer == QtWidgets.QMessageBox.Yes: + settings.remove('advanced/data path') + self.current_data_path = AppLocation.get_data_path() + log.warning('User requested data path set to default {path}'.format(path=self.current_data_path)) sys.exit() - # Set data location to default. - settings.remove('advanced/data path') - self.current_data_path = AppLocation.get_data_path() - log.warning('User requested data path set to default {path}'.format(path=self.current_data_path)) self.data_directory_label.setText(os.path.abspath(self.current_data_path)) # Don't allow data directory move if running portable. if settings.value('advanced/is portable'): From cff02e6b7af5435e308fef81991d93eda0625c32 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Sun, 2 Oct 2016 18:51:16 +0300 Subject: [PATCH 07/75] Fixed bug: 1513490 --- openlp/core/lib/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index d00d85b54..159dfdd6c 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -307,7 +307,6 @@ def expand_tags(text): text = text.replace(tag['end tag'], tag['end html']) return text - def create_separated_list(string_list): """ Returns a string that represents a join of a list of strings with a localized separator. This function corresponds @@ -318,7 +317,13 @@ def create_separated_list(string_list): :param string_list: List of unicode strings """ if LooseVersion(Qt.PYQT_VERSION_STR) >= LooseVersion('4.9') and LooseVersion(Qt.qVersion()) >= LooseVersion('4.8'): - return QtCore.QLocale().createSeparatedList(string_list) + # Separate items with multiple same type creators with ',' and the last with " and ". + and_translated = translate('OpenLP.Ui', 'and') + if len(string_list) > 1: + string_list = ', '.join(string_list[:-1]) + ' ' + and_translated + ' ' + string_list[-1] + else: + string_list = ''.join(string_list) + return string_list if not string_list: return '' elif len(string_list) == 1: @@ -335,7 +340,6 @@ def create_separated_list(string_list): 'Locale list separator: middle') % (string_list[index], merged) return translate('OpenLP.core.lib', '%s, %s', 'Locale list separator: start') % (string_list[0], merged) - from .exceptions import ValidationError from .filedialog import FileDialog from .screen import ScreenList From c7a682ebfb5a13320435267c2b31249250f610df Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Sun, 2 Oct 2016 20:03:59 +0300 Subject: [PATCH 08/75] - The footer now follows proper english grammar rules. --- openlp/core/lib/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index 159dfdd6c..6ac316854 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -317,10 +317,14 @@ def create_separated_list(string_list): :param string_list: List of unicode strings """ if LooseVersion(Qt.PYQT_VERSION_STR) >= LooseVersion('4.9') and LooseVersion(Qt.qVersion()) >= LooseVersion('4.8'): - # Separate items with multiple same type creators with ',' and the last with " and ". + # Separate items with multiple same type creators with ',' and the last with ', and ' ' If we have two creators, + # ',' is not used by proper grammar, however in some languages ',' is not used at all before 'and'. and_translated = translate('OpenLP.Ui', 'and') - if len(string_list) > 1: + comma_and = translate ('OpenLP.ui', ', and') + if len(string_list) == 2: string_list = ', '.join(string_list[:-1]) + ' ' + and_translated + ' ' + string_list[-1] + elif len(string_list) > 2: + string_list = ', '.join(string_list[:-1]) + comma_and + ' ' + string_list[-1] else: string_list = ''.join(string_list) return string_list From 946fb02d5749c14ac8deb063fd9e1c063e00a2a6 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Sun, 2 Oct 2016 21:57:38 +0300 Subject: [PATCH 09/75] The def create_separated_list now uses the perfectly made and re-formatted code by the mastermind alisonken1 --- openlp/core/lib/__init__.py | 43 ++++++++++++------------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index 6ac316854..d7393eb4e 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -310,39 +310,24 @@ def expand_tags(text): def create_separated_list(string_list): """ Returns a string that represents a join of a list of strings with a localized separator. This function corresponds - to QLocale::createSeparatedList which was introduced in Qt 4.8 and implements the algorithm from http://www.unicode.org/reports/tr35/#ListPatterns - - :param string_list: List of unicode strings + NOTE: translate() can change the format based on language styling (ex: Finnish not using "{} and {}" rather than + english style "{} , and {}"). + :param string_list: List of unicode strings + :return: Formatted string """ - if LooseVersion(Qt.PYQT_VERSION_STR) >= LooseVersion('4.9') and LooseVersion(Qt.qVersion()) >= LooseVersion('4.8'): - # Separate items with multiple same type creators with ',' and the last with ', and ' ' If we have two creators, - # ',' is not used by proper grammar, however in some languages ',' is not used at all before 'and'. - and_translated = translate('OpenLP.Ui', 'and') - comma_and = translate ('OpenLP.ui', ', and') - if len(string_list) == 2: - string_list = ', '.join(string_list[:-1]) + ' ' + and_translated + ' ' + string_list[-1] - elif len(string_list) > 2: - string_list = ', '.join(string_list[:-1]) + comma_and + ' ' + string_list[-1] - else: - string_list = ''.join(string_list) - return string_list - if not string_list: - return '' - elif len(string_list) == 1: - return string_list[0] - # TODO: Verify mocking of translate() test before conversion - elif len(string_list) == 2: - return translate('OpenLP.core.lib', '%s and %s', - 'Locale list separator: 2 items') % (string_list[0], string_list[1]) + list_length = len(string_list) + if list_length == 1: + return_list = string_list[0] + elif list_length == 2: + return_list = translate('OpenLP.core.lib', '{one} & {two}').format(one=string_list[0], two=string_list[1]) + elif list_length > 2: + return_list = translate('OpenLP.core.lib', '{first}, & {last}').format(first=', '.join(string_list[:-1]), + last=string_list[-1]) else: - merged = translate('OpenLP.core.lib', '%s, and %s', - 'Locale list separator: end') % (string_list[-2], string_list[-1]) - for index in reversed(list(range(1, len(string_list) - 2))): - merged = translate('OpenLP.core.lib', '%s, %s', - 'Locale list separator: middle') % (string_list[index], merged) - return translate('OpenLP.core.lib', '%s, %s', 'Locale list separator: start') % (string_list[0], merged) + return_list = "" + return return_list from .exceptions import ValidationError from .filedialog import FileDialog From 036907eb5ea1894cafc6f89c806fbc42391f86d6 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 03:03:15 +0300 Subject: [PATCH 10/75] - Changed "&" back to "and" --- openlp/core/lib/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index d7393eb4e..bec198288 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -307,13 +307,12 @@ def expand_tags(text): text = text.replace(tag['end tag'], tag['end html']) return text + def create_separated_list(string_list): """ - Returns a string that represents a join of a list of strings with a localized separator. This function corresponds - to QLocale::createSeparatedList which was introduced in Qt 4.8 and implements the algorithm from - http://www.unicode.org/reports/tr35/#ListPatterns - NOTE: translate() can change the format based on language styling (ex: Finnish not using "{} and {}" rather than - english style "{} , and {}"). + Returns a string that represents a join of a list of strings with a localized separator. + Localized separation will be done via the translate() function by the translators. + :param string_list: List of unicode strings :return: Formatted string """ @@ -321,9 +320,9 @@ def create_separated_list(string_list): if list_length == 1: return_list = string_list[0] elif list_length == 2: - return_list = translate('OpenLP.core.lib', '{one} & {two}').format(one=string_list[0], two=string_list[1]) + return_list = translate('OpenLP.core.lib', '{one} and {two}').format(one=string_list[0], two=string_list[1]) elif list_length > 2: - return_list = translate('OpenLP.core.lib', '{first}, & {last}').format(first=', '.join(string_list[:-1]), + return_list = translate('OpenLP.core.lib', '{first}, and {last}').format(first=', '.join(string_list[:-1]), last=string_list[-1]) else: return_list = "" From 869caa9bf5b2117991893401f7ea1d196bfd3166 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 10:27:08 +0300 Subject: [PATCH 11/75] - pep8 (one ident) --- openlp/core/__init__.py | 6 +++--- openlp/core/lib/__init__.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 852bf5424..92cb9cab4 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -208,8 +208,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): # If data_version is different from the current version ask if we should backup the data folder elif data_version != openlp_version: if QtWidgets.QMessageBox.question(None, translate('OpenLP', 'Backup'), - translate('OpenLP', 'OpenLP has been upgraded, do you want to\ncreate ' - 'a backup of the old data folder?'), + translate('OpenLP', 'OpenLP has been upgraded, do you want to create ' + 'a backup of OpenLPs data folder?'), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: # Create copy of data folder @@ -223,7 +223,7 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): translate('OpenLP', 'Backup of the data folder failed!')) return message = translate('OpenLP', - 'A backup of the data folder has been created in:\n\n' + 'A backup of the data folder has been created at:/n' '{text}').format(text=data_folder_backup_path) QtWidgets.QMessageBox.information(None, translate('OpenLP', 'Backup'), message) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index bec198288..be92d2d49 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -323,11 +323,12 @@ def create_separated_list(string_list): return_list = translate('OpenLP.core.lib', '{one} and {two}').format(one=string_list[0], two=string_list[1]) elif list_length > 2: return_list = translate('OpenLP.core.lib', '{first}, and {last}').format(first=', '.join(string_list[:-1]), - last=string_list[-1]) + last=string_list[-1]) else: return_list = "" return return_list + from .exceptions import ValidationError from .filedialog import FileDialog from .screen import ScreenList From ce4d8224a23b1b36d1610cdd4b8979412a621a1d Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 10:37:08 +0300 Subject: [PATCH 12/75] -redid some mistakenly removed text changes --- openlp/core/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 92cb9cab4..dd9b231e4 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -208,8 +208,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): # If data_version is different from the current version ask if we should backup the data folder elif data_version != openlp_version: if QtWidgets.QMessageBox.question(None, translate('OpenLP', 'Backup'), - translate('OpenLP', 'OpenLP has been upgraded, do you want to create ' - 'a backup of OpenLPs data folder?'), + translate('OpenLP', 'OpenLP has been upgraded, do you want to create\n' + 'a backup of the old data folder?'), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: # Create copy of data folder @@ -223,7 +223,7 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): translate('OpenLP', 'Backup of the data folder failed!')) return message = translate('OpenLP', - 'A backup of the data folder has been created at:/n' + 'A backup of the data folder has been created in:\n\n' '{text}').format(text=data_folder_backup_path) QtWidgets.QMessageBox.information(None, translate('OpenLP', 'Backup'), message) From 400595adf5746c33db2938db27260ff1d169925a Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 11:43:52 +0300 Subject: [PATCH 13/75] - Fixed tests for creating creator lists --- tests/functional/openlp_core_lib/test_lib.py | 50 ++++++++------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/tests/functional/openlp_core_lib/test_lib.py b/tests/functional/openlp_core_lib/test_lib.py index 145be21f4..05c2b8a28 100644 --- a/tests/functional/openlp_core_lib/test_lib.py +++ b/tests/functional/openlp_core_lib/test_lib.py @@ -689,50 +689,38 @@ class TestLib(TestCase): """ Test the create_separated_list function with a list consisting of only one entry """ - with patch('openlp.core.lib.Qt') as mocked_qt: - # GIVEN: A list with a string and the mocked Qt module. - mocked_qt.PYQT_VERSION_STR = '4.8' - mocked_qt.qVersion.return_value = '4.7' - string_list = ['Author 1'] + # GIVEN: A list with a string. + string_list = ['Author 1'] - # WHEN: We get a string build from the entries it the list and a separator. - string_result = create_separated_list(string_list) + # WHEN: We get a string build from the entries it the list and a separator. + string_result = create_separated_list(string_list) - # THEN: We should have "Author 1" - assert string_result == 'Author 1', 'The string should be u\'Author 1\'.' + # THEN: We should have "Author 1" + assert string_result == 'Author 1', 'The string should be u\'Author 1\'.' def test_create_separated_list_with_two_items(self): """ Test the create_separated_list function with a list of two entries """ - with patch('openlp.core.lib.Qt') as mocked_qt, patch('openlp.core.lib.translate') as mocked_translate: - # GIVEN: A list of strings and the mocked Qt module. - mocked_qt.PYQT_VERSION_STR = '4.8' - mocked_qt.qVersion.return_value = '4.7' - mocked_translate.return_value = '%s and %s' - string_list = ['Author 1', 'Author 2'] + # GIVEN: A list with two strings. + string_list = ['Author 1', 'Author 2'] - # WHEN: We get a string build from the entries it the list and a seperator. - string_result = create_separated_list(string_list) + # WHEN: We get a string build from the entries it the list and a seperator. + string_result = create_separated_list(string_list) - # THEN: We should have "Author 1 and Author 2" - assert string_result == 'Author 1 and Author 2', 'The string should be u\'Author 1 and Author 2\'.' + # THEN: We should have "Author 1 and Author 2" + assert string_result == 'Author 1 and Author 2', 'The string should be u\'Author 1 and Author 2\'.' def test_create_separated_list_with_three_items(self): """ Test the create_separated_list function with a list of three items """ - with patch('openlp.core.lib.Qt') as mocked_qt, patch('openlp.core.lib.translate') as mocked_translate: - # GIVEN: A list with a string and the mocked Qt module. - mocked_qt.PYQT_VERSION_STR = '4.8' - mocked_qt.qVersion.return_value = '4.7' - # Always return the untranslated string. - mocked_translate.side_effect = lambda module, string_to_translate, comment: string_to_translate - string_list = ['Author 1', 'Author 2', 'Author 3'] + # GIVEN: A list with three strings. + string_list = ['Author 1', 'Author 2', 'Author 3'] - # WHEN: We get a string build from the entries it the list and a seperator. - string_result = create_separated_list(string_list) + # WHEN: We get a string build from the entries it the list and a seperator. + string_result = create_separated_list(string_list) - # THEN: We should have "Author 1, Author 2, and Author 3" - assert string_result == 'Author 1, Author 2, and Author 3', 'The string should be u\'Author 1, ' \ - 'Author 2, and Author 3\'.' + # THEN: We should have "Author 1, Author 2, and Author 3" + assert string_result == 'Author 1, Author 2, and Author 3', 'The string should be u\'Author 1, ' \ + 'Author 2, and Author 3\'.' From e46174d653b5884f66d1787d970b03a57febcd3e Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 12:06:17 +0300 Subject: [PATCH 14/75] - Added the SWORD importer test fix by phill to this branch --- tests/functional/openlp_plugins/bibles/test_swordimport.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/functional/openlp_plugins/bibles/test_swordimport.py b/tests/functional/openlp_plugins/bibles/test_swordimport.py index 261df1c0e..14480bdd1 100644 --- a/tests/functional/openlp_plugins/bibles/test_swordimport.py +++ b/tests/functional/openlp_plugins/bibles/test_swordimport.py @@ -70,8 +70,7 @@ class TestSwordImport(TestCase): @patch('openlp.plugins.bibles.lib.importers.sword.SwordBible.application') @patch('openlp.plugins.bibles.lib.importers.sword.modules') - @patch('openlp.core.common.languages') - def test_simple_import(self, mocked_languages, mocked_pysword_modules, mocked_application): + def test_simple_import(self, mocked_pysword_modules, mocked_application): """ Test that a simple SWORD import works """ @@ -88,7 +87,7 @@ class TestSwordImport(TestCase): importer.create_verse = MagicMock() importer.create_book = MagicMock() importer.session = MagicMock() - mocked_languages.get_language.return_value = 'Danish' + importer.get_language = MagicMock(return_value='Danish') mocked_bible = MagicMock() mocked_genesis = MagicMock() mocked_genesis.name = 'Genesis' From ece524e2a13bc5a0d98db6c5fea768fce4bc1c1b Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 15:16:42 +0300 Subject: [PATCH 15/75] - def handle_db_error now uses except FileNotFoundError to handle the missing data path --- openlp/core/lib/db.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index c1b9fb5cf..0e9e9335f 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -75,26 +75,23 @@ def get_db_path(plugin_name, db_file_name=None): name=db_file_name) -def handle_db_error(self, plugin_name, db_file_name): +def handle_db_error(plugin_name, db_file_name): """ Log and report to the user that a database cannot be loaded - :param self: Allows the usage of other functions. :param plugin_name: Name of plugin :param db_file_name: File name of database :return: None """ - # Check if the path (Eg. C:/ or D:/) exists in the system, if not: 'pass' so def load will handle the missing - # drive in advancedtab.py. Otherwise check for "Normal" database errors. - self.current_data_path = AppLocation.get_data_path() - if not os.path.exists(self.current_data_path): - pass - else: + try: db_path = get_db_path(plugin_name, db_file_name) log.exception('Error loading database: {db}'.format(db=db_path)) critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), translate('OpenLP.Manager', 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) + # If the path (Eg. C:/ or D:/) does not exists in the system, return and def load will handle the missing + except FileNotFoundError: + return def init_url(plugin_name, db_file_name=None): From 421555e5782950fa1843ac6e18f70422406699da Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 15:27:52 +0300 Subject: [PATCH 16/75] - Improved one comment --- openlp/core/lib/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 0e9e9335f..4bcc3597b 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -89,7 +89,8 @@ def handle_db_error(plugin_name, db_file_name): critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), translate('OpenLP.Manager', 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) - # If the path (Eg. C:/ or D:/) does not exists in the system, return and def load will handle the missing + # If the path (Eg. C:/ or D:/) does not exists in the system, return. + # In this case def load in advancedtab.py will handle the missing database. except FileNotFoundError: return From 7ee7ef8846cb32af609d9facafee5d84e0bdfbcc Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 16:07:53 +0300 Subject: [PATCH 17/75] - Not sure why the db bug is fixed in this branch --- openlp/core/lib/db.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 4bcc3597b..858dbcc06 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -75,24 +75,20 @@ def get_db_path(plugin_name, db_file_name=None): name=db_file_name) -def handle_db_error(plugin_name, db_file_name): +def handle_db_error(self, plugin_name, db_file_name): """ Log and report to the user that a database cannot be loaded + :param self: :param plugin_name: Name of plugin :param db_file_name: File name of database :return: None """ - try: - db_path = get_db_path(plugin_name, db_file_name) - log.exception('Error loading database: {db}'.format(db=db_path)) - critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), - translate('OpenLP.Manager', - 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) - # If the path (Eg. C:/ or D:/) does not exists in the system, return. - # In this case def load in advancedtab.py will handle the missing database. - except FileNotFoundError: - return + db_path = get_db_path(plugin_name, db_file_name) + log.exception('Error loading database: {db}'.format(db=db_path)) + critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), + translate('OpenLP.Manager', + 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) def init_url(plugin_name, db_file_name=None): From be9c394b6383de35cabcde4c4ab4933d66231063 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 16:27:41 +0300 Subject: [PATCH 18/75] - For jenkins --- openlp/core/lib/db.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 858dbcc06..11c52c156 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -84,12 +84,15 @@ def handle_db_error(self, plugin_name, db_file_name): :param db_file_name: File name of database :return: None """ - db_path = get_db_path(plugin_name, db_file_name) - log.exception('Error loading database: {db}'.format(db=db_path)) - critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), - translate('OpenLP.Manager', - 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) - + try: + db_path = get_db_path(plugin_name, db_file_name) + log.exception('Error loading database: {db}'.format(db=db_path)) + critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), + translate('OpenLP.Manager', + 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) + except TypeError or FileNotFoundError: + log.exception('Failed to find data folder path.') + return def init_url(plugin_name, db_file_name=None): """ From ebdad8519f08d3e31c0a26cf0fdcf6144ed3d758 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 16:31:09 +0300 Subject: [PATCH 19/75] - Added one missing empty row --- openlp/core/lib/db.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 11c52c156..2cddece82 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -94,6 +94,7 @@ def handle_db_error(self, plugin_name, db_file_name): log.exception('Failed to find data folder path.') return + def init_url(plugin_name, db_file_name=None): """ Return the database URL. From 39916d59ed333a35bbb03b2d4b787e2c2f85c329 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 17:09:53 +0300 Subject: [PATCH 20/75] - Attempt to fix jen --- openlp/core/lib/db.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 2cddece82..9836a8080 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -84,16 +84,11 @@ def handle_db_error(self, plugin_name, db_file_name): :param db_file_name: File name of database :return: None """ - try: - db_path = get_db_path(plugin_name, db_file_name) - log.exception('Error loading database: {db}'.format(db=db_path)) - critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), - translate('OpenLP.Manager', - 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) - except TypeError or FileNotFoundError: - log.exception('Failed to find data folder path.') - return - + db_path = get_db_path(plugin_name, db_file_name) + log.exception('Error loading database: {db}'.format(db=db_path)) + critical_error_message_box(translate('OpenLP.Manager', 'Database Error'), + translate('OpenLP.Manager', + 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) def init_url(plugin_name, db_file_name=None): """ From 5d9d76f62d290a8fb04b8bde367e755e0396d3e2 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 17:14:28 +0300 Subject: [PATCH 21/75] pep8 --- openlp/core/lib/db.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 9836a8080..858dbcc06 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -90,6 +90,7 @@ def handle_db_error(self, plugin_name, db_file_name): translate('OpenLP.Manager', 'OpenLP cannot load your database.\n\nDatabase: {db}').format(db=db_path)) + def init_url(plugin_name, db_file_name=None): """ Return the database URL. From 0887ca6645f01e95b32432344727e95aa1f44f03 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Tue, 4 Oct 2016 17:21:40 +0300 Subject: [PATCH 22/75] - Removed self from def handle_db_error --- openlp/core/lib/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 858dbcc06..59d13e568 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -75,7 +75,7 @@ def get_db_path(plugin_name, db_file_name=None): name=db_file_name) -def handle_db_error(self, plugin_name, db_file_name): +def handle_db_error(plugin_name, db_file_name): """ Log and report to the user that a database cannot be loaded From 340b2f3298000efdee935aa39bdacd0c1e555e7e Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Mon, 17 Oct 2016 07:17:33 +0300 Subject: [PATCH 23/75] - Actually fixed the missing DB bug. --- openlp/core/__init__.py | 40 ++++++++++++++++++++++++++++++++--- openlp/core/lib/db.py | 1 - openlp/core/ui/advancedtab.py | 30 -------------------------- 3 files changed, 37 insertions(+), 34 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index dd9b231e4..887da172c 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -177,6 +177,40 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): self.shared_memory.create(1) return False + def is_data_path_missing(self): + """ + Check if the data folder path exists. + """ + data_folder_path = AppLocation.get_data_path() + if not os.path.exists(data_folder_path): + log.critical('Database was not found in: {path}'.format(path=data_folder_path)) + status = QtWidgets.QMessageBox.critical(None, translate('OpenLP', 'Data Directory Error'), + translate('OpenLP', 'OpenLP data folder was not found in:\n\n{path}' + '\n\nThe location of the data folder was ' + 'previously changed from the OpenLP\'s\n' + 'default location. If the data was stored on ' + 'removable device, that device\nneeds to be ' + 'made available.\n\n You may reset the data ' + 'location back to the default settings, ' + 'or you can try to make the current location ' + 'available.\n\n Do you want to reset the ' + 'default data location?\n\n Click "No" to close' + 'OpenLP so you can try to fix the the problem.' + '\n Click "Yes" to reset the default data ' + 'location and start OpenLP.') + .format(path=data_folder_path), + QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Yes | + QtWidgets.QMessageBox.No), + QtWidgets.QMessageBox.No) + if status == QtWidgets.QMessageBox.No: + # If answer was "No", return "True", it will shutdown OpenLP in def main + log.info('User requested termination') + return True + # If answer was "Yes", remove the custom data path thus resetting the default location. + Settings().remove('advanced/data path') + log.info('Database location has been reset to the default settings.') + return False + def hook_exception(self, exc_type, value, traceback): """ Add an exception hook so that any uncaught exceptions are displayed in this window rather than somewhere where @@ -202,6 +236,7 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): """ data_version = Settings().value('core/application version') openlp_version = get_application_version()['version'] + data_folder_path = AppLocation.get_data_path() # New installation, no need to create backup if not has_run_wizard: Settings().setValue('core/application version', openlp_version) @@ -213,7 +248,6 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: # Create copy of data folder - data_folder_path = AppLocation.get_data_path() timestamp = time.strftime("%Y%m%d-%H%M%S") data_folder_backup_path = data_folder_path + '-' + timestamp try: @@ -368,8 +402,8 @@ def main(args=None): Registry.create() Registry().register('application', application) application.setApplicationVersion(get_application_version()['version']) - # Instance check - if application.is_already_running(): + # If user answers "No" to already running or missing db dialogue, shutdown OpenLP. + if application.is_already_running() or application.is_data_path_missing(): sys.exit() # Remove/convert obsolete settings. Settings().remove_obsolete_settings() diff --git a/openlp/core/lib/db.py b/openlp/core/lib/db.py index 59d13e568..f42c3b5fc 100644 --- a/openlp/core/lib/db.py +++ b/openlp/core/lib/db.py @@ -79,7 +79,6 @@ def handle_db_error(plugin_name, db_file_name): """ Log and report to the user that a database cannot be loaded - :param self: :param plugin_name: Name of plugin :param db_file_name: File name of database :return: None diff --git a/openlp/core/ui/advancedtab.py b/openlp/core/ui/advancedtab.py index be4c9bcd6..fed712ed2 100644 --- a/openlp/core/ui/advancedtab.py +++ b/openlp/core/ui/advancedtab.py @@ -397,36 +397,6 @@ class AdvancedTab(SettingsTab): self.data_directory_cancel_button.hide() # Since data location can be changed, make sure the path is present. self.current_data_path = AppLocation.get_data_path() - if not os.path.exists(self.current_data_path): - log.error('Data path not found {path}'.format(path=self.current_data_path)) - answer = QtWidgets.QMessageBox.critical( - self, translate('OpenLP.AdvancedTab', 'Data Directory Error'), - translate('OpenLP.AdvancedTab', 'OpenLP data folder was not found in:\n\n{path}\n\n' - 'The location of the data folder was previously changed from the OpenLP\'s\n' - 'default location. If the data was stored on removable device, that device\nneeds to ' - 'be made available.\n\n You may reset the data location ' - 'back to the default settings, or you can try to make the current ' - 'location available.\n\n' - 'Do you want to reset the default data location?\n\n' - 'If you click "No" you can try to fix the the problem.\n' - 'If you click "Yes" the data will be reset to the default location. \n\n' - 'You will need to re-start OpenLP after this decision.').format(path=self.current_data_path), - QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No), - QtWidgets.QMessageBox.No) - if answer == QtWidgets.QMessageBox.No: - log.info('User requested termination') - # self.main_window.clean_up() is causing tracebacks, not sure if it's required in some cases. - try: - self.main_window.clean_up() - except: - pass - sys.exit() - # If answer was yes, Set data location to default and shut down OpenLP. - if answer == QtWidgets.QMessageBox.Yes: - settings.remove('advanced/data path') - self.current_data_path = AppLocation.get_data_path() - log.warning('User requested data path set to default {path}'.format(path=self.current_data_path)) - sys.exit() self.data_directory_label.setText(os.path.abspath(self.current_data_path)) # Don't allow data directory move if running portable. if settings.value('advanced/is portable'): From 98b51a1db8de4908419cedd7a42d18f25468e20a Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Mon, 17 Oct 2016 07:29:08 +0300 Subject: [PATCH 24/75] - Code cleanup --- openlp/core/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 887da172c..15de367e2 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -236,7 +236,6 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): """ data_version = Settings().value('core/application version') openlp_version = get_application_version()['version'] - data_folder_path = AppLocation.get_data_path() # New installation, no need to create backup if not has_run_wizard: Settings().setValue('core/application version', openlp_version) @@ -248,6 +247,7 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: # Create copy of data folder + data_folder_path = AppLocation.get_data_path() timestamp = time.strftime("%Y%m%d-%H%M%S") data_folder_backup_path = data_folder_path + '-' + timestamp try: @@ -257,7 +257,7 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): translate('OpenLP', 'Backup of the data folder failed!')) return message = translate('OpenLP', - 'A backup of the data folder has been created in:\n\n' + 'A backup of the data folder has been created at:\n\n' '{text}').format(text=data_folder_backup_path) QtWidgets.QMessageBox.information(None, translate('OpenLP', 'Backup'), message) From aef0941e1885e5c41d380cad22520c543bbb5896 Mon Sep 17 00:00:00 2001 From: Olli Suutari Date: Mon, 17 Oct 2016 22:42:07 +0300 Subject: [PATCH 25/75] - Modified the def create_separated_list by suggestions of TRB143 --- openlp/core/lib/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index be92d2d49..69c3d30b3 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -318,15 +318,15 @@ def create_separated_list(string_list): """ list_length = len(string_list) if list_length == 1: - return_list = string_list[0] + list_to_string = string_list[0] elif list_length == 2: - return_list = translate('OpenLP.core.lib', '{one} and {two}').format(one=string_list[0], two=string_list[1]) + list_to_string = translate('OpenLP.core.lib', '{one} and {two}').format(one=string_list[0], two=string_list[1]) elif list_length > 2: - return_list = translate('OpenLP.core.lib', '{first}, and {last}').format(first=', '.join(string_list[:-1]), + list_to_string = translate('OpenLP.core.lib', '{first}, and {last}').format(first=', '.join(string_list[:-1]), last=string_list[-1]) else: - return_list = "" - return return_list + list_to_string = '' + return list_to_string from .exceptions import ValidationError From 7acfe1dbfd63ac6b83f115530f44a9dbb923491b Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Sun, 23 Oct 2016 20:28:30 +0200 Subject: [PATCH 26/75] Add new branding to OpenLP for when we get to 2.6 --- openlp/core/ui/aboutdialog.py | 1 + openlp/core/ui/generaltab.py | 2 +- openlp/core/ui/projector/editform.py | 2 +- openlp/core/ui/projector/sourceselectform.py | 4 +- resources/images/openlp-about-logo.png | Bin 28293 -> 22584 bytes resources/images/openlp-logo.svg | 445 ++++-------------- resources/images/openlp-splash-screen.png | Bin 57089 -> 48734 bytes .../openlp_core_ui/test_settingsform.py | 12 +- 8 files changed, 109 insertions(+), 357 deletions(-) diff --git a/openlp/core/ui/aboutdialog.py b/openlp/core/ui/aboutdialog.py index 918e48e64..3f9034dcb 100644 --- a/openlp/core/ui/aboutdialog.py +++ b/openlp/core/ui/aboutdialog.py @@ -41,6 +41,7 @@ class UiAboutDialog(object): about_dialog.setObjectName('about_dialog') about_dialog.setWindowIcon(build_icon(':/icon/openlp-logo.svg')) self.about_dialog_layout = QtWidgets.QVBoxLayout(about_dialog) + self.about_dialog_layout.setContentsMargins(8, 8, 8, 8) self.about_dialog_layout.setObjectName('about_dialog_layout') self.logo_label = QtWidgets.QLabel(about_dialog) self.logo_label.setPixmap(QtGui.QPixmap(':/graphics/openlp-about-logo.png')) diff --git a/openlp/core/ui/generaltab.py b/openlp/core/ui/generaltab.py index 463aad73f..629e55e0f 100644 --- a/openlp/core/ui/generaltab.py +++ b/openlp/core/ui/generaltab.py @@ -44,7 +44,7 @@ class GeneralTab(SettingsTab): self.logo_file = ':/graphics/openlp-splash-screen.png' self.logo_background_color = '#ffffff' self.screens = ScreenList() - self.icon_path = ':/icon/openlp-logo-16x16.png' + self.icon_path = ':/icon/openlp-logo.svg' general_translated = translate('OpenLP.GeneralTab', 'General') super(GeneralTab, self).__init__(parent, 'Core', general_translated) diff --git a/openlp/core/ui/projector/editform.py b/openlp/core/ui/projector/editform.py index f4cf8a774..4020b2330 100644 --- a/openlp/core/ui/projector/editform.py +++ b/openlp/core/ui/projector/editform.py @@ -47,7 +47,7 @@ class Ui_ProjectorEditForm(object): Create the interface layout. """ edit_projector_dialog.setObjectName('edit_projector_dialog') - edit_projector_dialog.setWindowIcon(build_icon(u':/icon/openlp-logo-32x32.png')) + edit_projector_dialog.setWindowIcon(build_icon(u':/icon/openlp-logo.svg')) edit_projector_dialog.setMinimumWidth(400) edit_projector_dialog.setModal(True) # Define the basic layout diff --git a/openlp/core/ui/projector/sourceselectform.py b/openlp/core/ui/projector/sourceselectform.py index c2b1c2a1b..62b0a2158 100644 --- a/openlp/core/ui/projector/sourceselectform.py +++ b/openlp/core/ui/projector/sourceselectform.py @@ -243,7 +243,7 @@ class SourceSelectTabs(QtWidgets.QDialog): title = translate('OpenLP.SourceSelectForm', 'Select Projector Source') self.setWindowTitle(title) self.setObjectName('source_select_tabs') - self.setWindowIcon(build_icon(':/icon/openlp-log-32x32.png')) + self.setWindowIcon(build_icon(':/icon/openlp-log.svg')) self.setModal(True) self.layout = QtWidgets.QVBoxLayout() self.layout.setObjectName('source_select_tabs_layout') @@ -395,7 +395,7 @@ class SourceSelectSingle(QtWidgets.QDialog): else: title = translate('OpenLP.SourceSelectForm', 'Select Projector Source') self.setObjectName('source_select_single') - self.setWindowIcon(build_icon(':/icon/openlp-log-32x32.png')) + self.setWindowIcon(build_icon(':/icon/openlp-log.svg')) self.setModal(True) self.edit = edit diff --git a/resources/images/openlp-about-logo.png b/resources/images/openlp-about-logo.png index a20534362feca0beec7283b944f81d095062ddfc..b2b141a17c67943ec3b383236b1676860dfdb8e7 100644 GIT binary patch literal 22584 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 UP&s`hq5uE@07*qoM6N<$f@HOpYybcN diff --git a/resources/images/openlp-logo.svg b/resources/images/openlp-logo.svg index c5e7985e1..764ba8563 100644 --- a/resources/images/openlp-logo.svg +++ b/resources/images/openlp-logo.svg @@ -1,5 +1,5 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - image/svg+xml - - - - - - - - - - - - - - - - - - + inkscape:zoom="1" + inkscape:cx="151.21623" + inkscape:cy="240.04907" + inkscape:window-x="1920" + inkscape:window-y="312" + inkscape:window-maximized="1" + inkscape:current-layer="svg5740" + fit-margin-left="0" + fit-margin-top="0" + fit-margin-right="0" + fit-margin-bottom="0" + showguides="true" + inkscape:guide-bbox="true"> \ No newline at end of file diff --git a/resources/images/openlp-splash-screen.png b/resources/images/openlp-splash-screen.png index 618e47c6eb86f856f098a58fbfe5dccf9bdbb5c0..09785488a219db75fd2ce3d3be6933e13ccfe706 100644 GIT binary patch literal 48734 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 literal 57089 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
  • m`3t!*3(D_*`~|n& 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 !oCZ#Hy0fN7PniVvQ&1N{EclUyHVit{b*I^7j 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 i56}?u|9!#jzh Date: Thu, 10 Nov 2016 07:09:04 +0000 Subject: [PATCH 27/75] Move no results functionality to the ListWidgetWithDnD --- openlp/core/lib/mediamanageritem.py | 30 -------------- openlp/core/ui/lib/listwidgetwithdnd.py | 41 +++++++++++++++++-- openlp/plugins/bibles/lib/mediaitem.py | 22 ++-------- openlp/plugins/custom/lib/mediaitem.py | 2 - openlp/plugins/songs/lib/mediaitem.py | 1 - .../openlp_plugins/bibles/test_mediaitem.py | 4 -- 6 files changed, 42 insertions(+), 58 deletions(-) diff --git a/openlp/core/lib/mediamanageritem.py b/openlp/core/lib/mediamanageritem.py index 2edea93cf..378020812 100644 --- a/openlp/core/lib/mediamanageritem.py +++ b/openlp/core/lib/mediamanageritem.py @@ -397,8 +397,6 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): # Decide if we have to show the context menu or not. if item is None: return - if not item.flags() & QtCore.Qt.ItemIsSelectable: - return self.menu.exec(self.list_view.mapToGlobal(point)) def get_file_list(self): @@ -638,34 +636,6 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): """ return item - def check_search_result(self): - """ - Checks if the list_view is empty and adds a "No Search Results" item. - """ - if self.list_view.count(): - return - message = translate('OpenLP.MediaManagerItem', 'No Search Results') - item = QtWidgets.QListWidgetItem(message) - item.setFlags(QtCore.Qt.NoItemFlags) - font = QtGui.QFont() - font.setItalic(True) - item.setFont(font) - self.list_view.addItem(item) - - def check_search_result_search_while_typing_short(self): - """ - This is used in Bible "Search while typing" if the search is shorter than the min required len. - """ - if self.list_view.count(): - return - message = translate('OpenLP.MediaManagerItem', 'Search is too short to be used in: "Search while typing"') - item = QtWidgets.QListWidgetItem(message) - item.setFlags(QtCore.Qt.NoItemFlags) - font = QtGui.QFont() - font.setItalic(True) - item.setFont(font) - self.list_view.addItem(item) - def _get_id_of_item_to_generate(self, item, remote_item): """ Utility method to check items being submitted for slide generation. diff --git a/openlp/core/ui/lib/listwidgetwithdnd.py b/openlp/core/ui/lib/listwidgetwithdnd.py index 43ec741d0..23bdbcd2d 100644 --- a/openlp/core/ui/lib/listwidgetwithdnd.py +++ b/openlp/core/ui/lib/listwidgetwithdnd.py @@ -26,7 +26,10 @@ import os from PyQt5 import QtCore, QtGui, QtWidgets -from openlp.core.common import Registry +from openlp.core.common import Registry, translate + +NO_RESULTS = translate('OpenLP.ListWidgetWithDnD', 'No Search Results') +SHORT_RESULTS = translate('OpenLP.ListWidgetWithDnD', 'Please type more text to use \'Search As You Type\'') class ListWidgetWithDnD(QtWidgets.QListWidget): @@ -37,8 +40,9 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): """ Initialise the list widget """ - super(ListWidgetWithDnD, self).__init__(parent) + super().__init__(parent) self.mime_data_text = name + self.no_results_text = NO_RESULTS def activateDnD(self): """ @@ -48,6 +52,19 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): self.setDragDropMode(QtWidgets.QAbstractItemView.DragDrop) Registry().register_function(('%s_dnd' % self.mime_data_text), self.parent().load_file) + def clear(self, search_while_typing=False): + """ + Re-implement clear, so that we can customise feedback when using 'Search as you type' + + :param search_while_typing: True if we want to display the customised message + :return: None + """ + if search_while_typing: + self.no_results_text = SHORT_RESULTS + else: + self.no_results_text = NO_RESULTS + super().clear() + def mouseMoveEvent(self, event): """ Drag and drop event does not care what data is selected as the recipient will use events to request the data @@ -102,6 +119,24 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): listing = os.listdir(local_file) for file in listing: files.append(os.path.join(local_file, file)) - Registry().execute('%s_dnd' % self.mime_data_text, {'files': files, 'target': self.itemAt(event.pos())}) + Registry().execute('{mime_data}_dnd'.format(mime_data=self.mime_data_text), + {'files': files, 'target': self.itemAt(event.pos())}) else: event.ignore() + + def paintEvent(self, event): + """ + Re-implement paintEvent so that we can add 'No Results' text when the listWidget is empty. + + :param event: A QPaintEvent + :return: None + """ + super().paintEvent(event) + if not self.count(): + viewport = self.viewport() + painter = QtGui.QPainter(viewport) + font = QtGui.QFont() + font.setItalic(True) + painter.setFont(font) + painter.drawText(QtCore.QRect(0, 0, viewport.width(), viewport.height()), + (QtCore.Qt.AlignHCenter | QtCore.Qt.TextWordWrap), self.no_results_text) diff --git a/openlp/plugins/bibles/lib/mediaitem.py b/openlp/plugins/bibles/lib/mediaitem.py index fc92b8f7a..f9329d503 100644 --- a/openlp/plugins/bibles/lib/mediaitem.py +++ b/openlp/plugins/bibles/lib/mediaitem.py @@ -75,21 +75,16 @@ class BibleMediaItem(MediaManagerItem): self.has_search = True self.search_results = {} self.second_search_results = {} - self.check_search_result() Registry().register_function('bibles_load_list', self.reload_bibles) def __check_second_bible(self, bible, second_bible): """ Check if the first item is a second bible item or not. """ - bitem = self.list_view.item(0) - if not bitem.flags() & QtCore.Qt.ItemIsSelectable: - # The item is the "No Search Results" item. - self.list_view.clear() + if not self.list_view.count(): self.display_results(bible, second_bible) return - else: - item_second_bible = self._decode_qt_object(bitem, 'second_bible') + item_second_bible = self._decode_qt_object(self.list_view.item(0), 'second_bible') if item_second_bible and second_bible or not item_second_bible and not second_bible: self.display_results(bible, second_bible) elif critical_error_message_box( @@ -551,14 +546,12 @@ class BibleMediaItem(MediaManagerItem): def on_clear_button_clicked(self): # Clear the list, then set the "No search Results" message, then clear the text field and give it focus. self.list_view.clear() - self.check_search_result() self.quick_search_edit.clear() self.quick_search_edit.setFocus() def on_advanced_clear_button_clicked(self): # The same as the on_clear_button_clicked, but gives focus to Book name field in "Select" (advanced). self.list_view.clear() - self.check_search_result() self.advanced_book_combo_box.setFocus() def on_lock_button_toggled(self, checked): @@ -692,7 +685,6 @@ class BibleMediaItem(MediaManagerItem): elif self.search_results: self.display_results(bible, second_bible) self.advancedSearchButton.setEnabled(True) - self.check_search_result() self.application.set_normal_cursor() def on_quick_reference_search(self): @@ -886,7 +878,6 @@ class BibleMediaItem(MediaManagerItem): elif self.search_results: self.display_results(bible, second_bible) self.quickSearchButton.setEnabled(True) - self.check_search_result() self.application.set_normal_cursor() def on_quick_search_while_typing(self): @@ -917,7 +908,6 @@ class BibleMediaItem(MediaManagerItem): self.__check_second_bible(bible, second_bible) elif self.search_results: self.display_results(bible, second_bible) - self.check_search_result() self.application.set_normal_cursor() def on_search_text_edit_changed(self): @@ -956,17 +946,13 @@ class BibleMediaItem(MediaManagerItem): if len(text) == 0: if not self.quickLockButton.isChecked(): self.list_view.clear() - self.check_search_result() else: if limit == 3 and (len(text) < limit or len(count_space_digit_reference) == 0): if not self.quickLockButton.isChecked(): self.list_view.clear() - self.check_search_result() - elif (limit == 8 and (len(text) < limit or len(count_spaces_two_chars_text) == 0 or - len(count_two_chars_text) < 2)): + elif (limit == 8 and len(text) < limit ): if not self.quickLockButton.isChecked(): - self.list_view.clear() - self.check_search_result_search_while_typing_short() + self.list_view.clear(search_while_typing=True) else: """ Start search if no chars are entered or deleted for 0.2 s diff --git a/openlp/plugins/custom/lib/mediaitem.py b/openlp/plugins/custom/lib/mediaitem.py index 2b999da19..e3b730294 100644 --- a/openlp/plugins/custom/lib/mediaitem.py +++ b/openlp/plugins/custom/lib/mediaitem.py @@ -131,7 +131,6 @@ class CustomMediaItem(MediaManagerItem): # Called to redisplay the custom list screen edith from a search # or from the exit of the Custom edit dialog. If remote editing is # active trigger it and clean up so it will not update again. - self.check_search_result() def on_new_click(self): """ @@ -268,7 +267,6 @@ class CustomMediaItem(MediaManagerItem): CustomSlide.theme_name.like(search_keywords), order_by_ref=CustomSlide.title) self.load_list(search_results) - self.check_search_result() def on_search_text_edit_changed(self, text): """ diff --git a/openlp/plugins/songs/lib/mediaitem.py b/openlp/plugins/songs/lib/mediaitem.py index b89858019..1d060f484 100644 --- a/openlp/plugins/songs/lib/mediaitem.py +++ b/openlp/plugins/songs/lib/mediaitem.py @@ -232,7 +232,6 @@ class SongMediaItem(MediaManagerItem): search_results = self.plugin.manager.get_all_objects( Song, and_(Song.ccli_number.like(search_string), Song.ccli_number != '')) self.display_results_cclinumber(search_results) - self.check_search_result() def search_entire(self, search_keywords): search_string = '%{text}%'.format(text=clean_string(search_keywords)) diff --git a/tests/functional/openlp_plugins/bibles/test_mediaitem.py b/tests/functional/openlp_plugins/bibles/test_mediaitem.py index 28a755fd6..d4a6eee39 100644 --- a/tests/functional/openlp_plugins/bibles/test_mediaitem.py +++ b/tests/functional/openlp_plugins/bibles/test_mediaitem.py @@ -155,7 +155,6 @@ class TestMediaItem(TestCase, TestMixin): self.media_item.list_view = MagicMock() self.media_item.search_results = MagicMock() self.media_item.display_results = MagicMock() - self.media_item.check_search_result = MagicMock() self.app.set_normal_cursor = MagicMock() # WHEN: on_quick_search_button is called @@ -169,7 +168,6 @@ class TestMediaItem(TestCase, TestMixin): self.assertEqual(1, self.media_item.quickLockButton.isChecked.call_count, 'Lock Should had been called once') self.assertEqual(1, self.media_item.display_results.call_count, 'Display results Should had been called once') self.assertEqual(2, self.media_item.quickSearchButton.setEnabled.call_count, 'Disable and Enable the button') - self.assertEqual(1, self.media_item.check_search_result.call_count, 'Check results Should had been called once') self.assertEqual(1, self.app.set_normal_cursor.call_count, 'Normal cursor should had been called once') def test_on_clear_button_clicked(self): @@ -178,7 +176,6 @@ class TestMediaItem(TestCase, TestMixin): """ # GIVEN: Mocked list_view, check_search_results & quick_search_edit. self.media_item.list_view = MagicMock() - self.media_item.check_search_result = MagicMock() self.media_item.quick_search_edit = MagicMock() # WHEN: on_clear_button_clicked is called @@ -186,7 +183,6 @@ class TestMediaItem(TestCase, TestMixin): # THEN: Search result should be reset and search field should receive focus. self.media_item.list_view.clear.assert_called_once_with(), - self.media_item.check_search_result.assert_called_once_with(), self.media_item.quick_search_edit.clear.assert_called_once_with(), self.media_item.quick_search_edit.setFocus.assert_called_once_with() From 426cd13fd0bfc3b1a0ed42d5ccda728ac2d522c4 Mon Sep 17 00:00:00 2001 From: Philip Ridout Date: Fri, 11 Nov 2016 19:19:59 +0000 Subject: [PATCH 28/75] fix if statment --- openlp/plugins/bibles/lib/mediaitem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/plugins/bibles/lib/mediaitem.py b/openlp/plugins/bibles/lib/mediaitem.py index f9329d503..0ad45d055 100644 --- a/openlp/plugins/bibles/lib/mediaitem.py +++ b/openlp/plugins/bibles/lib/mediaitem.py @@ -950,7 +950,7 @@ class BibleMediaItem(MediaManagerItem): if limit == 3 and (len(text) < limit or len(count_space_digit_reference) == 0): if not self.quickLockButton.isChecked(): self.list_view.clear() - elif (limit == 8 and len(text) < limit ): + elif limit == 8 and (len(text) < limit or len(count_two_chars_text) < 2): if not self.quickLockButton.isChecked(): self.list_view.clear(search_while_typing=True) else: From 7a2df5eab39d22efed8e9e996fdafde7b5914898 Mon Sep 17 00:00:00 2001 From: Philip Ridout Date: Fri, 11 Nov 2016 20:50:47 +0000 Subject: [PATCH 29/75] Added test! --- .../test_listwidgetwithdnd.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py diff --git a/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py b/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py new file mode 100644 index 000000000..ee6df9fb4 --- /dev/null +++ b/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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 # +############################################################################### +""" +This module contains tests for the openlp.core.lib.listwidgetwithdnd module +""" +from unittest import TestCase + +from openlp.core.ui.lib.listwidgetwithdnd import ListWidgetWithDnD, NO_RESULTS, SHORT_RESULTS +from unittest.mock import MagicMock, patch + + +class TestListWidgetWithDnD(TestCase): + """ + Test the :class:`~openlp.core.lib.listwidgetwithdnd.ListWidgetWithDnD` class + """ + def test_clear(self): + """ + Test the clear method when called without any arguments. + """ + # GIVEN: An instance of ListWidgetWithDnD + widget = ListWidgetWithDnD() + + # WHEN: Calling clear with out any arguments + widget.clear() + + # THEN: The results text should be the standard 'no results' text. + self.assertEqual(widget.no_results_text, NO_RESULTS) + + def test_clear_search_while_typing(self): + """ + Test the clear method when called with the search_while_typing argument set to True + """ + # GIVEN: An instance of ListWidgetWithDnD + widget = ListWidgetWithDnD() + + # WHEN: Calling clear with search_while_typing set to True + widget.clear(search_while_typing=True) + + # THEN: The results text should be the 'short results' text. + self.assertEqual(widget.no_results_text, SHORT_RESULTS) + + def test_paint_event(self): + """ + Test the paintEvent method when the list is not empty + """ + # GIVEN: An instance of ListWidgetWithDnD with a mocked out count methode which returns 1 + # (i.e the list has an item) + widget = ListWidgetWithDnD() + with patch('openlp.core.ui.lib.listwidgetwithdnd.QtWidgets.QListWidget.paintEvent') as mocked_paint_event, \ + patch.object(widget, 'count', return_value=1), \ + patch.object(widget, 'viewport') as mocked_viewport: + mocked_event = MagicMock() + + # WHEN: Calling paintEvent + widget.paintEvent(mocked_event) + + # THEN: The overridden paintEvnet should have been called + mocked_paint_event.assert_called_once_with(mocked_event) + self.assertFalse(mocked_viewport.called) + + def test_paint_event_no_items(self): + """ + Test the paintEvent method when the list is empty + """ + # GIVEN: An instance of ListWidgetWithDnD with a mocked out count methode which returns 0 + # (i.e the list is empty) + widget = ListWidgetWithDnD() + mocked_painter_instance = MagicMock() + mocked_qrect = MagicMock() + with patch('openlp.core.ui.lib.listwidgetwithdnd.QtWidgets.QListWidget.paintEvent') as mocked_paint_event, \ + patch.object(widget, 'count', return_value=0), \ + patch.object(widget, 'viewport'), \ + patch('openlp.core.ui.lib.listwidgetwithdnd.QtGui.QPainter', + return_value=mocked_painter_instance) as mocked_qpainter, \ + patch('openlp.core.ui.lib.listwidgetwithdnd.QtCore.QRect', return_value=mocked_qrect): + mocked_event = MagicMock() + + # WHEN: Calling paintEvent + widget.paintEvent(mocked_event) + + # THEN: The overridden paintEvnet should have been called, and some text should be drawn. + mocked_paint_event.assert_called_once_with(mocked_event) + mocked_qpainter.assert_called_once_with(widget.viewport()) + mocked_painter_instance.drawText.assert_called_once_with(mocked_qrect, 4100, 'No Search Results') From 6a3982bab93e87bfe5e4a675893d4072b91a9fd3 Mon Sep 17 00:00:00 2001 From: Philip Ridout Date: Sat, 12 Nov 2016 10:00:36 +0000 Subject: [PATCH 30/75] Moved over to using UiStrings --- openlp/core/common/uistrings.py | 2 ++ openlp/core/ui/lib/listwidgetwithdnd.py | 11 ++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/openlp/core/common/uistrings.py b/openlp/core/common/uistrings.py index 3c8477864..e23fc3f40 100644 --- a/openlp/core/common/uistrings.py +++ b/openlp/core/common/uistrings.py @@ -112,6 +112,7 @@ class UiStrings(object): self.NFSp = translate('OpenLP.Ui', 'No Files Selected', 'Plural') self.NISs = translate('OpenLP.Ui', 'No Item Selected', 'Singular') self.NISp = translate('OpenLP.Ui', 'No Items Selected', 'Plural') + self.NoResults = translate('OpenLP.Ui', 'No Search Results') self.OLP = translate('OpenLP.Ui', 'OpenLP') self.OLPV2 = "{name} {version}".format(name=self.OLP, version="2") self.OLPV2x = "{name} {version}".format(name=self.OLP, version="2.4") @@ -139,6 +140,7 @@ class UiStrings(object): self.Settings = translate('OpenLP.Ui', 'Settings') self.SaveService = translate('OpenLP.Ui', 'Save Service') self.Service = translate('OpenLP.Ui', 'Service') + self.ShortResults = translate('OpenLP.Ui', 'Please type more text to use \'Search As You Type\'') self.Split = translate('OpenLP.Ui', 'Optional &Split') self.SplitToolTip = translate('OpenLP.Ui', 'Split a slide into two only if it does not fit on the screen as one slide.') diff --git a/openlp/core/ui/lib/listwidgetwithdnd.py b/openlp/core/ui/lib/listwidgetwithdnd.py index 23bdbcd2d..de601fa13 100644 --- a/openlp/core/ui/lib/listwidgetwithdnd.py +++ b/openlp/core/ui/lib/listwidgetwithdnd.py @@ -26,10 +26,7 @@ import os from PyQt5 import QtCore, QtGui, QtWidgets -from openlp.core.common import Registry, translate - -NO_RESULTS = translate('OpenLP.ListWidgetWithDnD', 'No Search Results') -SHORT_RESULTS = translate('OpenLP.ListWidgetWithDnD', 'Please type more text to use \'Search As You Type\'') +from openlp.core.common import Registry, UiStrings class ListWidgetWithDnD(QtWidgets.QListWidget): @@ -42,7 +39,7 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): """ super().__init__(parent) self.mime_data_text = name - self.no_results_text = NO_RESULTS + self.no_results_text = UiStrings().NoResults def activateDnD(self): """ @@ -60,9 +57,9 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): :return: None """ if search_while_typing: - self.no_results_text = SHORT_RESULTS + self.no_results_text = UiStrings().ShortResults else: - self.no_results_text = NO_RESULTS + self.no_results_text = UiStrings().NoResults super().clear() def mouseMoveEvent(self, event): From f031df36374e3dfec7eaf1dfeae6807f463d03ff Mon Sep 17 00:00:00 2001 From: Philip Ridout Date: Sat, 12 Nov 2016 11:19:52 +0000 Subject: [PATCH 31/75] Fixed Tests --- openlp/core/ui/lib/listwidgetwithdnd.py | 2 +- .../openlp_core_ui_lib/test_listwidgetwithdnd.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openlp/core/ui/lib/listwidgetwithdnd.py b/openlp/core/ui/lib/listwidgetwithdnd.py index de601fa13..daf374ea9 100644 --- a/openlp/core/ui/lib/listwidgetwithdnd.py +++ b/openlp/core/ui/lib/listwidgetwithdnd.py @@ -85,7 +85,7 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): """ if event.mimeData().hasUrls(): event.accept() - else: + else: event.ignore() def dragMoveEvent(self, event): diff --git a/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py b/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py index ee6df9fb4..f6cc8b446 100644 --- a/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py +++ b/tests/functional/openlp_core_ui_lib/test_listwidgetwithdnd.py @@ -24,7 +24,8 @@ This module contains tests for the openlp.core.lib.listwidgetwithdnd module """ from unittest import TestCase -from openlp.core.ui.lib.listwidgetwithdnd import ListWidgetWithDnD, NO_RESULTS, SHORT_RESULTS +from openlp.core.common.uistrings import UiStrings +from openlp.core.ui.lib.listwidgetwithdnd import ListWidgetWithDnD from unittest.mock import MagicMock, patch @@ -43,7 +44,7 @@ class TestListWidgetWithDnD(TestCase): widget.clear() # THEN: The results text should be the standard 'no results' text. - self.assertEqual(widget.no_results_text, NO_RESULTS) + self.assertEqual(widget.no_results_text, UiStrings().NoResults) def test_clear_search_while_typing(self): """ @@ -56,7 +57,7 @@ class TestListWidgetWithDnD(TestCase): widget.clear(search_while_typing=True) # THEN: The results text should be the 'short results' text. - self.assertEqual(widget.no_results_text, SHORT_RESULTS) + self.assertEqual(widget.no_results_text, UiStrings().ShortResults) def test_paint_event(self): """ From 9133c078c71118d0da07e37f63f4161d06490c15 Mon Sep 17 00:00:00 2001 From: Philip Ridout Date: Sat, 12 Nov 2016 11:50:06 +0000 Subject: [PATCH 32/75] Pep againbzr pushbzr push --- openlp/core/ui/lib/listwidgetwithdnd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/core/ui/lib/listwidgetwithdnd.py b/openlp/core/ui/lib/listwidgetwithdnd.py index daf374ea9..de601fa13 100644 --- a/openlp/core/ui/lib/listwidgetwithdnd.py +++ b/openlp/core/ui/lib/listwidgetwithdnd.py @@ -85,7 +85,7 @@ class ListWidgetWithDnD(QtWidgets.QListWidget): """ if event.mimeData().hasUrls(): event.accept() - else: + else: event.ignore() def dragMoveEvent(self, event): From f83d9ad5203ab0da414113ec23fae4d4280419d0 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Sun, 20 Nov 2016 21:24:11 +0100 Subject: [PATCH 33/75] Insert real button label in text to make sure it shows correct text. --- openlp/core/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 15de367e2..4f99cb46e 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -39,7 +39,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import Registry, OpenLPMixin, AppLocation, LanguageManager, Settings, UiStrings, \ check_directory_exists, is_macosx, is_win, translate -from openlp.core.common.versionchecker import VersionThread, get_application_version +from openlp.core.common.versionchecker import VersionThread, get_application_version, clean_button_text from openlp.core.lib import ScreenList from openlp.core.resources import qInitResources from openlp.core.ui import SplashScreen @@ -184,6 +184,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): data_folder_path = AppLocation.get_data_path() if not os.path.exists(data_folder_path): log.critical('Database was not found in: {path}'.format(path=data_folder_path)) + yes_button = clean_button_text(self.buttonText(QtWidgets.QMessageBox.Yes)) + no_button = clean_button_text(self.buttonText(QtWidgets.QMessageBox.Yes)) status = QtWidgets.QMessageBox.critical(None, translate('OpenLP', 'Data Directory Error'), translate('OpenLP', 'OpenLP data folder was not found in:\n\n{path}' '\n\nThe location of the data folder was ' @@ -194,11 +196,11 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): 'location back to the default settings, ' 'or you can try to make the current location ' 'available.\n\n Do you want to reset the ' - 'default data location?\n\n Click "No" to close' - 'OpenLP so you can try to fix the the problem.' - '\n Click "Yes" to reset the default data ' - 'location and start OpenLP.') - .format(path=data_folder_path), + 'default data location?\n\n Click "{no}" to ' + 'close OpenLP so you can try to fix the the ' + 'problem.\n Click "{yes}" to reset the default ' + 'data location and start OpenLP.') + .format(path=data_folder_path, yes=yes_button, no=no_button), QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No), QtWidgets.QMessageBox.No) From 5e600f44176527ba0840997008e4b6c01e52a775 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Mon, 21 Nov 2016 21:21:31 +0100 Subject: [PATCH 34/75] Change the text for missing data path text. --- openlp/core/__init__.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 4f99cb46e..5de5e69de 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -39,7 +39,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import Registry, OpenLPMixin, AppLocation, LanguageManager, Settings, UiStrings, \ check_directory_exists, is_macosx, is_win, translate -from openlp.core.common.versionchecker import VersionThread, get_application_version, clean_button_text +from openlp.core.common.versionchecker import VersionThread, get_application_version from openlp.core.lib import ScreenList from openlp.core.resources import qInitResources from openlp.core.ui import SplashScreen @@ -183,24 +183,20 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): """ data_folder_path = AppLocation.get_data_path() if not os.path.exists(data_folder_path): - log.critical('Database was not found in: {path}'.format(path=data_folder_path)) - yes_button = clean_button_text(self.buttonText(QtWidgets.QMessageBox.Yes)) - no_button = clean_button_text(self.buttonText(QtWidgets.QMessageBox.Yes)) + log.critical('Database was not found in: ' + data_folder_path) status = QtWidgets.QMessageBox.critical(None, translate('OpenLP', 'Data Directory Error'), translate('OpenLP', 'OpenLP data folder was not found in:\n\n{path}' '\n\nThe location of the data folder was ' - 'previously changed from the OpenLP\'s\n' + 'previously changed from the OpenLP\'s ' 'default location. If the data was stored on ' - 'removable device, that device\nneeds to be ' - 'made available.\n\n You may reset the data ' - 'location back to the default settings, ' + 'removable device, that device needs to be ' + 'made available.\n\nYou may reset the data ' + 'location back to the default location, ' 'or you can try to make the current location ' - 'available.\n\n Do you want to reset the ' - 'default data location?\n\n Click "{no}" to ' - 'close OpenLP so you can try to fix the the ' - 'problem.\n Click "{yes}" to reset the default ' - 'data location and start OpenLP.') - .format(path=data_folder_path, yes=yes_button, no=no_button), + 'available.\n\nDo you want to reset to the ' + 'default data location? If not, OpenLP will be ' + 'closed so you can try to fix the the problem.') + .format(path=data_folder_path), QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No), QtWidgets.QMessageBox.No) @@ -404,8 +400,12 @@ def main(args=None): Registry.create() Registry().register('application', application) application.setApplicationVersion(get_application_version()['version']) - # If user answers "No" to already running or missing db dialogue, shutdown OpenLP. - if application.is_already_running() or application.is_data_path_missing(): + # Check if an instance of OpenLP is already running. Quit if there is a running instance and the user only wants one + if application.is_already_running(): + sys.exit() + # If the custom data path is missing and the user wants to restore the data path, quit OpenLP. + if application.is_data_path_missing(): + application.shared_memory.detach() sys.exit() # Remove/convert obsolete settings. Settings().remove_obsolete_settings() From b31d5ad08eded0e179838304bb90fc6d882ac6c9 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Mon, 21 Nov 2016 21:56:48 +0100 Subject: [PATCH 35/75] pep8 fix --- openlp/core/lib/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index 69c3d30b3..e0a4ba541 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -322,8 +322,8 @@ def create_separated_list(string_list): elif list_length == 2: list_to_string = translate('OpenLP.core.lib', '{one} and {two}').format(one=string_list[0], two=string_list[1]) elif list_length > 2: - list_to_string = translate('OpenLP.core.lib', '{first}, and {last}').format(first=', '.join(string_list[:-1]), - last=string_list[-1]) + list_to_string = translate('OpenLP.core.lib', '{first} and {last}').format(first=', '.join(string_list[:-1]), + last=string_list[-1]) else: list_to_string = '' return list_to_string From 8275f1139a4af3183b210117ffaa9765f03652b6 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Mon, 21 Nov 2016 22:07:01 +0100 Subject: [PATCH 36/75] Change tests to reflect change. --- tests/functional/openlp_core_lib/test_lib.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/functional/openlp_core_lib/test_lib.py b/tests/functional/openlp_core_lib/test_lib.py index 05c2b8a28..884d05be5 100644 --- a/tests/functional/openlp_core_lib/test_lib.py +++ b/tests/functional/openlp_core_lib/test_lib.py @@ -666,8 +666,8 @@ class TestLib(TestCase): string_result = create_separated_list(string_list) # THEN: We should have "Author 1, Author 2, and Author 3" - assert string_result == 'Author 1, Author 2, and Author 3', 'The string should be u\'Author 1, ' \ - 'Author 2, and Author 3\'.' + self.assertEqual(string_result, 'Author 1, Author 2 and Author 3', 'The string should be "Author 1, ' + 'Author 2, and Author 3".') def test_create_separated_list_empty_list(self): """ @@ -683,7 +683,7 @@ class TestLib(TestCase): string_result = create_separated_list(string_list) # THEN: We shoud have an emptry string. - assert string_result == '', 'The string sould be empty.' + self.assertEqual(string_result, '', 'The string sould be empty.') def test_create_separated_list_with_one_item(self): """ @@ -696,7 +696,7 @@ class TestLib(TestCase): string_result = create_separated_list(string_list) # THEN: We should have "Author 1" - assert string_result == 'Author 1', 'The string should be u\'Author 1\'.' + self.assertEqual(string_result, 'Author 1', 'The string should be "Author 1".') def test_create_separated_list_with_two_items(self): """ @@ -709,7 +709,7 @@ class TestLib(TestCase): string_result = create_separated_list(string_list) # THEN: We should have "Author 1 and Author 2" - assert string_result == 'Author 1 and Author 2', 'The string should be u\'Author 1 and Author 2\'.' + self.assertEqual(string_result, 'Author 1 and Author 2', 'The string should be "Author 1 and Author 2".') def test_create_separated_list_with_three_items(self): """ @@ -721,6 +721,6 @@ class TestLib(TestCase): # WHEN: We get a string build from the entries it the list and a seperator. string_result = create_separated_list(string_list) - # THEN: We should have "Author 1, Author 2, and Author 3" - assert string_result == 'Author 1, Author 2, and Author 3', 'The string should be u\'Author 1, ' \ - 'Author 2, and Author 3\'.' + # THEN: We should have "Author 1, Author 2 and Author 3" + self.assertEqual(string_result, 'Author 1, Author 2 and Author 3', 'The string should be "Author 1, ' + 'Author 2, and Author 3".') From 826bfd02ddcc1dbe0b1cbb5322cf428f2f507a31 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 24 Nov 2016 23:48:51 +0200 Subject: [PATCH 37/75] Add a WordProject Bible importer --- openlp/core/ui/lib/wizard.py | 1 + .../plugins/bibles/forms/bibleimportform.py | 43 +- .../bibles/lib/importers/wordproject.py | 169 +++++ openlp/plugins/bibles/lib/manager.py | 7 +- openlp/plugins/bibles/lib/mediaitem.py | 1 + openlp/plugins/media/lib/mediaitem.py | 3 +- .../bibles/test_wordprojectimport.py | 691 ++++++++++++++++++ 7 files changed, 912 insertions(+), 3 deletions(-) create mode 100644 openlp/plugins/bibles/lib/importers/wordproject.py create mode 100644 tests/functional/openlp_plugins/bibles/test_wordprojectimport.py diff --git a/openlp/core/ui/lib/wizard.py b/openlp/core/ui/lib/wizard.py index 5f2321f48..68efc43c1 100644 --- a/openlp/core/ui/lib/wizard.py +++ b/openlp/core/ui/lib/wizard.py @@ -46,6 +46,7 @@ class WizardStrings(object): OSIS = 'OSIS' ZEF = 'Zefania' SWORD = 'Sword' + WordProject = 'WordProject' # These strings should need a good reason to be retranslated elsewhere. FinishedImport = translate('OpenLP.Ui', 'Finished import.') FormatLabel = translate('OpenLP.Ui', 'Format:') diff --git a/openlp/plugins/bibles/forms/bibleimportform.py b/openlp/plugins/bibles/forms/bibleimportform.py index e1e062155..d7eae281f 100644 --- a/openlp/plugins/bibles/forms/bibleimportform.py +++ b/openlp/plugins/bibles/forms/bibleimportform.py @@ -125,6 +125,7 @@ class BibleImportForm(OpenLPWizard): self.csv_verses_button.clicked.connect(self.on_csv_verses_browse_button_clicked) self.open_song_browse_button.clicked.connect(self.on_open_song_browse_button_clicked) self.zefania_browse_button.clicked.connect(self.on_zefania_browse_button_clicked) + self.wordproject_browse_button.clicked.connect(self.on_wordproject_browse_button_clicked) self.web_update_button.clicked.connect(self.on_web_update_button_clicked) self.sword_browse_button.clicked.connect(self.on_sword_browse_button_clicked) self.sword_zipbrowse_button.clicked.connect(self.on_sword_zipbrowse_button_clicked) @@ -143,7 +144,7 @@ class BibleImportForm(OpenLPWizard): self.format_label = QtWidgets.QLabel(self.select_page) self.format_label.setObjectName('FormatLabel') self.format_combo_box = QtWidgets.QComboBox(self.select_page) - self.format_combo_box.addItems(['', '', '', '', '', '']) + self.format_combo_box.addItems(['', '', '', '', '', '', '']) self.format_combo_box.setObjectName('FormatComboBox') self.format_layout.addRow(self.format_label, self.format_combo_box) self.spacer = QtWidgets.QSpacerItem(10, 0, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) @@ -355,6 +356,25 @@ class BibleImportForm(OpenLPWizard): self.sword_disabled_label.setObjectName('SwordDisabledLabel') self.sword_layout.addWidget(self.sword_disabled_label) self.select_stack.addWidget(self.sword_widget) + self.wordproject_widget = QtWidgets.QWidget(self.select_page) + self.wordproject_widget.setObjectName('WordProjectWidget') + self.wordproject_layout = QtWidgets.QFormLayout(self.wordproject_widget) + self.wordproject_layout.setContentsMargins(0, 0, 0, 0) + self.wordproject_layout.setObjectName('WordProjectLayout') + self.wordproject_file_label = QtWidgets.QLabel(self.wordproject_widget) + self.wordproject_file_label.setObjectName('WordProjectFileLabel') + self.wordproject_file_layout = QtWidgets.QHBoxLayout() + self.wordproject_file_layout.setObjectName('WordProjectFileLayout') + self.wordproject_file_edit = QtWidgets.QLineEdit(self.wordproject_widget) + self.wordproject_file_edit.setObjectName('WordProjectFileEdit') + self.wordproject_file_layout.addWidget(self.wordproject_file_edit) + self.wordproject_browse_button = QtWidgets.QToolButton(self.wordproject_widget) + self.wordproject_browse_button.setIcon(self.open_icon) + self.wordproject_browse_button.setObjectName('WordProjectBrowseButton') + self.wordproject_file_layout.addWidget(self.wordproject_browse_button) + self.wordproject_layout.addRow(self.wordproject_file_label, self.wordproject_file_layout) + self.wordproject_layout.setItem(5, QtWidgets.QFormLayout.LabelRole, self.spacer) + self.select_stack.addWidget(self.wordproject_widget) self.select_page_layout.addLayout(self.select_stack) self.addPage(self.select_page) # License Page @@ -400,6 +420,7 @@ class BibleImportForm(OpenLPWizard): self.format_combo_box.setItemText(BibleFormat.OSIS, WizardStrings.OSIS) self.format_combo_box.setItemText(BibleFormat.CSV, WizardStrings.CSV) self.format_combo_box.setItemText(BibleFormat.OpenSong, WizardStrings.OS) + self.format_combo_box.setItemText(BibleFormat.WordProject, WizardStrings.WordProject) self.format_combo_box.setItemText(BibleFormat.WebDownload, translate('BiblesPlugin.ImportWizardForm', 'Web Download')) self.format_combo_box.setItemText(BibleFormat.Zefania, WizardStrings.ZEF) @@ -410,6 +431,7 @@ class BibleImportForm(OpenLPWizard): self.open_song_file_label.setText(translate('BiblesPlugin.ImportWizardForm', 'Bible file:')) self.web_source_label.setText(translate('BiblesPlugin.ImportWizardForm', 'Location:')) self.zefania_file_label.setText(translate('BiblesPlugin.ImportWizardForm', 'Bible file:')) + self.wordproject_file_label.setText(translate('BiblesPlugin.ImportWizardForm', 'Bible file:')) self.web_update_label.setText(translate('BiblesPlugin.ImportWizardForm', 'Click to download bible list')) self.web_update_button.setText(translate('BiblesPlugin.ImportWizardForm', 'Download bible list')) self.web_source_combo_box.setItemText(WebDownload.Crosswalk, translate('BiblesPlugin.ImportWizardForm', @@ -504,6 +526,12 @@ class BibleImportForm(OpenLPWizard): critical_error_message_box(UiStrings().NFSs, WizardStrings.YouSpecifyFile % WizardStrings.ZEF) self.zefania_file_edit.setFocus() return False + elif self.field('source_format') == BibleFormat.WordProject: + if not self.field('wordproject_file'): + critical_error_message_box(UiStrings().NFSs, + WizardStrings.YouSpecifyFile % WizardStrings.WordProject) + self.wordproject_file_edit.setFocus() + return False elif self.field('source_format') == BibleFormat.WebDownload: # If count is 0 the bible list has not yet been downloaded if self.web_translation_combo_box.count() == 0: @@ -627,6 +655,14 @@ class BibleImportForm(OpenLPWizard): self.get_file_name(WizardStrings.OpenTypeFile % WizardStrings.ZEF, self.zefania_file_edit, 'last directory import') + def on_wordproject_browse_button_clicked(self): + """ + Show the file open dialog for the WordProject file. + """ + # TODO: Verify format() with variable template + self.get_file_name(WizardStrings.OpenTypeFile % WizardStrings.WordProject, self.wordproject_file_edit, + 'last directory import') + def on_web_update_button_clicked(self): """ Download list of bibles from Crosswalk, BibleServer and BibleGateway. @@ -707,6 +743,7 @@ class BibleImportForm(OpenLPWizard): self.select_page.registerField('csv_versefile', self.csv_verses_edit) self.select_page.registerField('opensong_file', self.open_song_file_edit) self.select_page.registerField('zefania_file', self.zefania_file_edit) + self.select_page.registerField('wordproject_file', self.wordproject_file_edit) self.select_page.registerField('web_location', self.web_source_combo_box) self.select_page.registerField('web_biblename', self.web_translation_combo_box) self.select_page.registerField('sword_folder_path', self.sword_folder_edit) @@ -799,6 +836,10 @@ class BibleImportForm(OpenLPWizard): # Import a Zefania bible. importer = self.manager.import_bible(BibleFormat.Zefania, name=license_version, filename=self.field('zefania_file')) + elif bible_type == BibleFormat.WordProject: + # Import a WordProject bible. + importer = self.manager.import_bible(BibleFormat.WordProject, name=license_version, + filename=self.field('wordproject_file')) elif bible_type == BibleFormat.SWORD: # Import a SWORD bible. if self.sword_tab_widget.currentIndex() == self.sword_tab_widget.indexOf(self.sword_folder_tab): diff --git a/openlp/plugins/bibles/lib/importers/wordproject.py b/openlp/plugins/bibles/lib/importers/wordproject.py new file mode 100644 index 000000000..f48749fc6 --- /dev/null +++ b/openlp/plugins/bibles/lib/importers/wordproject.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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 # +############################################################################### +import os +import re +import logging +from codecs import open as copen +from tempfile import TemporaryDirectory +from zipfile import ZipFile + +from bs4 import BeautifulSoup, Tag, NavigableString + +from openlp.plugins.bibles.lib.bibleimport import BibleImport + +BOOK_NUMBER_PATTERN = re.compile(r'\[(\d+)\]') +REPLACE_SPACES = re.compile(r'\s{2,}') + +log = logging.getLogger(__name__) + + +class WordProjectBible(BibleImport): + """ + `WordProject `_ Bible format importer class. + """ + def _cleanup(self): + """ + Clean up after ourselves + """ + self.tmp.cleanup() + + def _unzip_file(self): + """ + Unzip the file to a temporary directory + """ + self.tmp = TemporaryDirectory() + zip_file = ZipFile(os.path.abspath(self.filename)) + zip_file.extractall(self.tmp.name) + self.base_dir = os.path.join(self.tmp.name, os.path.splitext(os.path.basename(self.filename))[0]) + + def process_books(self): + """ + Extract and create the bible books from the parsed html + + :param bible_data: parsed xml + :return: None + """ + with copen(os.path.join(self.base_dir, 'index.htm'), encoding='utf-8', errors='ignore') as index_file: + page = index_file.read() + soup = BeautifulSoup(page, 'lxml') + bible_books = soup.find('div', 'textOptions').find_all('li') + book_count = len(bible_books) + for li_book in bible_books: + log.debug(li_book) + if self.stop_import_flag: + break + # Sometimes the structure is "[1] Genesis", and sometimes it's "[1] Genesis" + if isinstance(li_book.contents[0], NavigableString) and str(li_book.contents[0]).strip(): + book_string = str(li_book.contents[0]) + book_name = str(li_book.a.contents[0]) + elif li_book.a: + book_string, book_name = str(li_book.a.contents[0]).split(' ', 1) + book_link = li_book.a['href'] + book_id = int(BOOK_NUMBER_PATTERN.search(book_string).group(1)) + book_name = book_name.strip() + db_book = self.find_and_create_book(book_name, book_count, self.language_id, book_id) + self.process_chapters(db_book, book_id, book_link) + self.session.commit() + + def process_chapters(self, db_book, book_id, book_link): + """ + Extract the chapters, and do some initial processing of the verses + + :param book: An OpenLP bible database book object + :param chapters: parsed chapters + :return: None + """ + log.debug(book_link) + book_file = os.path.join(self.base_dir, os.path.normpath(book_link)) + with copen(book_file, encoding='utf-8', errors='ignore') as f: + page = f.read() + soup = BeautifulSoup(page, 'lxml') + header_div = soup.find('div', 'textHeader') + chapters_p = header_div.find('p') + if not chapters_p: + chapters_p = soup.p + log.debug(chapters_p) + for item in chapters_p.contents: + if self.stop_import_flag: + break + if isinstance(item, Tag) and item.name in ['a', 'span']: + chapter_number = int(item.string.strip()) + self.set_current_chapter(db_book.name, chapter_number) + self.process_verses(db_book, book_id, chapter_number) + + def process_verses(self, db_book, book_number, chapter_number): + """ + Get the verses for a particular book + """ + chapter_file_name = os.path.join(self.base_dir, '{:02d}'.format(book_number), '{}.htm'.format(chapter_number)) + with copen(chapter_file_name, encoding='utf-8', errors='ignore') as chapter_file: + page = chapter_file.read() + soup = BeautifulSoup(page, 'lxml') + text_body = soup.find('div', 'textBody') + if text_body: + verses_p = text_body.find('p') + else: + verses_p = soup.find_all('p')[2] + verse_number = 0 + verse_text = '' + for item in verses_p.contents: + if self.stop_import_flag: + break + if isinstance(item, Tag) and 'verse' in item.get('class', []): + if verse_number > 0: + self.process_verse(db_book, chapter_number, verse_number, verse_text.strip()) + verse_number = int(item.string.strip()) + verse_text = '' + elif isinstance(item, NavigableString): + verse_text += str(item) + elif isinstance(item, Tag) and item.name in ['span', 'a']: + verse_text += str(item.string) + else: + log.warning('Can\'t store %s', item) + self.process_verse(db_book, chapter_number, verse_number, verse_text.strip()) + + def process_verse(self, db_book, chapter_number, verse_number, verse_text): + """ + Process a verse element + :param book: A database Book object + :param chapter_number: The chapter number to add the verses to (int) + :param element: The verse element to process. (etree element type) + :param use_milestones: set to True to process a 'milestone' verse. Defaults to False + :return: None + """ + if verse_text: + log.debug('%s %s:%s %s', db_book.name, chapter_number, verse_number, verse_text.strip()) + self.create_verse(db_book.id, chapter_number, verse_number, verse_text.strip()) + + def do_import(self, bible_name=None): + """ + Loads a Bible from file. + """ + self.log_debug('Starting WordProject import from "{name}"'.format(name=self.filename)) + self._unzip_file() + self.language_id = self.get_language_id(None, bible_name=self.filename) + result = False + if self.language_id: + self.process_books() + result = True + self._cleanup() + return result diff --git a/openlp/plugins/bibles/lib/manager.py b/openlp/plugins/bibles/lib/manager.py index fbb6fb6e7..084e24270 100644 --- a/openlp/plugins/bibles/lib/manager.py +++ b/openlp/plugins/bibles/lib/manager.py @@ -31,6 +31,7 @@ from .importers.http import HTTPBible from .importers.opensong import OpenSongBible from .importers.osis import OSISBible from .importers.zefania import ZefaniaBible +from .importers.wordproject import WordProjectBible try: from .importers.sword import SwordBible except: @@ -50,6 +51,7 @@ class BibleFormat(object): WebDownload = 3 Zefania = 4 SWORD = 5 + WordProject = 6 @staticmethod def get_class(bible_format): @@ -70,6 +72,8 @@ class BibleFormat(object): return ZefaniaBible elif bible_format == BibleFormat.SWORD: return SwordBible + elif bible_format == BibleFormat.WordProject: + return WordProjectBible else: return None @@ -84,7 +88,8 @@ class BibleFormat(object): BibleFormat.OpenSong, BibleFormat.WebDownload, BibleFormat.Zefania, - BibleFormat.SWORD + BibleFormat.SWORD, + BibleFormat.WordProject ] diff --git a/openlp/plugins/bibles/lib/mediaitem.py b/openlp/plugins/bibles/lib/mediaitem.py index 9a04e5360..c9b5c6b1e 100644 --- a/openlp/plugins/bibles/lib/mediaitem.py +++ b/openlp/plugins/bibles/lib/mediaitem.py @@ -425,6 +425,7 @@ class BibleMediaItem(MediaManagerItem): verse_count = self.plugin.manager.get_verse_count_by_book_ref_id(bible, book_ref_id, 1) if verse_count == 0: self.advancedSearchButton.setEnabled(False) + log.warning('Not enough chapters in %s', book_ref_id) critical_error_message_box(message=translate('BiblesPlugin.MediaItem', 'Bible not fully loaded.')) else: self.advancedSearchButton.setEnabled(True) diff --git a/openlp/plugins/media/lib/mediaitem.py b/openlp/plugins/media/lib/mediaitem.py index dc196fb59..00fccb657 100644 --- a/openlp/plugins/media/lib/mediaitem.py +++ b/openlp/plugins/media/lib/mediaitem.py @@ -150,7 +150,8 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): triggers=self.on_replace_click) if 'webkit' not in get_media_players()[0]: self.replace_action.setDisabled(True) - self.replace_action_context.setDisabled(True) + if hasattr(self, 'replace_action_context'): + self.replace_action_context.setDisabled(True) self.reset_action = self.toolbar.add_toolbar_action('reset_action', icon=':/system/system_close.png', visible=False, triggers=self.on_reset_click) self.media_widget = QtWidgets.QWidget(self) diff --git a/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py b/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py new file mode 100644 index 000000000..45a77c50f --- /dev/null +++ b/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py @@ -0,0 +1,691 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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 # +############################################################################### +""" +This module contains tests for the WordProject Bible importer. +""" + +import os +import json +from unittest import TestCase + +from openlp.plugins.bibles.lib.importers.wordproject import WordProjectBible +from openlp.plugins.bibles.lib.db import BibleDB + +from tests.functional import MagicMock, patch, call + +TEST_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), + '..', '..', '..', 'resources', 'bibles')) +INDEX_PAGE = """ + + + + + The Holy Bible in the English language with audio narration - KJV + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +

    WordProject

    +
    +
    +
    + + +
    +
    + +
    + +facebook + +twitter + +google + +linkin

    +
    + +
    + + +
    +
    +
    + +
    +
    +
    +

    Top + +

    + +
    +
    +
    + + + + + + + + +""" +CHAPTER_PAGE = """ + + + + + Creation of the world, Genesis Chapter 1 + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +

    WordProject

    +
    +
    +
    + + +
    +
    + +
    + +facebook + +twitter + +google + +linkin

    +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +

    Genesis

    + +

    Chapter: + +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 + +

    +
    + + + +
    +
    +

    Chapter 1

    + + +

    1 In the beginning God created the heaven and the earth. +
    2 And the earth was without form, and void; and darkness was upon the face of the deep. And the Spirit of God moved upon the face of the waters. +
    3 And God said, Let there be light: and there was light. +
    4 And God saw the light, that it was good: and God divided the light from the darkness. +
    5 And God called the light Day, and the darkness he called Night. And the evening and the morning were the first day. +
    6 And God said, Let there be a firmament in the midst of the waters, and let it divide the waters from the waters. +
    7 And God made the firmament, and divided the waters which were under the firmament from the waters which were above the firmament: and it was so. +
    8 And God called the firmament Heaven. And the evening and the morning were the second day. +
    9 And God said, Let the waters under the heaven be gathered together unto one place, and let the dry land appear: and it was so. +
    10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good. +
    11 And God said, Let the earth bring forth grass, the herb yielding seed, and the fruit tree yielding fruit after his kind, whose seed is in itself, upon the earth: and it was so. +
    12 And the earth brought forth grass, and herb yielding seed after his kind, and the tree yielding fruit, whose seed was in itself, after his kind: and God saw that it was good. +
    13 And the evening and the morning were the third day. +
    14 And God said, Let there be lights in the firmament of the heaven to divide the day from the night; and let them be for signs, and for seasons, and for days, and years: +
    15 And let them be for lights in the firmament of the heaven to give light upon the earth: and it was so. +
    16 And God made two great lights; the greater light to rule the day, and the lesser light to rule the night: he made the stars also. +
    17 And God set them in the firmament of the heaven to give light upon the earth, +
    18 And to rule over the day and over the night, and to divide the light from the darkness: and God saw that it was good. +
    19 And the evening and the morning were the fourth day. +
    20 And God said, Let the waters bring forth abundantly the moving creature that hath life, and fowl that may fly above the earth in the open firmament of heaven. +
    21 And God created great whales, and every living creature that moveth, which the waters brought forth abundantly, after their kind, and every winged fowl after his kind: and God saw that it was good. +
    22 And God blessed them, saying, Be fruitful, and multiply, and fill the waters in the seas, and let fowl multiply in the earth. +
    23 And the evening and the morning were the fifth day. +
    24 And God said, Let the earth bring forth the living creature after his kind, cattle, and creeping thing, and beast of the earth after his kind: and it was so. +
    25 And God made the beast of the earth after his kind, and cattle after their kind, and every thing that creepeth upon the earth after his kind: and God saw that it was good. +
    26 And God said, Let us make man in our image, after our likeness: and let them have dominion over the fish of the sea, and over the fowl of the air, and over the cattle, and over all the earth, and over every creeping thing that creepeth upon the earth. +
    27 So God created man in his own image, in the image of God created he him; male and female created he them. +
    28 And God blessed them, and God said unto them, Be fruitful, and multiply, and replenish the earth, and subdue it: and have dominion over the fish of the sea, and over the fowl of the air, and over every living thing that moveth upon the earth. +
    29 And God said, Behold, I have given you every herb bearing seed, which is upon the face of all the earth, and every tree, in the which is the fruit of a tree yielding seed; to you it shall be for meat. +
    30 And to every beast of the earth, and to every fowl of the air, and to every thing that creepeth upon the earth, wherein there is life, I have given every green herb for meat: and it was so. +
    31 And God saw every thing that he had made, and, behold, it was very good. And the evening and the morning were the sixth day. +

    + +
    +
    +
    +
    +
    + +
    +
    +
    +

     printer  +  arrowup  + +  arrowright 

    + +
    +
    +
    + + + + + + + + +""" + + +class TestWordProjectImport(TestCase): + """ + Test the functions in the :mod:`wordprojectimport` module. + """ + + def setUp(self): + self.registry_patcher = patch('openlp.plugins.bibles.lib.bibleimport.Registry') + self.addCleanup(self.registry_patcher.stop) + self.registry_patcher.start() + self.manager_patcher = patch('openlp.plugins.bibles.lib.db.Manager') + self.addCleanup(self.manager_patcher.stop) + self.manager_patcher.start() + + @patch('openlp.plugins.bibles.lib.importers.wordproject.os') + @patch('openlp.plugins.bibles.lib.importers.wordproject.copen') + def test_process_books(self, mocked_open, mocked_os): + """ + Test the process_books() method + """ + # GIVEN: A WordProject importer and a bunch of mocked things + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + importer.base_dir = '' + importer.stop_import_flag = False + importer.language_id = 'en' + mocked_open.return_value.__enter__.return_value.read.return_value = INDEX_PAGE + mocked_os.path.join.side_effect = lambda *x: ''.join(x) + + # WHEN: process_books() is called + with patch.object(importer, 'find_and_create_book') as mocked_find_and_create_book, \ + patch.object(importer, 'process_chapters') as mocked_process_chapters, \ + patch.object(importer, 'session') as mocked_session: + importer.process_books() + + # THEN: The right methods should have been called + mocked_os.path.join.assert_called_once_with('', 'index.htm') + mocked_open.assert_called_once_with('index.htm', encoding='utf-8', errors='ignore') + assert mocked_find_and_create_book.call_count == 66, 'There should be 66 books' + assert mocked_process_chapters.call_count == 66, 'There should be 66 books' + assert mocked_session.commit.call_count == 66, 'There should be 66 books' + + @patch('openlp.plugins.bibles.lib.importers.wordproject.os') + @patch('openlp.plugins.bibles.lib.importers.wordproject.copen') + def test_process_chapters(self, mocked_open, mocked_os): + """ + Test the process_chapters() method + """ + # GIVEN: A WordProject importer and a bunch of mocked things + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + importer.base_dir = '' + importer.stop_import_flag = False + importer.language_id = 'en' + mocked_open.return_value.__enter__.return_value.read.return_value = CHAPTER_PAGE + mocked_os.path.join.side_effect = lambda *x: ''.join(x) + mocked_os.path.normpath.side_effect = lambda x: x + mocked_db_book = MagicMock() + mocked_db_book.name = 'Genesis' + book_id = 1 + book_link = '01/1.htm' + + # WHEN: process_chapters() is called + with patch.object(importer, 'set_current_chapter') as mocked_set_current_chapter, \ + patch.object(importer, 'process_verses') as mocked_process_verses: + importer.process_chapters(mocked_db_book, book_id, book_link) + + # THEN: The right methods should have been called + expected_set_current_chapter_calls = [call('Genesis', ch) for ch in range(1, 51)] + expected_process_verses_calls = [call(mocked_db_book, 1, ch) for ch in range(1, 51)] + mocked_os.path.join.assert_called_once_with('', '01/1.htm') + mocked_open.assert_called_once_with('01/1.htm', encoding='utf-8', errors='ignore') + assert mocked_set_current_chapter.call_args_list == expected_set_current_chapter_calls + assert mocked_process_verses.call_args_list == expected_process_verses_calls + + @patch('openlp.plugins.bibles.lib.importers.wordproject.os') + @patch('openlp.plugins.bibles.lib.importers.wordproject.copen') + def test_process_verses(self, mocked_open, mocked_os): + """ + Test the process_verses() method + """ + # GIVEN: A WordProject importer and a bunch of mocked things + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + importer.base_dir = '' + importer.stop_import_flag = False + importer.language_id = 'en' + mocked_open.return_value.__enter__.return_value.read.return_value = CHAPTER_PAGE + mocked_os.path.join.side_effect = lambda *x: '/'.join(x) + mocked_db_book = MagicMock() + mocked_db_book.name = 'Genesis' + book_number = 1 + chapter_number = 1 + + # WHEN: process_verses() is called + with patch.object(importer, 'process_verse') as mocked_process_verse: + importer.process_verses(mocked_db_book, book_number, chapter_number) + + # THEN: All the right methods should have been called + mocked_os.path.join.assert_called_once_with('', '01', '1.htm') + mocked_open.assert_called_once_with('/01/1.htm', encoding='utf-8', errors='ignore') + assert mocked_process_verse.call_count == 31 + + def test_process_verse(self): + """ + Test the process_verse() method + """ + # GIVEN: An importer and a mocked method + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + mocked_db_book = MagicMock() + mocked_db_book.id = 1 + chapter_number = 1 + verse_number = 1 + verse_text = ' In the beginning, God created the heavens and the earth ' + + # WHEN: process_verse() is called + with patch.object(importer, 'create_verse') as mocked_create_verse: + importer.process_verse(mocked_db_book, chapter_number, verse_number, verse_text) + + # THEN: The create_verse() method should have been called + mocked_create_verse.assert_called_once_with(1, 1, 1, 'In the beginning, God created the heavens and the earth') + + def test_process_verse_no_text(self): + """ + Test the process_verse() method when there's no text + """ + # GIVEN: An importer and a mocked method + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + mocked_db_book = MagicMock() + mocked_db_book.id = 1 + chapter_number = 1 + verse_number = 1 + verse_text = '' + + # WHEN: process_verse() is called + with patch.object(importer, 'create_verse') as mocked_create_verse: + importer.process_verse(mocked_db_book, chapter_number, verse_number, verse_text) + + # THEN: The create_verse() method should NOT have been called + assert mocked_create_verse.call_count == 0 + + def test_do_import(self): + """ + Test the do_import() method + """ + # GIVEN: An importer and mocked methods + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + + # WHEN: do_import() is called + with patch.object(importer, '_unzip_file') as mocked_unzip_file, \ + patch.object(importer, 'get_language_id') as mocked_get_language_id, \ + patch.object(importer, 'process_books') as mocked_process_books, \ + patch.object(importer, '_cleanup') as mocked_cleanup: + mocked_get_language_id.return_value = 1 + result = importer.do_import() + + # THEN: The correct methods should have been called + mocked_unzip_file.assert_called_once_with() + mocked_get_language_id.assert_called_once_with(None, bible_name='kj.zip') + mocked_process_books.assert_called_once_with() + mocked_cleanup.assert_called_once_with() + assert result is True + + def test_do_import_no_language(self): + """ + Test the do_import() method when the language is not available + """ + # GIVEN: An importer and mocked methods + importer = WordProjectBible(MagicMock(), path='.', name='.', filename='kj.zip') + + # WHEN: do_import() is called + with patch.object(importer, '_unzip_file') as mocked_unzip_file, \ + patch.object(importer, 'get_language_id') as mocked_get_language_id, \ + patch.object(importer, 'process_books') as mocked_process_books, \ + patch.object(importer, '_cleanup') as mocked_cleanup: + mocked_get_language_id.return_value = None + result = importer.do_import() + + # THEN: The correct methods should have been called + mocked_unzip_file.assert_called_once_with() + mocked_get_language_id.assert_called_once_with(None, bible_name='kj.zip') + assert mocked_process_books.call_count == 0 + mocked_cleanup.assert_called_once_with() + assert result is False From 846476c4ad00c92caa97c5974cadc9adb1ae051a Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Fri, 25 Nov 2016 15:58:33 +0200 Subject: [PATCH 38/75] Stop wizards from resizing themselves based on the contents of comboboxes --- openlp/core/ui/lib/wizard.py | 1 + .../plugins/bibles/forms/bibleimportform.py | 33 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/openlp/core/ui/lib/wizard.py b/openlp/core/ui/lib/wizard.py index 68efc43c1..06225a376 100644 --- a/openlp/core/ui/lib/wizard.py +++ b/openlp/core/ui/lib/wizard.py @@ -96,6 +96,7 @@ class OpenLPWizard(QtWidgets.QWizard, RegistryProperties): super(OpenLPWizard, self).__init__(parent) self.plugin = plugin self.with_progress_page = add_progress_page + self.setFixedWidth(640) self.setObjectName(name) self.open_icon = build_icon(':/general/general_open.png') self.delete_icon = build_icon(':/general/general_delete.png') diff --git a/openlp/plugins/bibles/forms/bibleimportform.py b/openlp/plugins/bibles/forms/bibleimportform.py index d7eae281f..564474271 100644 --- a/openlp/plugins/bibles/forms/bibleimportform.py +++ b/openlp/plugins/bibles/forms/bibleimportform.py @@ -162,6 +162,7 @@ class BibleImportForm(OpenLPWizard): self.osis_file_layout = QtWidgets.QHBoxLayout() self.osis_file_layout.setObjectName('OsisFileLayout') self.osis_file_edit = QtWidgets.QLineEdit(self.osis_widget) + self.osis_file_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.osis_file_edit.setObjectName('OsisFileEdit') self.osis_file_layout.addWidget(self.osis_file_edit) self.osis_browse_button = QtWidgets.QToolButton(self.osis_widget) @@ -181,6 +182,7 @@ class BibleImportForm(OpenLPWizard): self.csv_books_layout = QtWidgets.QHBoxLayout() self.csv_books_layout.setObjectName('CsvBooksLayout') self.csv_books_edit = QtWidgets.QLineEdit(self.csv_widget) + self.csv_books_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.csv_books_edit.setObjectName('CsvBooksEdit') self.csv_books_layout.addWidget(self.csv_books_edit) self.csv_books_button = QtWidgets.QToolButton(self.csv_widget) @@ -193,6 +195,7 @@ class BibleImportForm(OpenLPWizard): self.csv_verses_layout = QtWidgets.QHBoxLayout() self.csv_verses_layout.setObjectName('CsvVersesLayout') self.csv_verses_edit = QtWidgets.QLineEdit(self.csv_widget) + self.csv_verses_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.csv_verses_edit.setObjectName('CsvVersesEdit') self.csv_verses_layout.addWidget(self.csv_verses_edit) self.csv_verses_button = QtWidgets.QToolButton(self.csv_widget) @@ -212,6 +215,7 @@ class BibleImportForm(OpenLPWizard): self.open_song_file_layout = QtWidgets.QHBoxLayout() self.open_song_file_layout.setObjectName('OpenSongFileLayout') self.open_song_file_edit = QtWidgets.QLineEdit(self.open_song_widget) + self.open_song_file_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.open_song_file_edit.setObjectName('OpenSongFileEdit') self.open_song_file_layout.addWidget(self.open_song_file_edit) self.open_song_browse_button = QtWidgets.QToolButton(self.open_song_widget) @@ -301,55 +305,58 @@ class BibleImportForm(OpenLPWizard): self.sword_widget = QtWidgets.QWidget(self.select_page) self.sword_widget.setObjectName('SwordWidget') self.sword_layout = QtWidgets.QVBoxLayout(self.sword_widget) + self.sword_layout.setContentsMargins(0, 0, 0, 0) self.sword_layout.setObjectName('SwordLayout') self.sword_tab_widget = QtWidgets.QTabWidget(self.sword_widget) self.sword_tab_widget.setObjectName('SwordTabWidget') self.sword_folder_tab = QtWidgets.QWidget(self.sword_tab_widget) self.sword_folder_tab.setObjectName('SwordFolderTab') - self.sword_folder_tab_layout = QtWidgets.QGridLayout(self.sword_folder_tab) + self.sword_folder_tab_layout = QtWidgets.QFormLayout(self.sword_folder_tab) self.sword_folder_tab_layout.setObjectName('SwordTabFolderLayout') self.sword_folder_label = QtWidgets.QLabel(self.sword_folder_tab) self.sword_folder_label.setObjectName('SwordSourceLabel') - self.sword_folder_tab_layout.addWidget(self.sword_folder_label, 0, 0) self.sword_folder_label.setObjectName('SwordFolderLabel') self.sword_folder_edit = QtWidgets.QLineEdit(self.sword_folder_tab) + self.sword_folder_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.sword_folder_edit.setObjectName('SwordFolderEdit') self.sword_browse_button = QtWidgets.QToolButton(self.sword_folder_tab) self.sword_browse_button.setIcon(self.open_icon) self.sword_browse_button.setObjectName('SwordBrowseButton') - self.sword_folder_tab_layout.addWidget(self.sword_folder_edit, 0, 1) - self.sword_folder_tab_layout.addWidget(self.sword_browse_button, 0, 2) + self.sword_folder_layout = QtWidgets.QHBoxLayout() + self.sword_folder_layout.addWidget(self.sword_folder_edit) + self.sword_folder_layout.addWidget(self.sword_browse_button) + self.sword_folder_tab_layout.addRow(self.sword_folder_label, self.sword_folder_layout) self.sword_bible_label = QtWidgets.QLabel(self.sword_folder_tab) self.sword_bible_label.setObjectName('SwordBibleLabel') - self.sword_folder_tab_layout.addWidget(self.sword_bible_label, 1, 0) self.sword_bible_combo_box = QtWidgets.QComboBox(self.sword_folder_tab) self.sword_bible_combo_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) self.sword_bible_combo_box.setInsertPolicy(QtWidgets.QComboBox.InsertAlphabetically) self.sword_bible_combo_box.setObjectName('SwordBibleComboBox') - self.sword_folder_tab_layout.addWidget(self.sword_bible_combo_box, 1, 1) + self.sword_folder_tab_layout.addRow(self.sword_bible_label, self.sword_bible_combo_box) self.sword_tab_widget.addTab(self.sword_folder_tab, '') self.sword_zip_tab = QtWidgets.QWidget(self.sword_tab_widget) self.sword_zip_tab.setObjectName('SwordZipTab') - self.sword_zip_layout = QtWidgets.QGridLayout(self.sword_zip_tab) + self.sword_zip_layout = QtWidgets.QFormLayout(self.sword_zip_tab) self.sword_zip_layout.setObjectName('SwordZipLayout') self.sword_zipfile_label = QtWidgets.QLabel(self.sword_zip_tab) self.sword_zipfile_label.setObjectName('SwordZipFileLabel') self.sword_zipfile_edit = QtWidgets.QLineEdit(self.sword_zip_tab) + self.sword_zipfile_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.sword_zipfile_edit.setObjectName('SwordZipFileEdit') self.sword_zipbrowse_button = QtWidgets.QToolButton(self.sword_zip_tab) self.sword_zipbrowse_button.setIcon(self.open_icon) self.sword_zipbrowse_button.setObjectName('SwordZipBrowseButton') + self.sword_zipfile_layout = QtWidgets.QHBoxLayout() + self.sword_zipfile_layout.addWidget(self.sword_zipfile_edit) + self.sword_zipfile_layout.addWidget(self.sword_zipbrowse_button) + self.sword_zip_layout.addRow(self.sword_zipfile_label, self.sword_zipfile_layout) self.sword_zipbible_label = QtWidgets.QLabel(self.sword_folder_tab) self.sword_zipbible_label.setObjectName('SwordZipBibleLabel') self.sword_zipbible_combo_box = QtWidgets.QComboBox(self.sword_zip_tab) self.sword_zipbible_combo_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) self.sword_zipbible_combo_box.setInsertPolicy(QtWidgets.QComboBox.InsertAlphabetically) self.sword_zipbible_combo_box.setObjectName('SwordZipBibleComboBox') - self.sword_zip_layout.addWidget(self.sword_zipfile_label, 0, 0) - self.sword_zip_layout.addWidget(self.sword_zipfile_edit, 0, 1) - self.sword_zip_layout.addWidget(self.sword_zipbrowse_button, 0, 2) - self.sword_zip_layout.addWidget(self.sword_zipbible_label, 1, 0) - self.sword_zip_layout.addWidget(self.sword_zipbible_combo_box, 1, 1) + self.sword_zip_layout.addRow(self.sword_zipbible_label, self.sword_zipbible_combo_box) self.sword_tab_widget.addTab(self.sword_zip_tab, '') self.sword_layout.addWidget(self.sword_tab_widget) self.sword_disabled_label = QtWidgets.QLabel(self.sword_widget) @@ -366,6 +373,7 @@ class BibleImportForm(OpenLPWizard): self.wordproject_file_layout = QtWidgets.QHBoxLayout() self.wordproject_file_layout.setObjectName('WordProjectFileLayout') self.wordproject_file_edit = QtWidgets.QLineEdit(self.wordproject_widget) + self.wordproject_file_edit.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self.wordproject_file_edit.setObjectName('WordProjectFileEdit') self.wordproject_file_layout.addWidget(self.wordproject_file_edit) self.wordproject_browse_button = QtWidgets.QToolButton(self.wordproject_widget) @@ -490,6 +498,7 @@ class BibleImportForm(OpenLPWizard): """ Validate the current page before moving on to the next page. """ + log.debug(self.size()) if self.currentPage() == self.welcome_page: return True elif self.currentPage() == self.select_page: From d0ed37e1d592ae5a41585e028a7a8a74c662d5a4 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Fri, 25 Nov 2016 16:17:34 +0200 Subject: [PATCH 39/75] Move contents of html files into actual html files --- .../bibles/test_wordprojectimport.py | 475 +----------------- .../resources/bibles/wordproject_chapter.htm | 248 +++++++++ tests/resources/bibles/wordproject_index.htm | 222 ++++++++ 3 files changed, 472 insertions(+), 473 deletions(-) create mode 100644 tests/resources/bibles/wordproject_chapter.htm create mode 100644 tests/resources/bibles/wordproject_index.htm diff --git a/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py b/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py index 45a77c50f..622f83fa8 100644 --- a/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py +++ b/tests/functional/openlp_plugins/bibles/test_wordprojectimport.py @@ -34,479 +34,8 @@ from tests.functional import MagicMock, patch, call TEST_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'bibles')) -INDEX_PAGE = """ - - - - - The Holy Bible in the English language with audio narration - KJV - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -

    WordProject

    -
    -
    -
    - - -
    -
    - -
    - -facebook - -twitter - -google - -linkin

    -
    - -
    - - -
    -
    -
    - -
    -
    -
    -

    Top - -

    - -
    -
    -
    - - - - - - - - -""" -CHAPTER_PAGE = """ - - - - - Creation of the world, Genesis Chapter 1 - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -

    WordProject

    -
    -
    -
    - - -
    -
    - -
    - -facebook - -twitter - -google - -linkin

    -
    - -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -

    Genesis

    - -

    Chapter: - -1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 - -

    -
    - - - -
    -
    -

    Chapter 1

    - - -

    1 In the beginning God created the heaven and the earth. -
    2 And the earth was without form, and void; and darkness was upon the face of the deep. And the Spirit of God moved upon the face of the waters. -
    3 And God said, Let there be light: and there was light. -
    4 And God saw the light, that it was good: and God divided the light from the darkness. -
    5 And God called the light Day, and the darkness he called Night. And the evening and the morning were the first day. -
    6 And God said, Let there be a firmament in the midst of the waters, and let it divide the waters from the waters. -
    7 And God made the firmament, and divided the waters which were under the firmament from the waters which were above the firmament: and it was so. -
    8 And God called the firmament Heaven. And the evening and the morning were the second day. -
    9 And God said, Let the waters under the heaven be gathered together unto one place, and let the dry land appear: and it was so. -
    10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good. -
    11 And God said, Let the earth bring forth grass, the herb yielding seed, and the fruit tree yielding fruit after his kind, whose seed is in itself, upon the earth: and it was so. -
    12 And the earth brought forth grass, and herb yielding seed after his kind, and the tree yielding fruit, whose seed was in itself, after his kind: and God saw that it was good. -
    13 And the evening and the morning were the third day. -
    14 And God said, Let there be lights in the firmament of the heaven to divide the day from the night; and let them be for signs, and for seasons, and for days, and years: -
    15 And let them be for lights in the firmament of the heaven to give light upon the earth: and it was so. -
    16 And God made two great lights; the greater light to rule the day, and the lesser light to rule the night: he made the stars also. -
    17 And God set them in the firmament of the heaven to give light upon the earth, -
    18 And to rule over the day and over the night, and to divide the light from the darkness: and God saw that it was good. -
    19 And the evening and the morning were the fourth day. -
    20 And God said, Let the waters bring forth abundantly the moving creature that hath life, and fowl that may fly above the earth in the open firmament of heaven. -
    21 And God created great whales, and every living creature that moveth, which the waters brought forth abundantly, after their kind, and every winged fowl after his kind: and God saw that it was good. -
    22 And God blessed them, saying, Be fruitful, and multiply, and fill the waters in the seas, and let fowl multiply in the earth. -
    23 And the evening and the morning were the fifth day. -
    24 And God said, Let the earth bring forth the living creature after his kind, cattle, and creeping thing, and beast of the earth after his kind: and it was so. -
    25 And God made the beast of the earth after his kind, and cattle after their kind, and every thing that creepeth upon the earth after his kind: and God saw that it was good. -
    26 And God said, Let us make man in our image, after our likeness: and let them have dominion over the fish of the sea, and over the fowl of the air, and over the cattle, and over all the earth, and over every creeping thing that creepeth upon the earth. -
    27 So God created man in his own image, in the image of God created he him; male and female created he them. -
    28 And God blessed them, and God said unto them, Be fruitful, and multiply, and replenish the earth, and subdue it: and have dominion over the fish of the sea, and over the fowl of the air, and over every living thing that moveth upon the earth. -
    29 And God said, Behold, I have given you every herb bearing seed, which is upon the face of all the earth, and every tree, in the which is the fruit of a tree yielding seed; to you it shall be for meat. -
    30 And to every beast of the earth, and to every fowl of the air, and to every thing that creepeth upon the earth, wherein there is life, I have given every green herb for meat: and it was so. -
    31 And God saw every thing that he had made, and, behold, it was very good. And the evening and the morning were the sixth day. -

    - -
    -
    -
    -
    -
    - -
    -
    -
    -

     printer  -  arrowup  - -  arrowright 

    - -
    -
    -
    - - - - - - - - -""" +INDEX_PAGE = open(os.path.join(TEST_PATH, 'wordproject_index.htm')).read() +CHAPTER_PAGE = open(os.path.join(TEST_PATH, 'wordproject_chapter.htm')).read() class TestWordProjectImport(TestCase): diff --git a/tests/resources/bibles/wordproject_chapter.htm b/tests/resources/bibles/wordproject_chapter.htm new file mode 100644 index 000000000..fb9b8a272 --- /dev/null +++ b/tests/resources/bibles/wordproject_chapter.htm @@ -0,0 +1,248 @@ + + + + + Creation of the world, Genesis Chapter 1 + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +

    WordProject

    +
    +
    +
    + + +
    +
    + +
    + +facebook + +twitter + +google + +linkin

    +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +

    Genesis

    + +

    Chapter: + +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 + +

    +
    + + + +
    +
    +

    Chapter 1

    + + +

    1 In the beginning God created the heaven and the earth. +
    2 And the earth was without form, and void; and darkness was upon the face of the deep. And the Spirit of God moved upon the face of the waters. +
    3 And God said, Let there be light: and there was light. +
    4 And God saw the light, that it was good: and God divided the light from the darkness. +
    5 And God called the light Day, and the darkness he called Night. And the evening and the morning were the first day. +
    6 And God said, Let there be a firmament in the midst of the waters, and let it divide the waters from the waters. +
    7 And God made the firmament, and divided the waters which were under the firmament from the waters which were above the firmament: and it was so. +
    8 And God called the firmament Heaven. And the evening and the morning were the second day. +
    9 And God said, Let the waters under the heaven be gathered together unto one place, and let the dry land appear: and it was so. +
    10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good. +
    11 And God said, Let the earth bring forth grass, the herb yielding seed, and the fruit tree yielding fruit after his kind, whose seed is in itself, upon the earth: and it was so. +
    12 And the earth brought forth grass, and herb yielding seed after his kind, and the tree yielding fruit, whose seed was in itself, after his kind: and God saw that it was good. +
    13 And the evening and the morning were the third day. +
    14 And God said, Let there be lights in the firmament of the heaven to divide the day from the night; and let them be for signs, and for seasons, and for days, and years: +
    15 And let them be for lights in the firmament of the heaven to give light upon the earth: and it was so. +
    16 And God made two great lights; the greater light to rule the day, and the lesser light to rule the night: he made the stars also. +
    17 And God set them in the firmament of the heaven to give light upon the earth, +
    18 And to rule over the day and over the night, and to divide the light from the darkness: and God saw that it was good. +
    19 And the evening and the morning were the fourth day. +
    20 And God said, Let the waters bring forth abundantly the moving creature that hath life, and fowl that may fly above the earth in the open firmament of heaven. +
    21 And God created great whales, and every living creature that moveth, which the waters brought forth abundantly, after their kind, and every winged fowl after his kind: and God saw that it was good. +
    22 And God blessed them, saying, Be fruitful, and multiply, and fill the waters in the seas, and let fowl multiply in the earth. +
    23 And the evening and the morning were the fifth day. +
    24 And God said, Let the earth bring forth the living creature after his kind, cattle, and creeping thing, and beast of the earth after his kind: and it was so. +
    25 And God made the beast of the earth after his kind, and cattle after their kind, and every thing that creepeth upon the earth after his kind: and God saw that it was good. +
    26 And God said, Let us make man in our image, after our likeness: and let them have dominion over the fish of the sea, and over the fowl of the air, and over the cattle, and over all the earth, and over every creeping thing that creepeth upon the earth. +
    27 So God created man in his own image, in the image of God created he him; male and female created he them. +
    28 And God blessed them, and God said unto them, Be fruitful, and multiply, and replenish the earth, and subdue it: and have dominion over the fish of the sea, and over the fowl of the air, and over every living thing that moveth upon the earth. +
    29 And God said, Behold, I have given you every herb bearing seed, which is upon the face of all the earth, and every tree, in the which is the fruit of a tree yielding seed; to you it shall be for meat. +
    30 And to every beast of the earth, and to every fowl of the air, and to every thing that creepeth upon the earth, wherein there is life, I have given every green herb for meat: and it was so. +
    31 And God saw every thing that he had made, and, behold, it was very good. And the evening and the morning were the sixth day. +

    + +
    +
    +
    +
    +
    + +
    +
    +
    +

     printer  +  arrowup  + +  arrowright 

    + +
    +
    +
    + + + + + + + + + diff --git a/tests/resources/bibles/wordproject_index.htm b/tests/resources/bibles/wordproject_index.htm new file mode 100644 index 000000000..861ca2dda --- /dev/null +++ b/tests/resources/bibles/wordproject_index.htm @@ -0,0 +1,222 @@ + + + + + The Holy Bible in the English language with audio narration - KJV + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +

    WordProject

    +
    +
    +
    + + +
    +
    + +
    + +facebook + +twitter + +google + +linkin

    +
    + +
    + + +
    +
    +
    + +
    +
    +
    +

    Top + +

    + +
    +
    +
    + + + + + + + + From ef7f0b13892bcee190fd9189ad412a27726b37d8 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Fri, 25 Nov 2016 16:21:27 +0200 Subject: [PATCH 40/75] Fix pep8 --- openlp/plugins/bibles/forms/bibleimportform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/plugins/bibles/forms/bibleimportform.py b/openlp/plugins/bibles/forms/bibleimportform.py index 564474271..ae39d821b 100644 --- a/openlp/plugins/bibles/forms/bibleimportform.py +++ b/openlp/plugins/bibles/forms/bibleimportform.py @@ -538,7 +538,7 @@ class BibleImportForm(OpenLPWizard): elif self.field('source_format') == BibleFormat.WordProject: if not self.field('wordproject_file'): critical_error_message_box(UiStrings().NFSs, - WizardStrings.YouSpecifyFile % WizardStrings.WordProject) + WizardStrings.YouSpecifyFile % WizardStrings.WordProject) self.wordproject_file_edit.setFocus() return False elif self.field('source_format') == BibleFormat.WebDownload: From 595fd90d45691e508504c0286456531c5ec6dad5 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 14:06:54 +0100 Subject: [PATCH 41/75] Fix some errors on windows --- tests/functional/openlp_core_ui/test_maindisplay.py | 1 + .../openlp_plugins/presentations/test_powerpointcontroller.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/functional/openlp_core_ui/test_maindisplay.py b/tests/functional/openlp_core_ui/test_maindisplay.py index 5aa0f42a4..1ac6160a6 100644 --- a/tests/functional/openlp_core_ui/test_maindisplay.py +++ b/tests/functional/openlp_core_ui/test_maindisplay.py @@ -270,6 +270,7 @@ class TestMainDisplay(TestCase, TestMixin): service_item = MagicMock() service_item.theme_data = MagicMock() service_item.theme_data.background_type = 'video' + service_item.theme_data.theme_name = 'name' mocked_plugin = MagicMock() display.plugin_manager = PluginManager() display.plugin_manager.plugins = [mocked_plugin] diff --git a/tests/functional/openlp_plugins/presentations/test_powerpointcontroller.py b/tests/functional/openlp_plugins/presentations/test_powerpointcontroller.py index 3666eac40..824951a66 100644 --- a/tests/functional/openlp_plugins/presentations/test_powerpointcontroller.py +++ b/tests/functional/openlp_plugins/presentations/test_powerpointcontroller.py @@ -137,7 +137,7 @@ class TestPowerpointDocument(TestCase, TestMixin): instance.goto_slide(42) # THEN: mocked_critical_error_message_box should have been called - mocked_critical_error_message_box.assert_called_with('Error', 'An error occurred in the Powerpoint ' + mocked_critical_error_message_box.assert_called_with('Error', 'An error occurred in the PowerPoint ' 'integration and the presentation will be stopped.' ' Restart the presentation if you wish to ' 'present it.') From ceffeae39c6faf70b061944a86bb590c4da39b9e Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 14:07:21 +0100 Subject: [PATCH 42/75] Added appveyor conf file. --- appveyor.yml | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 appveyor.yml diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 000000000..8d3f61477 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,66 @@ +version: 2.5.0.{build} + +init: + - choco install -y --force bzr + - set PATH=C:\Program Files (x86)\Bazaar;%PATH% + - bzr --version + +clone_script: + - bzr checkout --lightweight lp:openlp openlp + +environment: + PYTHON: C:\\Python34 + +install: + # Install dependencies from pypi + - %PYTHON%\python.exe -m pip install sqlalchemy alembic chardet beautifulsoup4 Mako nose mock pyodbc==3.0.10 psycopg2 pypiwin32 pyenchant + # Install mysql dependency + - %PYTHON%\python.exe -m pip install http://cdn.mysql.com/Downloads/Connector-Python/mysql-connector-python-2.0.4.zip#md5=3df394d89300db95163f17c843ef49df + # Download and install lxml and pyicu (originally from http://www.lfd.uci.edu/~gohlke/pythonlibs/) + - curl -L "https://www.dropbox.com/s/7dwwna459j6qvbp/lxml-3.6.4-cp34-cp34m-win32.whl?dl=1" -o lxml-3.6.4-cp34-cp34m-win32.whl + - %PYTHON%\python.exe -m pip install lxml-3.6.4-cp34-cp34m-win32.whl + - curl -L "https://www.dropbox.com/s/ib1yq4xq7o1dma7/PyICU-1.9.5-cp34-cp34m-win32.whl?dl=1" -o PyICU-1.9.5-cp34-cp34m-win32.whl + - %PYTHON%\python.exe -m pip install PyICU-1.9.5-cp34-cp34m-win32.whl + # Download and install PyQt5 + - curl -L -O http://downloads.sourceforge.net/project/pyqt/PyQt5/PyQt-5.5.1/PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe + - PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe /S + # Download and install Inno Setup - used for packaging + - curl -L -O http://www.jrsoftware.org/download.php/is-unicode.exe + - is-unicode.exe /VERYSILENT /SUPPRESSMSGBOXES /SP- + # Download and unpack portable-bundle + - curl -L "https://www.dropbox.com/s/omr8mw9kamnml3l/portable-setup.7z?dl=1" -o portable-setup.7z + - 7z x portable-setup.7z + # Download and unpack mupdf + - curl -O http://mupdf.com/downloads/archive/mupdf-1.9a-windows.zip + - 7z x mupdf-1.9a-windows.zip + - cp mupdf-1.9a-windows/mupdf.exe openlp/mupdf.exe + # Download and unpack mediainfo + - curl -O https://mediaarea.net/download/binary/mediainfo/0.7.90/MediaInfo_CLI_0.7.90_Windows_i386.zip + - mkdir MediaInfo + - 7z x -o MediaInfo MediaInfo_CLI_0.7.90_Windows_i386.zip + - cp MediaInfo\\MediaInfo.exe openlp\\MediaInfo.exe + # Disabled portable installers - can't figure out how to make them silent + # - curl -L -O http://downloads.sourceforge.net/project/portableapps/PortableApps.com%20Installer/PortableApps.comInstaller_3.4.4.paf.exe + # - PortableApps.comInstaller_3.4.4.paf.exe /S + # - curl -L -O http://downloads.sourceforge.net/project/portableapps/PortableApps.com%20Launcher/PortableApps.comLauncher_2.2.1.paf.exe + # - PortableApps.comLauncher_2.2.1.paf.exe /S + # - curl -L -O http://downloads.sourceforge.net/project/portableapps/NSIS%20Portable/NSISPortable_3.0_English.paf.exe + # - NSISPortable_3.0_English.paf.exe /S + + +build: off + +test_script: + - cd openlp + - %PYTHON%\\python.exe -m nose -v tests + +after_test: + # This is where we create a package using PyInstaller + # First download and unpack PyInstaller + - curl -L -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip + - 7z x develop.zip + # Then get the packaging repo + - bzr checkout --lightweight lp:~tomasgroth/openlp/packaging-appveyor packaging + - cd packaging + - %PYTHON%\python.exe windows/windows-builder.py -v -u -t -c windows\\config.ini -b ..\\openlp + From 7017f82b3617890cd632d16b9c6b37f2a87113f8 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 14:17:32 +0100 Subject: [PATCH 43/75] Workaround for 7z extraction --- appveyor.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 8d3f61477..189d94151 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -37,7 +37,9 @@ install: # Download and unpack mediainfo - curl -O https://mediaarea.net/download/binary/mediainfo/0.7.90/MediaInfo_CLI_0.7.90_Windows_i386.zip - mkdir MediaInfo - - 7z x -o MediaInfo MediaInfo_CLI_0.7.90_Windows_i386.zip + - cd MediaInfo + - 7z x ../MediaInfo_CLI_0.7.90_Windows_i386.zip + - cd.. - cp MediaInfo\\MediaInfo.exe openlp\\MediaInfo.exe # Disabled portable installers - can't figure out how to make them silent # - curl -L -O http://downloads.sourceforge.net/project/portableapps/PortableApps.com%20Installer/PortableApps.comInstaller_3.4.4.paf.exe From 8595a2171f6feb4751b81577f922b47c6a588f60 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 14:54:29 +0100 Subject: [PATCH 44/75] Another test fix for windows. --- tests/functional/openlp_core_ui/test_maindisplay.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/functional/openlp_core_ui/test_maindisplay.py b/tests/functional/openlp_core_ui/test_maindisplay.py index 1ac6160a6..751263e06 100644 --- a/tests/functional/openlp_core_ui/test_maindisplay.py +++ b/tests/functional/openlp_core_ui/test_maindisplay.py @@ -271,6 +271,7 @@ class TestMainDisplay(TestCase, TestMixin): service_item.theme_data = MagicMock() service_item.theme_data.background_type = 'video' service_item.theme_data.theme_name = 'name' + service_item._raw_frames = [] mocked_plugin = MagicMock() display.plugin_manager = PluginManager() display.plugin_manager.plugins = [mocked_plugin] From ccdc88c4cbcb60a567c7eb913d56c4f3d9423ed5 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 29 Nov 2016 17:44:32 +0200 Subject: [PATCH 45/75] Change the name of the help menu to try to fix the double-help-menu problem --- openlp/core/common/settings.py | 6 +++--- openlp/core/ui/mainwindow.py | 11 +++++------ openlp/plugins/media/lib/mediaitem.py | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/openlp/core/common/settings.py b/openlp/core/common/settings.py index 8f70fafff..132e9652a 100644 --- a/openlp/core/common/settings.py +++ b/openlp/core/common/settings.py @@ -216,8 +216,8 @@ class Settings(QtCore.QSettings): ('advanced/default color', 'core/logo background color', []), # Default image renamed + moved to general > 2.4. ('advanced/default image', 'core/logo file', []), # Default image renamed + moved to general after 2.4. ('shortcuts/escapeItem', 'shortcuts/desktopScreenEnable', []), # Escape item was removed in 2.6. - ('shortcuts/offlineHelpItem', 'shortcuts/HelpItem', []), # Online and Offline help were combined in 2.6. - ('shortcuts/onlineHelpItem', 'shortcuts/HelpItem', []) # Online and Offline help were combined in 2.6. + ('shortcuts/offlineHelpItem', 'shortcuts/userManualItem', []), # Online and Offline help were combined in 2.6. + ('shortcuts/onlineHelpItem', 'shortcuts/userManualItem', []) # Online and Offline help were combined in 2.6. ] @staticmethod @@ -276,7 +276,7 @@ class Settings(QtCore.QSettings): 'shortcuts/fileSaveItem': [QtGui.QKeySequence(QtGui.QKeySequence.Save)], 'shortcuts/fileOpenItem': [QtGui.QKeySequence(QtGui.QKeySequence.Open)], 'shortcuts/goLive': [], - 'shortcuts/HelpItem': [QtGui.QKeySequence(QtGui.QKeySequence.HelpContents)], + 'shortcuts/userManualItem': [QtGui.QKeySequence(QtGui.QKeySequence.HelpContents)], 'shortcuts/importThemeItem': [], 'shortcuts/importBibleItem': [], 'shortcuts/listViewBiblesDeleteItem': [QtGui.QKeySequence(QtGui.QKeySequence.Delete)], diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index b8bd126dd..e6d0634b4 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -312,10 +312,9 @@ class Ui_MainWindow(object): elif is_macosx(): self.local_help_file = os.path.join(AppLocation.get_directory(AppLocation.AppDir), '..', 'Resources', 'OpenLP.help') - self.on_help_item = create_action(main_window, 'HelpItem', - icon=':/system/system_help_contents.png', - can_shortcuts=True, - category=UiStrings().Help, triggers=self.on_help_clicked) + self.user_manual_item = create_action(main_window, 'userManualItem', icon=':/system/system_help_contents.png', + can_shortcuts=True, category=UiStrings().Help, + triggers=self.on_help_clicked) self.web_site_item = create_action(main_window, 'webSiteItem', can_shortcuts=True, category=UiStrings().Help) # Shortcuts not connected to buttons or menu entries. self.search_shortcut_action = create_action(main_window, @@ -354,7 +353,7 @@ class Ui_MainWindow(object): add_actions(self.tools_menu, (self.tools_open_data_folder, None)) add_actions(self.tools_menu, (self.tools_first_time_wizard, None)) add_actions(self.tools_menu, [self.update_theme_images]) - add_actions(self.help_menu, (self.on_help_item, None, self.web_site_item, self.about_item)) + add_actions(self.help_menu, (self.user_manual_item, None, self.web_site_item, self.about_item)) add_actions(self.menu_bar, (self.file_menu.menuAction(), self.view_menu.menuAction(), self.tools_menu.menuAction(), self.settings_menu.menuAction(), self.help_menu.menuAction())) add_actions(self, [self.search_shortcut_action]) @@ -450,7 +449,7 @@ class Ui_MainWindow(object): 'from here.')) self.about_item.setText(translate('OpenLP.MainWindow', '&About')) self.about_item.setStatusTip(translate('OpenLP.MainWindow', 'More information about OpenLP.')) - self.on_help_item.setText(translate('OpenLP.MainWindow', '&User Manual')) + self.user_manual_item.setText(translate('OpenLP.MainWindow', '&User Manual')) self.search_shortcut_action.setText(UiStrings().Search) self.search_shortcut_action.setToolTip( translate('OpenLP.MainWindow', 'Jump to the search box of the current active plugin.')) diff --git a/openlp/plugins/media/lib/mediaitem.py b/openlp/plugins/media/lib/mediaitem.py index dc196fb59..2344bc6d6 100644 --- a/openlp/plugins/media/lib/mediaitem.py +++ b/openlp/plugins/media/lib/mediaitem.py @@ -150,7 +150,7 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): triggers=self.on_replace_click) if 'webkit' not in get_media_players()[0]: self.replace_action.setDisabled(True) - self.replace_action_context.setDisabled(True) + # self.replace_action_context.setDisabled(True) self.reset_action = self.toolbar.add_toolbar_action('reset_action', icon=':/system/system_close.png', visible=False, triggers=self.on_reset_click) self.media_widget = QtWidgets.QWidget(self) From 1f4c8e742ee88087cea3aceadabf771514239a98 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 21:37:39 +0100 Subject: [PATCH 46/75] More work on appveyor --- scripts/appveyor-webhook.py | 102 +++++++++++++++++++++++++++ appveyor.yml => scripts/appveyor.yml | 27 +++---- 2 files changed, 113 insertions(+), 16 deletions(-) create mode 100755 scripts/appveyor-webhook.py rename appveyor.yml => scripts/appveyor.yml (70%) diff --git a/scripts/appveyor-webhook.py b/scripts/appveyor-webhook.py new file mode 100755 index 000000000..841c5b532 --- /dev/null +++ b/scripts/appveyor-webhook.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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 # +############################################################################### + +import json +import urllib +import urllib.request +import datetime +import sys +from subprocess import Popen, PIPE + +token = 'xx' +webhook_url = 'https://ci.appveyor.com/api/subversion/webhook?id=x' +branch = 'lp:openlp' + +webhook_element = \ +{ + "commit": { + "author": { + "email": "open@contributer", + "name": "OpenLP Contributor" + }, + "id": None, + "message": "Building " + branch, + "timestamp": datetime.datetime.now().isoformat() + }, + "config": None, + "repository": { + "name": "repo_name", + "url": "repo_url" + } +} + + +def get_version(): + """ + Get the version of the branch. + """ + bzr = Popen(('bzr', 'tags'), stdout=PIPE) + output = bzr.communicate()[0] + code = bzr.wait() + if code != 0: + raise Exception('Error running bzr tags') + lines = output.splitlines() + if len(lines) == 0: + tag = '0.0.0' + revision = '0' + else: + tag, revision = lines[-1].decode('utf-8').split() + bzr = Popen(('bzr', 'log', '--line', '-r', '-1'), stdout=PIPE) + output, error = bzr.communicate() + code = bzr.wait() + if code != 0: + raise Exception('Error running bzr log') + latest = output.decode('utf-8').split(':')[0] + version_string = latest == revision and tag or '%s-bzr%s' % (tag, latest) + # Save decimal version in case we need to do a portable build. + version = latest == revision and tag or '%s.%s' % (tag, latest) + return version_string, version + + +def get_yml(): + f = open('appveyor.yml') + yml_text = f.read() + f.close() + yml_text = yml_text.replace('BRANCHNAME', branch) + return yml_text + + +def hook(token, webhook_url): + webhook_element['config'] = get_yml() + webhook_element['commit']['message'] = 'Building ' + branch + version_string, version = get_version() + webhook_element['commit']['id'] = version_string + request = urllib.request.Request(webhook_url) + print(json.dumps(webhook_element)) + request.add_header('Content-Type','application/json;charset=utf-8') + request.add_header('Authorization', 'Bearer ' + token) + responce = urllib.request.urlopen(request, json.dumps(webhook_element).encode('utf-8')) + print(responce.read().decode('utf-8')) + + +hook(token, webhook_url) diff --git a/appveyor.yml b/scripts/appveyor.yml similarity index 70% rename from appveyor.yml rename to scripts/appveyor.yml index 189d94151..490464044 100644 --- a/appveyor.yml +++ b/scripts/appveyor.yml @@ -6,21 +6,21 @@ init: - bzr --version clone_script: - - bzr checkout --lightweight lp:openlp openlp + - bzr checkout --lightweight BRANCHNAME openlp environment: PYTHON: C:\\Python34 install: # Install dependencies from pypi - - %PYTHON%\python.exe -m pip install sqlalchemy alembic chardet beautifulsoup4 Mako nose mock pyodbc==3.0.10 psycopg2 pypiwin32 pyenchant + - "%PYTHON%\\python.exe -m pip install sqlalchemy alembic chardet beautifulsoup4 Mako nose mock pyodbc==3.0.10 psycopg2 pypiwin32 pyenchant" # Install mysql dependency - - %PYTHON%\python.exe -m pip install http://cdn.mysql.com/Downloads/Connector-Python/mysql-connector-python-2.0.4.zip#md5=3df394d89300db95163f17c843ef49df + - "%PYTHON%\\python.exe -m pip install http://cdn.mysql.com/Downloads/Connector-Python/mysql-connector-python-2.0.4.zip#md5=3df394d89300db95163f17c843ef49df" # Download and install lxml and pyicu (originally from http://www.lfd.uci.edu/~gohlke/pythonlibs/) - curl -L "https://www.dropbox.com/s/7dwwna459j6qvbp/lxml-3.6.4-cp34-cp34m-win32.whl?dl=1" -o lxml-3.6.4-cp34-cp34m-win32.whl - - %PYTHON%\python.exe -m pip install lxml-3.6.4-cp34-cp34m-win32.whl + - "%PYTHON%\\python.exe -m pip install lxml-3.6.4-cp34-cp34m-win32.whl" - curl -L "https://www.dropbox.com/s/ib1yq4xq7o1dma7/PyICU-1.9.5-cp34-cp34m-win32.whl?dl=1" -o PyICU-1.9.5-cp34-cp34m-win32.whl - - %PYTHON%\python.exe -m pip install PyICU-1.9.5-cp34-cp34m-win32.whl + - "%PYTHON%\\python.exe -m pip install PyICU-1.9.5-cp34-cp34m-win32.whl" # Download and install PyQt5 - curl -L -O http://downloads.sourceforge.net/project/pyqt/PyQt5/PyQt-5.5.1/PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe - PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe /S @@ -37,9 +37,7 @@ install: # Download and unpack mediainfo - curl -O https://mediaarea.net/download/binary/mediainfo/0.7.90/MediaInfo_CLI_0.7.90_Windows_i386.zip - mkdir MediaInfo - - cd MediaInfo - - 7z x ../MediaInfo_CLI_0.7.90_Windows_i386.zip - - cd.. + - 7z x -oMediaInfo MediaInfo_CLI_0.7.90_Windows_i386.zip - cp MediaInfo\\MediaInfo.exe openlp\\MediaInfo.exe # Disabled portable installers - can't figure out how to make them silent # - curl -L -O http://downloads.sourceforge.net/project/portableapps/PortableApps.com%20Installer/PortableApps.comInstaller_3.4.4.paf.exe @@ -54,15 +52,12 @@ build: off test_script: - cd openlp - - %PYTHON%\\python.exe -m nose -v tests + - "%PYTHON%\\python.exe -m nose -v tests" after_test: # This is where we create a package using PyInstaller - # First download and unpack PyInstaller - - curl -L -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip + # First get PyInstaller + - curl -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip - 7z x develop.zip - # Then get the packaging repo - - bzr checkout --lightweight lp:~tomasgroth/openlp/packaging-appveyor packaging - - cd packaging - - %PYTHON%\python.exe windows/windows-builder.py -v -u -t -c windows\\config.ini -b ..\\openlp - + # Build the bundle + - "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp" From 1bb2e9e2781b2060f8e3ef2c137add272e01fee8 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 21:57:33 +0100 Subject: [PATCH 47/75] Another fix for tests on windows. --- tests/functional/openlp_core_ui/test_maindisplay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/openlp_core_ui/test_maindisplay.py b/tests/functional/openlp_core_ui/test_maindisplay.py index 751263e06..8b10f6e0f 100644 --- a/tests/functional/openlp_core_ui/test_maindisplay.py +++ b/tests/functional/openlp_core_ui/test_maindisplay.py @@ -271,7 +271,7 @@ class TestMainDisplay(TestCase, TestMixin): service_item.theme_data = MagicMock() service_item.theme_data.background_type = 'video' service_item.theme_data.theme_name = 'name' - service_item._raw_frames = [] + service_item.theme_data.background_filename = 'background_filename' mocked_plugin = MagicMock() display.plugin_manager = PluginManager() display.plugin_manager.plugins = [mocked_plugin] From bbda32b949e88848bb9772f678504423e337fceb Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Tue, 29 Nov 2016 22:08:32 +0100 Subject: [PATCH 48/75] use revision number for appveyor id --- scripts/appveyor-webhook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/appveyor-webhook.py b/scripts/appveyor-webhook.py index 841c5b532..e28fac6b3 100755 --- a/scripts/appveyor-webhook.py +++ b/scripts/appveyor-webhook.py @@ -72,7 +72,7 @@ def get_version(): if code != 0: raise Exception('Error running bzr log') latest = output.decode('utf-8').split(':')[0] - version_string = latest == revision and tag or '%s-bzr%s' % (tag, latest) + version_string = latest == revision and tag or 'r%s' % latest # Save decimal version in case we need to do a portable build. version = latest == revision and tag or '%s.%s' % (tag, latest) return version_string, version @@ -93,7 +93,7 @@ def hook(token, webhook_url): webhook_element['commit']['id'] = version_string request = urllib.request.Request(webhook_url) print(json.dumps(webhook_element)) - request.add_header('Content-Type','application/json;charset=utf-8') + request.add_header('Content-Type', 'application/json;charset=utf-8') request.add_header('Authorization', 'Bearer ' + token) responce = urllib.request.urlopen(request, json.dumps(webhook_element).encode('utf-8')) print(responce.read().decode('utf-8')) From 955fdc50ac486e4bc0d2ee4b5a054e6c80822545 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 29 Nov 2016 23:57:27 +0200 Subject: [PATCH 49/75] Fix bug #1645867 by setting an application attribute related to OpenGL Fixes: https://launchpad.net/bugs/1645867, https://launchpad.net/bugs/1591749 --- openlp/core/__init__.py | 1 + openlp/core/ui/themewizard.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index 5de5e69de..cb298e11b 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -375,6 +375,7 @@ def main(args=None): application.setOrganizationName('OpenLP') application.setOrganizationDomain('openlp.org') application.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) + application.setAttribute(QtCore.Qt.AA_DontCreateNativeWidgetSiblings, True) if args and args.portable: application.setApplicationName('OpenLPPortable') Settings.setDefaultFormat(Settings.IniFormat) diff --git a/openlp/core/ui/themewizard.py b/openlp/core/ui/themewizard.py index 95262cf8f..7eac787d9 100644 --- a/openlp/core/ui/themewizard.py +++ b/openlp/core/ui/themewizard.py @@ -44,9 +44,9 @@ class Ui_ThemeWizard(object): theme_wizard.setModal(True) theme_wizard.setOptions(QtWidgets.QWizard.IndependentPages | QtWidgets.QWizard.NoBackButtonOnStartPage | QtWidgets.QWizard.HaveCustomButton1) + theme_wizard.setFixedWidth(640) if is_macosx(): theme_wizard.setPixmap(QtWidgets.QWizard.BackgroundPixmap, QtGui.QPixmap(':/wizards/openlp-osx-wizard.png')) - theme_wizard.resize(646, 400) else: theme_wizard.setWizardStyle(QtWidgets.QWizard.ModernStyle) self.spacer = QtWidgets.QSpacerItem(10, 0, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) From 105edf36c36092c98a3e5e602b43ffe8c2cce148 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Wed, 30 Nov 2016 21:54:06 +0100 Subject: [PATCH 50/75] More appveyor adjustments --- scripts/appveyor-webhook.py | 79 ++++++++++++++++++++++++++----------- scripts/appveyor.yml | 66 +++++++++++++++++-------------- 2 files changed, 92 insertions(+), 53 deletions(-) diff --git a/scripts/appveyor-webhook.py b/scripts/appveyor-webhook.py index e28fac6b3..573aa1045 100755 --- a/scripts/appveyor-webhook.py +++ b/scripts/appveyor-webhook.py @@ -21,34 +21,40 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### +""" +This script is used to trigger a build at appveyor. Since the code is not hosted +on github the normal triggering mechanisms can't be use. The project is +registered as subversion repository. A webhook is used to trigger new builds. +The appveyor.yml used for the build is send to appveyor when calling the hook. +""" import json import urllib import urllib.request import datetime import sys +import time from subprocess import Popen, PIPE -token = 'xx' -webhook_url = 'https://ci.appveyor.com/api/subversion/webhook?id=x' -branch = 'lp:openlp' +appveyor_build_url = 'https://ci.appveyor.com/project/TomasGroth/openlp/build' +appveyor_api_url = 'https://ci.appveyor.com/api/projects/TomasGroth/openlp' webhook_element = \ -{ - "commit": { - "author": { - "email": "open@contributer", - "name": "OpenLP Contributor" + { + 'commit': { + 'author': { + 'email': 'open@contributer', + 'name': 'OpenLP Contributor' + }, + 'id': None, + 'message': None, + 'timestamp': datetime.datetime.now().isoformat() }, - "id": None, - "message": "Building " + branch, - "timestamp": datetime.datetime.now().isoformat() - }, - "config": None, - "repository": { - "name": "repo_name", - "url": "repo_url" + 'config': None, + 'repository': { + 'name': 'repo_name', + 'url': 'repo_url' + } } -} def get_version(): @@ -78,7 +84,10 @@ def get_version(): return version_string, version -def get_yml(): +def get_yml(branch): + """ + Returns the content of appveyor.yml and inserts the branch to be build + """ f = open('appveyor.yml') yml_text = f.read() f.close() @@ -86,17 +95,39 @@ def get_yml(): return yml_text -def hook(token, webhook_url): - webhook_element['config'] = get_yml() +def hook(webhook_url, yml): + """ + Activate the webhook to start the build + """ + webhook_element['config'] = yml webhook_element['commit']['message'] = 'Building ' + branch version_string, version = get_version() webhook_element['commit']['id'] = version_string request = urllib.request.Request(webhook_url) - print(json.dumps(webhook_element)) request.add_header('Content-Type', 'application/json;charset=utf-8') - request.add_header('Authorization', 'Bearer ' + token) responce = urllib.request.urlopen(request, json.dumps(webhook_element).encode('utf-8')) - print(responce.read().decode('utf-8')) + if responce.getcode() != 204: + print('An error happened when calling the webhook! Return code: %d' % responce.getcode()) + print(responce.read().decode('utf-8')) -hook(token, webhook_url) +def get_appveyor_build_url(branch): + """ + Get the url of the build. + """ + # Wait 10 seconds to make sure the hook has been triggered + time.sleep(10) + responce = urllib.request.urlopen(appveyor_api_url) + json_str = responce.read().decode('utf-8') + build_json = json.loads(json_str) + build_url = '%s/%s' % (appveyor_build_url, build_json['build']['version']) + print('Check this URL for build status: %s' % build_url) + + +if len(sys.argv) != 3: + print('Usage: %s ' % sys.argv[0]) +else: + webhook_url = sys.argv[1] + branch = sys.argv[2] + hook(webhook_url, get_yml(branch)) + get_appveyor_build_url(branch) diff --git a/scripts/appveyor.yml b/scripts/appveyor.yml index 490464044..4784ed50d 100644 --- a/scripts/appveyor.yml +++ b/scripts/appveyor.yml @@ -1,4 +1,4 @@ -version: 2.5.0.{build} +version: OpenLP-win-ci-b{build} init: - choco install -y --force bzr @@ -6,39 +6,54 @@ init: - bzr --version clone_script: - - bzr checkout --lightweight BRANCHNAME openlp + - bzr checkout --lightweight BRANCHNAME openlp-branch environment: PYTHON: C:\\Python34 install: # Install dependencies from pypi - - "%PYTHON%\\python.exe -m pip install sqlalchemy alembic chardet beautifulsoup4 Mako nose mock pyodbc==3.0.10 psycopg2 pypiwin32 pyenchant" + - "%PYTHON%\\python.exe -m pip install sqlalchemy alembic chardet beautifulsoup4 Mako nose mock pyodbc psycopg2 pypiwin32 pyenchant" # Install mysql dependency - "%PYTHON%\\python.exe -m pip install http://cdn.mysql.com/Downloads/Connector-Python/mysql-connector-python-2.0.4.zip#md5=3df394d89300db95163f17c843ef49df" # Download and install lxml and pyicu (originally from http://www.lfd.uci.edu/~gohlke/pythonlibs/) - - curl -L "https://www.dropbox.com/s/7dwwna459j6qvbp/lxml-3.6.4-cp34-cp34m-win32.whl?dl=1" -o lxml-3.6.4-cp34-cp34m-win32.whl - - "%PYTHON%\\python.exe -m pip install lxml-3.6.4-cp34-cp34m-win32.whl" - - curl -L "https://www.dropbox.com/s/ib1yq4xq7o1dma7/PyICU-1.9.5-cp34-cp34m-win32.whl?dl=1" -o PyICU-1.9.5-cp34-cp34m-win32.whl - - "%PYTHON%\\python.exe -m pip install PyICU-1.9.5-cp34-cp34m-win32.whl" + - "%PYTHON%\\python.exe -m pip install https://get.openlp.org/win-sdk/lxml-3.6.4-cp34-cp34m-win32.whl" + - "%PYTHON%\\python.exe -m pip install https://get.openlp.org/win-sdk/PyICU-1.9.5-cp34-cp34m-win32.whl" # Download and install PyQt5 - curl -L -O http://downloads.sourceforge.net/project/pyqt/PyQt5/PyQt-5.5.1/PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe - PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe /S - # Download and install Inno Setup - used for packaging - - curl -L -O http://www.jrsoftware.org/download.php/is-unicode.exe - - is-unicode.exe /VERYSILENT /SUPPRESSMSGBOXES /SP- - # Download and unpack portable-bundle - - curl -L "https://www.dropbox.com/s/omr8mw9kamnml3l/portable-setup.7z?dl=1" -o portable-setup.7z - - 7z x portable-setup.7z # Download and unpack mupdf - curl -O http://mupdf.com/downloads/archive/mupdf-1.9a-windows.zip - 7z x mupdf-1.9a-windows.zip - - cp mupdf-1.9a-windows/mupdf.exe openlp/mupdf.exe + - cp mupdf-1.9a-windows/mupdf.exe openlp-branch/mupdf.exe # Download and unpack mediainfo - curl -O https://mediaarea.net/download/binary/mediainfo/0.7.90/MediaInfo_CLI_0.7.90_Windows_i386.zip - mkdir MediaInfo - 7z x -oMediaInfo MediaInfo_CLI_0.7.90_Windows_i386.zip - - cp MediaInfo\\MediaInfo.exe openlp\\MediaInfo.exe + - cp MediaInfo\\MediaInfo.exe openlp-branch\\MediaInfo.exe + + +build: off + +test_script: + - cd openlp-branch + - "%PYTHON%\\python.exe -m nose -v tests" + # Go back to the user root folder + - cd.. + +after_test: + # This is where we create a package using PyInstaller + # First get PyInstaller + - curl -L -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip + - 7z x develop.zip + # Install PyInstaller dependencies + - "%PYTHON%\\python.exe -m pip install future" + # Download and install Inno Setup - used for packaging + - curl -L -O http://www.jrsoftware.org/download.php/is-unicode.exe + - is-unicode.exe /VERYSILENT /SUPPRESSMSGBOXES /SP- + # Download and unpack portable-bundle + - curl -O https://get.openlp.org/win-sdk/portable-setup.7z + - 7z x portable-setup.7z # Disabled portable installers - can't figure out how to make them silent # - curl -L -O http://downloads.sourceforge.net/project/portableapps/PortableApps.com%20Installer/PortableApps.comInstaller_3.4.4.paf.exe # - PortableApps.comInstaller_3.4.4.paf.exe /S @@ -46,18 +61,11 @@ install: # - PortableApps.comLauncher_2.2.1.paf.exe /S # - curl -L -O http://downloads.sourceforge.net/project/portableapps/NSIS%20Portable/NSISPortable_3.0_English.paf.exe # - NSISPortable_3.0_English.paf.exe /S - - -build: off - -test_script: - - cd openlp - - "%PYTHON%\\python.exe -m nose -v tests" - -after_test: - # This is where we create a package using PyInstaller - # First get PyInstaller - - curl -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip - - 7z x develop.zip + # Get the packaging code + - bzr checkout --lightweight lp:~tomasgroth/openlp/packaging-appveyor packaging # Build the bundle - - "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp" + - cd packaging + - "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp-branch" + +artifacts: + - path: openlp-branch\dist\*.exe From 19a450fab5e7556c609d7909d4d01c9b5dd8bbe1 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Thu, 1 Dec 2016 09:10:51 +0100 Subject: [PATCH 51/75] Use the standard packaging repo. --- scripts/appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/appveyor.yml b/scripts/appveyor.yml index 4784ed50d..43647bf71 100644 --- a/scripts/appveyor.yml +++ b/scripts/appveyor.yml @@ -62,7 +62,7 @@ after_test: # - curl -L -O http://downloads.sourceforge.net/project/portableapps/NSIS%20Portable/NSISPortable_3.0_English.paf.exe # - NSISPortable_3.0_English.paf.exe /S # Get the packaging code - - bzr checkout --lightweight lp:~tomasgroth/openlp/packaging-appveyor packaging + - bzr checkout --lightweight lp:openlp/packaging packaging # Build the bundle - cd packaging - "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp-branch" From 23a4fcb665f7a559dad46b377bd8f687d896d573 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Fri, 9 Dec 2016 22:22:47 +0100 Subject: [PATCH 52/75] rewrite the appveyor.yml --- scripts/appveyor.yml | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/appveyor.yml b/scripts/appveyor.yml index 43647bf71..e60473cc5 100644 --- a/scripts/appveyor.yml +++ b/scripts/appveyor.yml @@ -37,17 +37,19 @@ build: off test_script: - cd openlp-branch - - "%PYTHON%\\python.exe -m nose -v tests" + #- "%PYTHON%\\python.exe -m nose -v tests" # Go back to the user root folder - cd.. after_test: # This is where we create a package using PyInstaller # First get PyInstaller - - curl -L -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip - - 7z x develop.zip + #- curl -L -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip + - curl -L -O https://github.com/pyinstaller/pyinstaller/releases/download/v3.2/PyInstaller-3.2.zip + - 7z x PyInstaller-3.2.zip + #- mv pyinstaller-develop PyInstaller-3.2 # Install PyInstaller dependencies - - "%PYTHON%\\python.exe -m pip install future" + - "%PYTHON%\\python.exe -m pip install future pefile" # Download and install Inno Setup - used for packaging - curl -L -O http://www.jrsoftware.org/download.php/is-unicode.exe - is-unicode.exe /VERYSILENT /SUPPRESSMSGBOXES /SP- @@ -62,10 +64,28 @@ after_test: # - curl -L -O http://downloads.sourceforge.net/project/portableapps/NSIS%20Portable/NSISPortable_3.0_English.paf.exe # - NSISPortable_3.0_English.paf.exe /S # Get the packaging code - - bzr checkout --lightweight lp:openlp/packaging packaging + #- bzr checkout --lightweight lp:openlp/packaging packaging + - bzr checkout --lightweight lp:~raoul-snyman/openlp/pyinstaller-change packaging + #- curl -L http://bazaar.launchpad.net/~openlp-core/openlp/packaging/tarball -o packaging.tar.gz + #- 7z e packaging.tar.gz + #- 7z x packaging.tar + #- mv ~openlp-core/openlp/packaging packaging + # If this is trunk we should also build the manual + # Download and install HTML Help Workshop + #- curl -L "http://go.microsoft.com/fwlink/p/?linkid=14188" -o htmlhelp.exe + #- htmlhelp.exe /Q + # Install sphinx + - "%PYTHON%\\python.exe -m pip install sphinx" + # Get the documentation code + #- bzr checkout --lightweight lp:openlp/documentation documentation + - curl -L http://bazaar.launchpad.net/~openlp-core/openlp/documentation/tarball -o documentation.tar.gz + - 7z e documentation.tar.gz + - 7z x documentation.tar + - mv ~openlp-core/openlp/documentation documentation # Build the bundle - cd packaging - - "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp-branch" + #- "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation" + - "%PYTHON%\\python.exe builders/windows-builder.py -v --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation --portable" artifacts: - path: openlp-branch\dist\*.exe From 79934596ac47c5e90a89b9eb7f963db1e2d3f351 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 13 Dec 2016 00:14:45 +0200 Subject: [PATCH 53/75] Fix the problem with the Library/Media Manager icons not showing up. --- openlp/core/ui/lib/mediadockmanager.py | 2 +- openlp/core/ui/mainwindow.py | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openlp/core/ui/lib/mediadockmanager.py b/openlp/core/ui/lib/mediadockmanager.py index ad786b3a0..1a7676465 100644 --- a/openlp/core/ui/lib/mediadockmanager.py +++ b/openlp/core/ui/lib/mediadockmanager.py @@ -54,7 +54,7 @@ class MediaDockManager(object): match = True break if not match: - self.media_dock.addItem(media_item, visible_title['title']) + self.media_dock.addItem(media_item, media_item.plugin.icon, visible_title['title']) def remove_dock(self, media_item): """ diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 92b29d16f..55153e29d 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -52,19 +52,18 @@ from openlp.core.ui.lib.mediadockmanager import MediaDockManager log = logging.getLogger(__name__) MEDIA_MANAGER_STYLE = """ -QToolBox { - padding-bottom: 2px; -} -QToolBox::tab { +::tab { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 palette(button), stop: 1.0 palette(mid)); border: 1px solid palette(mid); - border-radius: 3px; + margin-top: 0; + margin-bottom: 0; + text-align: left; } -QToolBox::tab:selected { +::tab:selected { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 palette(light), stop: 1.0 palette(button)); - border: 1px solid palette(mid); + border: 1px solid palette(highlight); font-weight: bold; } """ From 19a00b44e06f4f771910f3bfc58dabb92aabfed4 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 13 Dec 2016 00:16:23 +0200 Subject: [PATCH 54/75] Update the version number to 2.5.0 just for any dev builds that might happen --- openlp/.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlp/.version b/openlp/.version index 6b4950e3d..437459cd9 100644 --- a/openlp/.version +++ b/openlp/.version @@ -1 +1 @@ -2.4 +2.5.0 From 97dfafba4e23747e64b76f3c2bf2c134b8e0c860 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 13 Dec 2016 08:04:27 +0200 Subject: [PATCH 55/75] Make the tabs in the media library prettier --- openlp/core/ui/mainwindow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index 55153e29d..fb53bf126 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -55,14 +55,13 @@ MEDIA_MANAGER_STYLE = """ ::tab { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 palette(button), stop: 1.0 palette(mid)); - border: 1px solid palette(mid); + border: 0; + border-radius: 2px; margin-top: 0; margin-bottom: 0; text-align: left; } ::tab:selected { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 palette(light), stop: 1.0 palette(button)); border: 1px solid palette(highlight); font-weight: bold; } From c818c3ce8c5fb7aa4f4fbbb6b6aea1cf406aa1e4 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 13 Dec 2016 16:55:11 +0200 Subject: [PATCH 56/75] Added a test --- .../openlp_core_ui/test_aboutform.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/functional/openlp_core_ui/test_aboutform.py b/tests/functional/openlp_core_ui/test_aboutform.py index 612c6b887..381ed83c8 100644 --- a/tests/functional/openlp_core_ui/test_aboutform.py +++ b/tests/functional/openlp_core_ui/test_aboutform.py @@ -32,16 +32,30 @@ from tests.helpers.testmixin import TestMixin class TestFirstTimeForm(TestCase, TestMixin): - def test_on_volunteer_button_clicked(self): + @patch('openlp.core.ui.aboutform.webbrowser') + def test_on_volunteer_button_clicked(self, mocked_webbrowser): """ Test that clicking on the "Volunteer" button opens a web page. """ # GIVEN: A new About dialog and a mocked out webbrowser module - with patch('openlp.core.ui.aboutform.webbrowser') as mocked_webbrowser: - about_form = AboutForm(None) + about_form = AboutForm(None) - # WHEN: The "Volunteer" button is "clicked" - about_form.on_volunteer_button_clicked() + # WHEN: The "Volunteer" button is "clicked" + about_form.on_volunteer_button_clicked() - # THEN: A web browser is opened - mocked_webbrowser.open_new.assert_called_with('http://openlp.org/en/contribute') + # THEN: A web browser is opened + mocked_webbrowser.open_new.assert_called_with('http://openlp.org/en/contribute') + + @patch('openlp.core.ui.aboutform.get_application_version') + def test_about_form_build_number(self, mocked_get_application_version): + """ + Test that the build number is added to the about form + """ + # GIVEN: A mocked out get_application_version function + mocked_get_application_version.return_value = {'version': '3.1.5', 'build': '3000'} + + # WHEN: The about form is created + about_form = AboutForm(None) + + # THEN: The build number should be in the text + assert about_form.about_text_edit.plainText().split('\n')[0] == '' From f17204aa19d3937929c52632529cc2c1689e2bf3 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 13 Dec 2016 21:43:27 +0200 Subject: [PATCH 57/75] Fix a problem with the test --- tests/functional/openlp_core_ui/test_aboutform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/openlp_core_ui/test_aboutform.py b/tests/functional/openlp_core_ui/test_aboutform.py index 381ed83c8..60c4c2c68 100644 --- a/tests/functional/openlp_core_ui/test_aboutform.py +++ b/tests/functional/openlp_core_ui/test_aboutform.py @@ -58,4 +58,4 @@ class TestFirstTimeForm(TestCase, TestMixin): about_form = AboutForm(None) # THEN: The build number should be in the text - assert about_form.about_text_edit.plainText().split('\n')[0] == '' + assert about_form.about_text_edit.toPlainText().split('\n')[0] == '' From ddb92c3cd5d32997f32e89c1f09a31cd52527c03 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Tue, 13 Dec 2016 21:50:34 +0200 Subject: [PATCH 58/75] Fix a problem with the test --- tests/functional/openlp_core_ui/test_aboutform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/openlp_core_ui/test_aboutform.py b/tests/functional/openlp_core_ui/test_aboutform.py index 60c4c2c68..47a685f9d 100644 --- a/tests/functional/openlp_core_ui/test_aboutform.py +++ b/tests/functional/openlp_core_ui/test_aboutform.py @@ -58,4 +58,4 @@ class TestFirstTimeForm(TestCase, TestMixin): about_form = AboutForm(None) # THEN: The build number should be in the text - assert about_form.about_text_edit.toPlainText().split('\n')[0] == '' + assert 'OpenLP 3.1.5 build 3000' in about_form.about_text_edit.toPlainText() From 3639785151ef57223c991766925ef66c36fba15f Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 15 Dec 2016 16:11:42 +0200 Subject: [PATCH 59/75] Fix bug #1642684 by rather just setting the edit text to a blank string Fixes: https://launchpad.net/bugs/1642684 --- openlp/plugins/songs/forms/editsongform.py | 19 ++++++------ .../openlp_plugins/songs/test_editsongform.py | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/openlp/plugins/songs/forms/editsongform.py b/openlp/plugins/songs/forms/editsongform.py index a17c9fb5f..271dadce7 100644 --- a/openlp/plugins/songs/forms/editsongform.py +++ b/openlp/plugins/songs/forms/editsongform.py @@ -118,13 +118,13 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): objects = self.manager.get_all_objects(cls) objects.sort(key=get_key) combo.clear() - combo.addItem('') for obj in objects: row = combo.count() combo.addItem(obj.name) cache.append(obj.name) combo.setItemData(row, obj.id) set_case_insensitive_completer(cache, combo) + combo.setEditText('') def _add_author_to_list(self, author, author_type): """ @@ -360,7 +360,6 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): authors = self.manager.get_all_objects(Author) authors.sort(key=get_author_key) self.authors_combo_box.clear() - self.authors_combo_box.addItem('') self.authors = [] for author in authors: row = self.authors_combo_box.count() @@ -368,6 +367,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): self.authors_combo_box.setItemData(row, author.id) self.authors.append(author.display_name) set_case_insensitive_completer(self.authors, self.authors_combo_box) + self.authors_combo_box.setEditText('') # Types self.author_types_combo_box.clear() @@ -398,11 +398,11 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): return get_natural_key(theme) self.theme_combo_box.clear() - self.theme_combo_box.addItem('') self.themes = theme_list self.themes.sort(key=get_theme_key) self.theme_combo_box.addItems(theme_list) set_case_insensitive_completer(self.themes, self.theme_combo_box) + self.theme_combo_box.setEditText('') def load_media_files(self): """ @@ -442,7 +442,6 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): self.load_songbooks() self.load_media_files() self.theme_combo_box.setEditText('') - self.theme_combo_box.setCurrentIndex(0) # it's a new song to preview is not possible self.preview_button.setVisible(False) @@ -591,7 +590,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): self.manager.save_object(author) self._add_author_to_list(author, author_type) self.load_authors() - self.authors_combo_box.setCurrentIndex(0) + self.authors_combo_box.setEditText('') else: return elif item > 0: @@ -602,7 +601,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): message=translate('SongsPlugin.EditSongForm', 'This author is already in the list.')) else: self._add_author_to_list(author, author_type) - self.authors_combo_box.setCurrentIndex(0) + self.authors_combo_box.setEditText('') else: QtWidgets.QMessageBox.warning( self, UiStrings().NISs, @@ -666,7 +665,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): topic_item.setData(QtCore.Qt.UserRole, topic.id) self.topics_list_view.addItem(topic_item) self.load_topics() - self.topics_combo_box.setCurrentIndex(0) + self.topics_combo_box.setEditText('') else: return elif item > 0: @@ -679,7 +678,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): topic_item = QtWidgets.QListWidgetItem(str(topic.name)) topic_item.setData(QtCore.Qt.UserRole, topic.id) self.topics_list_view.addItem(topic_item) - self.topics_combo_box.setCurrentIndex(0) + self.topics_combo_box.setEditText('') else: QtWidgets.QMessageBox.warning( self, UiStrings().NISs, @@ -709,7 +708,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): self.manager.save_object(songbook) self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text()) self.load_songbooks() - self.songbooks_combo_box.setCurrentIndex(0) + self.songbooks_combo_box.setEditText('') self.songbook_entry_edit.clear() else: return @@ -721,7 +720,7 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): message=translate('SongsPlugin.EditSongForm', 'This Songbook is already in the list.')) else: self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text()) - self.songbooks_combo_box.setCurrentIndex(0) + self.songbooks_combo_box.setEditText('') self.songbook_entry_edit.clear() else: QtWidgets.QMessageBox.warning( diff --git a/tests/functional/openlp_plugins/songs/test_editsongform.py b/tests/functional/openlp_plugins/songs/test_editsongform.py index 184c59717..ba53fa525 100644 --- a/tests/functional/openlp_plugins/songs/test_editsongform.py +++ b/tests/functional/openlp_plugins/songs/test_editsongform.py @@ -76,3 +76,34 @@ class TestEditSongForm(TestCase, TestMixin): # THEN they should be valid self.assertTrue(valid, "The tags list should be valid") + + @patch('openlp.plugins.songs.forms.editsongform.set_case_insensitive_completer') + def test_load_objects(self, mocked_set_case_insensitive_completer): + """ + Test the _load_objects() method + """ + # GIVEN: A song edit form and some mocked stuff + mocked_class = MagicMock() + mocked_class.name = 'Author' + mocked_combo = MagicMock() + mocked_combo.count.return_value = 0 + mocked_cache = MagicMock() + mocked_object = MagicMock() + mocked_object.name = 'Charles' + mocked_object.id = 1 + mocked_manager = MagicMock() + mocked_manager.get_all_objects.return_value = [mocked_object] + self.edit_song_form.manager = mocked_manager + + # WHEN: _load_objects() is called + self.edit_song_form._load_objects(mocked_class, mocked_combo, mocked_cache) + + # THEN: All the correct methods should have been called + self.edit_song_form.manager.get_all_objects.assert_called_once_with(mocked_class) + mocked_combo.clear.assert_called_once_with() + mocked_combo.count.assert_called_once_with() + mocked_combo.addItem.assert_called_once_with('Charles') + mocked_cache.append.assert_called_once_with('Charles') + mocked_combo.setItemData.assert_called_once_with(0, 1) + mocked_set_case_insensitive_completer.assert_called_once_with(mocked_cache, mocked_combo) + mocked_combo.setEditText.assert_called_once_with('') From dcfd0a83a6e7a0dd183987bf1b536a1419063435 Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Thu, 15 Dec 2016 17:33:03 +0100 Subject: [PATCH 60/75] Updates to the appveyor. Only build docs if building trunk. --- scripts/appveyor-webhook.py | 10 +++++++--- scripts/appveyor.yml | 38 ++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/scripts/appveyor-webhook.py b/scripts/appveyor-webhook.py index 573aa1045..45438645d 100755 --- a/scripts/appveyor-webhook.py +++ b/scripts/appveyor-webhook.py @@ -42,7 +42,7 @@ webhook_element = \ { 'commit': { 'author': { - 'email': 'open@contributer', + 'email': 'contributer@openlp', 'name': 'OpenLP Contributor' }, 'id': None, @@ -92,6 +92,10 @@ def get_yml(branch): yml_text = f.read() f.close() yml_text = yml_text.replace('BRANCHNAME', branch) + if 'openlp-core/openlp/trunk' in branch: + yml_text = yml_text.replace('BUILD_DOCS', '$TRUE') + else: + yml_text = yml_text.replace('BUILD_DOCS', '$FALSE') return yml_text @@ -115,8 +119,6 @@ def get_appveyor_build_url(branch): """ Get the url of the build. """ - # Wait 10 seconds to make sure the hook has been triggered - time.sleep(10) responce = urllib.request.urlopen(appveyor_api_url) json_str = responce.read().decode('utf-8') build_json = json.loads(json_str) @@ -130,4 +132,6 @@ else: webhook_url = sys.argv[1] branch = sys.argv[2] hook(webhook_url, get_yml(branch)) + # Wait 5 seconds to make sure the hook has been triggered + time.sleep(5) get_appveyor_build_url(branch) diff --git a/scripts/appveyor.yml b/scripts/appveyor.yml index e60473cc5..bdc23f303 100644 --- a/scripts/appveyor.yml +++ b/scripts/appveyor.yml @@ -44,10 +44,8 @@ test_script: after_test: # This is where we create a package using PyInstaller # First get PyInstaller - #- curl -L -O https://github.com/pyinstaller/pyinstaller/archive/develop.zip - curl -L -O https://github.com/pyinstaller/pyinstaller/releases/download/v3.2/PyInstaller-3.2.zip - 7z x PyInstaller-3.2.zip - #- mv pyinstaller-develop PyInstaller-3.2 # Install PyInstaller dependencies - "%PYTHON%\\python.exe -m pip install future pefile" # Download and install Inno Setup - used for packaging @@ -64,28 +62,38 @@ after_test: # - curl -L -O http://downloads.sourceforge.net/project/portableapps/NSIS%20Portable/NSISPortable_3.0_English.paf.exe # - NSISPortable_3.0_English.paf.exe /S # Get the packaging code - #- bzr checkout --lightweight lp:openlp/packaging packaging - - bzr checkout --lightweight lp:~raoul-snyman/openlp/pyinstaller-change packaging - #- curl -L http://bazaar.launchpad.net/~openlp-core/openlp/packaging/tarball -o packaging.tar.gz - #- 7z e packaging.tar.gz - #- 7z x packaging.tar - #- mv ~openlp-core/openlp/packaging packaging + - curl -L http://bazaar.launchpad.net/~openlp-core/openlp/packaging/tarball -o packaging.tar.gz + - 7z e packaging.tar.gz + - 7z x packaging.tar + - mv ~openlp-core/openlp/packaging packaging # If this is trunk we should also build the manual # Download and install HTML Help Workshop #- curl -L "http://go.microsoft.com/fwlink/p/?linkid=14188" -o htmlhelp.exe #- htmlhelp.exe /Q # Install sphinx - - "%PYTHON%\\python.exe -m pip install sphinx" + - ps: >- + If (BUILD_DOCS) { + &"$env:PYTHON\python.exe" -m pip install sphinx + Invoke-WebRequest -Uri "http://bazaar.launchpad.net/~openlp-core/openlp/documentation/tarball" -OutFile documentation.tar.gz + 7z e documentation.tar.gz + 7z x documentation.tar + mv ~openlp-core/openlp/documentation documentation + cd packaging + &"$env:PYTHON\python.exe" builders/windows-builder.py --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation --portable + } else { + cd packaging + &"$env:PYTHON\python.exe" builders/windows-builder.py --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch --portable + } # Get the documentation code #- bzr checkout --lightweight lp:openlp/documentation documentation - - curl -L http://bazaar.launchpad.net/~openlp-core/openlp/documentation/tarball -o documentation.tar.gz - - 7z e documentation.tar.gz - - 7z x documentation.tar - - mv ~openlp-core/openlp/documentation documentation + #- curl -L http://bazaar.launchpad.net/~openlp-core/openlp/documentation/tarball -o documentation.tar.gz + #- 7z e documentation.tar.gz + #- 7z x documentation.tar + #- mv ~openlp-core/openlp/documentation documentation # Build the bundle - - cd packaging + #- cd packaging #- "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation" - - "%PYTHON%\\python.exe builders/windows-builder.py -v --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation --portable" + #- "%PYTHON%\\python.exe builders/windows-builder.py --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation --portable" artifacts: - path: openlp-branch\dist\*.exe From 47ab1ce1a7de7f43e8515357514897aeb0940942 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 15 Dec 2016 19:45:46 +0200 Subject: [PATCH 61/75] Hide the splash screen when the backup dialog shows and when the exception form shows --- openlp/core/__init__.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/openlp/core/__init__.py b/openlp/core/__init__.py index cb298e11b..ad06f3629 100644 --- a/openlp/core/__init__.py +++ b/openlp/core/__init__.py @@ -129,21 +129,21 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): application_stylesheet += WIN_REPAIR_STYLESHEET if application_stylesheet: self.setStyleSheet(application_stylesheet) - show_splash = Settings().value('core/show splash') - if show_splash: + can_show_splash = Settings().value('core/show splash') + if can_show_splash: self.splash = SplashScreen() self.splash.show() # make sure Qt really display the splash screen self.processEvents() # Check if OpenLP has been upgrade and if a backup of data should be created - self.backup_on_upgrade(has_run_wizard) + self.backup_on_upgrade(has_run_wizard, can_show_splash) # start the main app window self.main_window = MainWindow() Registry().execute('bootstrap_initialise') Registry().execute('bootstrap_post_set_up') Registry().initialise = False self.main_window.show() - if show_splash: + if can_show_splash: # now kill the splashscreen self.splash.finish(self.main_window) log.debug('Splashscreen closed') @@ -224,13 +224,20 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): self.exception_form = ExceptionForm() self.exception_form.exception_text_edit.setPlainText(''.join(format_exception(exc_type, value, traceback))) self.set_normal_cursor() + is_splash_visible = False + if hasattr(self, 'splash') and self.splash.isVisible(): + is_splash_visible = True + self.splash.hide() self.exception_form.exec() + if is_splash_visible: + self.splash.show() - def backup_on_upgrade(self, has_run_wizard): + def backup_on_upgrade(self, has_run_wizard, can_show_splash): """ Check if OpenLP has been upgraded, and ask if a backup of data should be made :param has_run_wizard: OpenLP has been run before + :param can_show_splash: Should OpenLP show the splash screen """ data_version = Settings().value('core/application version') openlp_version = get_application_version()['version'] @@ -239,6 +246,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): Settings().setValue('core/application version', openlp_version) # If data_version is different from the current version ask if we should backup the data folder elif data_version != openlp_version: + if self.splash.isVisible(): + self.splash.hide() if QtWidgets.QMessageBox.question(None, translate('OpenLP', 'Backup'), translate('OpenLP', 'OpenLP has been upgraded, do you want to create\n' 'a backup of the old data folder?'), @@ -261,6 +270,8 @@ class OpenLP(OpenLPMixin, QtWidgets.QApplication): # Update the version in the settings Settings().setValue('core/application version', openlp_version) + if can_show_splash: + self.splash.show() def process_events(self): """ From acb8bc2ba1864fbbe6ddfe27c65b52d911ab925f Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Thu, 15 Dec 2016 19:24:11 +0100 Subject: [PATCH 62/75] clean up appveyor.yml --- scripts/appveyor.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/scripts/appveyor.yml b/scripts/appveyor.yml index bdc23f303..89621d150 100644 --- a/scripts/appveyor.yml +++ b/scripts/appveyor.yml @@ -3,7 +3,6 @@ version: OpenLP-win-ci-b{build} init: - choco install -y --force bzr - set PATH=C:\Program Files (x86)\Bazaar;%PATH% - - bzr --version clone_script: - bzr checkout --lightweight BRANCHNAME openlp-branch @@ -32,7 +31,6 @@ install: - 7z x -oMediaInfo MediaInfo_CLI_0.7.90_Windows_i386.zip - cp MediaInfo\\MediaInfo.exe openlp-branch\\MediaInfo.exe - build: off test_script: @@ -67,10 +65,6 @@ after_test: - 7z x packaging.tar - mv ~openlp-core/openlp/packaging packaging # If this is trunk we should also build the manual - # Download and install HTML Help Workshop - #- curl -L "http://go.microsoft.com/fwlink/p/?linkid=14188" -o htmlhelp.exe - #- htmlhelp.exe /Q - # Install sphinx - ps: >- If (BUILD_DOCS) { &"$env:PYTHON\python.exe" -m pip install sphinx @@ -84,16 +78,6 @@ after_test: cd packaging &"$env:PYTHON\python.exe" builders/windows-builder.py --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch --portable } - # Get the documentation code - #- bzr checkout --lightweight lp:openlp/documentation documentation - #- curl -L http://bazaar.launchpad.net/~openlp-core/openlp/documentation/tarball -o documentation.tar.gz - #- 7z e documentation.tar.gz - #- 7z x documentation.tar - #- mv ~openlp-core/openlp/documentation documentation - # Build the bundle - #- cd packaging - #- "%PYTHON%\\python.exe windows/windows-builder.py -v -u -t -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation" - #- "%PYTHON%\\python.exe builders/windows-builder.py --skip-update --skip-translations -c windows/config-appveyor.ini -b ../openlp-branch -d ../documentation --portable" artifacts: - path: openlp-branch\dist\*.exe From 936e6c8816034c897feba9456430a0567aeb94dc Mon Sep 17 00:00:00 2001 From: Tomas Groth Date: Thu, 15 Dec 2016 20:26:18 +0100 Subject: [PATCH 63/75] enable running tests --- scripts/appveyor.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/appveyor.yml b/scripts/appveyor.yml index 89621d150..27b4cfc98 100644 --- a/scripts/appveyor.yml +++ b/scripts/appveyor.yml @@ -35,7 +35,8 @@ build: off test_script: - cd openlp-branch - #- "%PYTHON%\\python.exe -m nose -v tests" + # Run the tests + - "%PYTHON%\\python.exe -m nose -v tests" # Go back to the user root folder - cd.. From 95eb290226ffe2d26a063502d07df1faf531398b Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 15 Dec 2016 22:36:04 +0200 Subject: [PATCH 64/75] Make the tab style affect only the media library tabs, not everything else too --- openlp/core/ui/mainwindow.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openlp/core/ui/mainwindow.py b/openlp/core/ui/mainwindow.py index ab92914a5..ec585790c 100644 --- a/openlp/core/ui/mainwindow.py +++ b/openlp/core/ui/mainwindow.py @@ -52,7 +52,7 @@ from openlp.core.ui.lib.mediadockmanager import MediaDockManager log = logging.getLogger(__name__) MEDIA_MANAGER_STYLE = """ -::tab { +::tab#media_tool_box { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 palette(button), stop: 1.0 palette(mid)); border: 0; @@ -61,10 +61,8 @@ MEDIA_MANAGER_STYLE = """ margin-bottom: 0; text-align: left; } -::tab:selected { - border: 1px solid palette(highlight); - font-weight: bold; -} +/* This is here to make the tabs on KDE with the Breeze theme work */ +::tab:selected {} """ PROGRESSBAR_STYLE = """ From c86d669346c832c0d9a049717f956e0b16f6af52 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 15 Dec 2016 23:29:29 +0200 Subject: [PATCH 65/75] Added a test --- .../openlp_core_ui/test_shortcutlistdialog.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/functional/openlp_core_ui/test_shortcutlistdialog.py diff --git a/tests/functional/openlp_core_ui/test_shortcutlistdialog.py b/tests/functional/openlp_core_ui/test_shortcutlistdialog.py new file mode 100644 index 000000000..2f39cb34a --- /dev/null +++ b/tests/functional/openlp_core_ui/test_shortcutlistdialog.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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.shortcutlistdialog package. +""" +from PyQt5 import QtCore, QtGui, QtWidgets + +from openlp.core.ui.shortcutlistdialog import CaptureShortcutButton, ShortcutTreeWidget + +from tests.interfaces import MagicMock, patch + + +def test_key_press_event(): + """ + Test the keyPressEvent method + """ + # GIVEN: A checked button and a mocked event + button = CaptureShortcutButton() + button.setChecked(True) + mocked_event = MagicMock() + mocked_event.key.return_value = QtCore.Qt.Key_Space + + # WHEN: keyPressEvent is called with an event that should be ignored + button.keyPressEvent(mocked_event) + + # THEN: The ignore() method on the event should have been called + mocked_event.ignore.assert_called_once_with() + + +def test_keyboard_search(): + """ + Test the keyboardSearch method of the ShortcutTreeWidget + """ + # GIVEN: A ShortcutTreeWidget + widget = ShortcutTreeWidget() + + # WHEN: keyboardSearch() is called + widget.keyboardSearch('') + + # THEN: Nothing happens + assert True From 77e048f6de2982f71ee039da9b9fe3fafdcfc910 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Fri, 16 Dec 2016 00:01:15 +0200 Subject: [PATCH 66/75] Fix the tests --- tests/functional/test_init.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/functional/test_init.py b/tests/functional/test_init.py index 825de57f3..504489416 100644 --- a/tests/functional/test_init.py +++ b/tests/functional/test_init.py @@ -102,7 +102,7 @@ class TestInit(TestCase, TestMixin): mocked_question.return_value = QtWidgets.QMessageBox.No # WHEN: We check if a backup should be created - self.openlp.backup_on_upgrade(old_install) + self.openlp.backup_on_upgrade(old_install, False) # THEN: It should not ask if we want to create a backup self.assertEqual(Settings().value('core/application version'), '2.2.0', 'Version should be the same!') @@ -120,14 +120,18 @@ class TestInit(TestCase, TestMixin): 'build': 'bzr000' } Settings().setValue('core/application version', '2.0.5') + self.openlp.splash = MagicMock() + self.openlp.splash.isVisible.return_value = True with patch('openlp.core.get_application_version') as mocked_get_application_version,\ patch('openlp.core.QtWidgets.QMessageBox.question') as mocked_question: mocked_get_application_version.return_value = MOCKED_VERSION mocked_question.return_value = QtWidgets.QMessageBox.No # WHEN: We check if a backup should be created - self.openlp.backup_on_upgrade(old_install) + self.openlp.backup_on_upgrade(old_install, True) # THEN: It should ask if we want to create a backup self.assertEqual(Settings().value('core/application version'), '2.2.0', 'Version should be upgraded!') self.assertEqual(mocked_question.call_count, 1, 'A question should have been asked!') + self.openlp.splash.hide.assert_called_once_with() + self.openlp.splash.show.assert_called_once_with() From 4008ed008feabd4ab6c8d785bbf39f58c47cb79b Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Tue, 20 Dec 2016 21:20:54 +0000 Subject: [PATCH 67/75] Move url size --- openlp/core/lib/webpagereader.py | 182 -------------- openlp/core/ui/firsttimeform.py | 28 +-- openlp/plugins/bibles/lib/importers/http.py | 2 +- .../openlp_core_lib/test_webpagereader.py | 229 ------------------ .../openlp_core_ui/test_first_time.py | 2 +- 5 files changed, 6 insertions(+), 437 deletions(-) delete mode 100644 openlp/core/lib/webpagereader.py delete mode 100644 tests/functional/openlp_core_lib/test_webpagereader.py diff --git a/openlp/core/lib/webpagereader.py b/openlp/core/lib/webpagereader.py deleted file mode 100644 index 52c98bbaf..000000000 --- a/openlp/core/lib/webpagereader.py +++ /dev/null @@ -1,182 +0,0 @@ -# -*- coding: utf-8 -*- -# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 - -############################################################################### -# OpenLP - Open Source Lyrics Projection # -# --------------------------------------------------------------------------- # -# Copyright (c) 2008-2016 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 # -############################################################################### -""" -The :mod:`openlp.core.utils` module provides the utility libraries for OpenLP. -""" -import logging -import socket -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from http.client import HTTPException -from random import randint - -from openlp.core.common import Registry - -log = logging.getLogger(__name__ + '.__init__') - -USER_AGENTS = { - 'win32': [ - 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36', - 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36', - 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.71 Safari/537.36' - ], - 'darwin': [ - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_3) AppleWebKit/537.31 (KHTML, like Gecko) ' - 'Chrome/26.0.1410.43 Safari/537.31', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/536.11 (KHTML, like Gecko) ' - 'Chrome/20.0.1132.57 Safari/536.11', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/536.11 (KHTML, like Gecko) ' - 'Chrome/20.0.1132.47 Safari/536.11', - ], - 'linux2': [ - 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Ubuntu Chromium/25.0.1364.160 ' - 'Chrome/25.0.1364.160 Safari/537.22', - 'Mozilla/5.0 (X11; CrOS armv7l 2913.260.0) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.99 ' - 'Safari/537.11', - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.27 (KHTML, like Gecko) Chrome/26.0.1389.0 Safari/537.27' - ], - 'default': [ - 'Mozilla/5.0 (X11; NetBSD amd64; rv:18.0) Gecko/20130120 Firefox/18.0' - ] -} -CONNECTION_TIMEOUT = 30 -CONNECTION_RETRIES = 2 - - -class HTTPRedirectHandlerFixed(urllib.request.HTTPRedirectHandler): - """ - Special HTTPRedirectHandler used to work around http://bugs.python.org/issue22248 - (Redirecting to urls with special chars) - """ - def redirect_request(self, req, fp, code, msg, headers, new_url): - # - """ - Test if the new_url can be decoded to ascii - - :param req: - :param fp: - :param code: - :param msg: - :param headers: - :param new_url: - :return: - """ - try: - new_url.encode('latin1').decode('ascii') - fixed_url = new_url - except Exception: - # The url could not be decoded to ascii, so we do some url encoding - fixed_url = urllib.parse.quote(new_url.encode('latin1').decode('utf-8', 'replace'), safe='/:') - return super(HTTPRedirectHandlerFixed, self).redirect_request(req, fp, code, msg, headers, fixed_url) - - -def _get_user_agent(): - """ - Return a user agent customised for the platform the user is on. - """ - browser_list = USER_AGENTS.get(sys.platform, None) - if not browser_list: - browser_list = USER_AGENTS['default'] - random_index = randint(0, len(browser_list) - 1) - return browser_list[random_index] - - -def get_web_page(url, header=None, update_openlp=False): - """ - Attempts to download the webpage at url and returns that page or None. - - :param url: The URL to be downloaded. - :param header: An optional HTTP header to pass in the request to the web server. - :param update_openlp: Tells OpenLP to update itself if the page is successfully downloaded. - Defaults to False. - """ - # TODO: Add proxy usage. Get proxy info from OpenLP settings, add to a - # proxy_handler, build into an opener and install the opener into urllib2. - # http://docs.python.org/library/urllib2.html - if not url: - return None - # This is needed to work around http://bugs.python.org/issue22248 and https://bugs.launchpad.net/openlp/+bug/1251437 - opener = urllib.request.build_opener(HTTPRedirectHandlerFixed()) - urllib.request.install_opener(opener) - req = urllib.request.Request(url) - if not header or header[0].lower() != 'user-agent': - user_agent = _get_user_agent() - req.add_header('User-Agent', user_agent) - if header: - req.add_header(header[0], header[1]) - log.debug('Downloading URL = %s' % url) - retries = 0 - while retries <= CONNECTION_RETRIES: - retries += 1 - time.sleep(0.1) - try: - page = urllib.request.urlopen(req, timeout=CONNECTION_TIMEOUT) - log.debug('Downloaded page {text}'.format(text=page.geturl())) - break - except urllib.error.URLError as err: - log.exception('URLError on {text}'.format(text=url)) - log.exception('URLError: {text}'.format(text=err.reason)) - page = None - if retries > CONNECTION_RETRIES: - raise - except socket.timeout: - log.exception('Socket timeout: {text}'.format(text=url)) - page = None - if retries > CONNECTION_RETRIES: - raise - except socket.gaierror: - log.exception('Socket gaierror: {text}'.format(text=url)) - page = None - if retries > CONNECTION_RETRIES: - raise - except ConnectionRefusedError: - log.exception('ConnectionRefused: {text}'.format(text=url)) - page = None - if retries > CONNECTION_RETRIES: - raise - break - except ConnectionError: - log.exception('Connection error: {text}'.format(text=url)) - page = None - if retries > CONNECTION_RETRIES: - raise - except HTTPException: - log.exception('HTTPException error: {text}'.format(text=url)) - page = None - if retries > CONNECTION_RETRIES: - raise - except: - # Don't know what's happening, so reraise the original - raise - if update_openlp: - Registry().get('application').process_events() - if not page: - log.exception('{text} could not be downloaded'.format(text=url)) - return None - log.debug(page) - return page - - -__all__ = ['get_web_page'] diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index 974ef90db..b59f31211 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -39,7 +39,7 @@ from openlp.core.common import Registry, RegistryProperties, AppLocation, Settin translate, clean_button_text, trace_error_handler from openlp.core.lib import PluginStatus, build_icon from openlp.core.lib.ui import critical_error_message_box -from openlp.core.lib.webpagereader import get_web_page, CONNECTION_RETRIES, CONNECTION_TIMEOUT +from openlp.core.common.httputils import get_web_page, get_url_file_size, CONNECTION_RETRIES, CONNECTION_TIMEOUT from .firsttimewizard import UiFirstTimeWizard, FirstTimePage log = logging.getLogger(__name__) @@ -455,26 +455,6 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): if item: item.setIcon(build_icon(os.path.join(gettempdir(), 'openlp', screenshot))) - def _get_file_size(self, url): - """ - Get the size of a file. - - :param url: The URL of the file we want to download. - """ - retries = 0 - while True: - try: - site = urllib.request.urlopen(url, timeout=CONNECTION_TIMEOUT) - meta = site.info() - return int(meta.get("Content-Length")) - except urllib.error.URLError: - if retries > CONNECTION_RETRIES: - raise - else: - retries += 1 - time.sleep(0.1) - continue - def _download_progress(self, count, block_size): """ Calculate and display the download progress. @@ -510,7 +490,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): item = self.songs_list_widget.item(i) if item.checkState() == QtCore.Qt.Checked: filename, sha256 = item.data(QtCore.Qt.UserRole) - size = self._get_file_size('{path}{name}'.format(path=self.songs_url, name=filename)) + size = get_url_file_size('{path}{name}'.format(path=self.songs_url, name=filename)) self.max_progress += size # Loop through the Bibles list and increase for each selected item iterator = QtWidgets.QTreeWidgetItemIterator(self.bibles_tree_widget) @@ -519,7 +499,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): item = iterator.value() if item.parent() and item.checkState(0) == QtCore.Qt.Checked: filename, sha256 = item.data(0, QtCore.Qt.UserRole) - size = self._get_file_size('{path}{name}'.format(path=self.bibles_url, name=filename)) + size = get_url_file_size('{path}{name}'.format(path=self.bibles_url, name=filename)) self.max_progress += size iterator += 1 # Loop through the themes list and increase for each selected item @@ -528,7 +508,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): item = self.themes_list_widget.item(i) if item.checkState() == QtCore.Qt.Checked: filename, sha256 = item.data(QtCore.Qt.UserRole) - size = self._get_file_size('{path}{name}'.format(path=self.themes_url, name=filename)) + size = get_url_file_size('{path}{name}'.format(path=self.themes_url, name=filename)) self.max_progress += size except urllib.error.URLError: trace_error_handler(log) diff --git a/openlp/plugins/bibles/lib/importers/http.py b/openlp/plugins/bibles/lib/importers/http.py index d41187d93..071ab0119 100644 --- a/openlp/plugins/bibles/lib/importers/http.py +++ b/openlp/plugins/bibles/lib/importers/http.py @@ -32,7 +32,7 @@ from bs4 import BeautifulSoup, NavigableString, Tag from openlp.core.common import Registry, RegistryProperties, translate from openlp.core.lib.ui import critical_error_message_box -from openlp.core.lib.webpagereader import get_web_page +from openlp.core.common.httputils import get_web_page from openlp.plugins.bibles.lib import SearchResults from openlp.plugins.bibles.lib.bibleimport import BibleImport from openlp.plugins.bibles.lib.db import BibleDB, BiblesResourcesDB, Book diff --git a/tests/functional/openlp_core_lib/test_webpagereader.py b/tests/functional/openlp_core_lib/test_webpagereader.py deleted file mode 100644 index 6e33fca51..000000000 --- a/tests/functional/openlp_core_lib/test_webpagereader.py +++ /dev/null @@ -1,229 +0,0 @@ -# -*- coding: utf-8 -*- -# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 - -############################################################################### -# OpenLP - Open Source Lyrics Projection # -# --------------------------------------------------------------------------- # -# Copyright (c) 2008-2016 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 # -############################################################################### -""" -Functional tests to test the AppLocation class and related methods. -""" -from unittest import TestCase - -from openlp.core.lib.webpagereader import _get_user_agent, get_web_page - -from tests.functional import MagicMock, patch - - -class TestUtils(TestCase): - """ - A test suite to test out various methods around the AppLocation class. - """ - def test_get_user_agent_linux(self): - """ - Test that getting a user agent on Linux returns a user agent suitable for Linux - """ - with patch('openlp.core.lib.webpagereader.sys') as mocked_sys: - - # GIVEN: The system is Linux - mocked_sys.platform = 'linux2' - - # WHEN: We call _get_user_agent() - user_agent = _get_user_agent() - - # THEN: The user agent is a Linux (or ChromeOS) user agent - result = 'Linux' in user_agent or 'CrOS' in user_agent - self.assertTrue(result, 'The user agent should be a valid Linux user agent') - - def test_get_user_agent_windows(self): - """ - Test that getting a user agent on Windows returns a user agent suitable for Windows - """ - with patch('openlp.core.lib.webpagereader.sys') as mocked_sys: - - # GIVEN: The system is Linux - mocked_sys.platform = 'win32' - - # WHEN: We call _get_user_agent() - user_agent = _get_user_agent() - - # THEN: The user agent is a Linux (or ChromeOS) user agent - self.assertIn('Windows', user_agent, 'The user agent should be a valid Windows user agent') - - def test_get_user_agent_macos(self): - """ - Test that getting a user agent on OS X returns a user agent suitable for OS X - """ - with patch('openlp.core.lib.webpagereader.sys') as mocked_sys: - - # GIVEN: The system is Linux - mocked_sys.platform = 'darwin' - - # WHEN: We call _get_user_agent() - user_agent = _get_user_agent() - - # THEN: The user agent is a Linux (or ChromeOS) user agent - self.assertIn('Mac OS X', user_agent, 'The user agent should be a valid OS X user agent') - - def test_get_user_agent_default(self): - """ - Test that getting a user agent on a non-Linux/Windows/OS X platform returns the default user agent - """ - with patch('openlp.core.lib.webpagereader.sys') as mocked_sys: - - # GIVEN: The system is Linux - mocked_sys.platform = 'freebsd' - - # WHEN: We call _get_user_agent() - user_agent = _get_user_agent() - - # THEN: The user agent is a Linux (or ChromeOS) user agent - self.assertIn('NetBSD', user_agent, 'The user agent should be the default user agent') - - def test_get_web_page_no_url(self): - """ - Test that sending a URL of None to the get_web_page method returns None - """ - # GIVEN: A None url - test_url = None - - # WHEN: We try to get the test URL - result = get_web_page(test_url) - - # THEN: None should be returned - self.assertIsNone(result, 'The return value of get_web_page should be None') - - def test_get_web_page(self): - """ - Test that the get_web_page method works correctly - """ - with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \ - patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \ - patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent, \ - patch('openlp.core.common.Registry') as MockRegistry: - # GIVEN: Mocked out objects and a fake URL - mocked_request_object = MagicMock() - MockRequest.return_value = mocked_request_object - mocked_page_object = MagicMock() - mock_urlopen.return_value = mocked_page_object - mock_get_user_agent.return_value = 'user_agent' - fake_url = 'this://is.a.fake/url' - - # WHEN: The get_web_page() method is called - returned_page = get_web_page(fake_url) - - # THEN: The correct methods are called with the correct arguments and a web page is returned - MockRequest.assert_called_with(fake_url) - mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent') - self.assertEqual(1, mocked_request_object.add_header.call_count, - 'There should only be 1 call to add_header') - mock_get_user_agent.assert_called_with() - mock_urlopen.assert_called_with(mocked_request_object, timeout=30) - mocked_page_object.geturl.assert_called_with() - self.assertEqual(0, MockRegistry.call_count, 'The Registry() object should have never been called') - self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') - - def test_get_web_page_with_header(self): - """ - Test that adding a header to the call to get_web_page() adds the header to the request - """ - with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \ - patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \ - patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent: - # GIVEN: Mocked out objects, a fake URL and a fake header - mocked_request_object = MagicMock() - MockRequest.return_value = mocked_request_object - mocked_page_object = MagicMock() - mock_urlopen.return_value = mocked_page_object - mock_get_user_agent.return_value = 'user_agent' - fake_url = 'this://is.a.fake/url' - fake_header = ('Fake-Header', 'fake value') - - # WHEN: The get_web_page() method is called - returned_page = get_web_page(fake_url, header=fake_header) - - # THEN: The correct methods are called with the correct arguments and a web page is returned - MockRequest.assert_called_with(fake_url) - mocked_request_object.add_header.assert_called_with(fake_header[0], fake_header[1]) - self.assertEqual(2, mocked_request_object.add_header.call_count, - 'There should only be 2 calls to add_header') - mock_get_user_agent.assert_called_with() - mock_urlopen.assert_called_with(mocked_request_object, timeout=30) - mocked_page_object.geturl.assert_called_with() - self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') - - def test_get_web_page_with_user_agent_in_headers(self): - """ - Test that adding a user agent in the header when calling get_web_page() adds that user agent to the request - """ - with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \ - patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \ - patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent: - # GIVEN: Mocked out objects, a fake URL and a fake header - mocked_request_object = MagicMock() - MockRequest.return_value = mocked_request_object - mocked_page_object = MagicMock() - mock_urlopen.return_value = mocked_page_object - fake_url = 'this://is.a.fake/url' - user_agent_header = ('User-Agent', 'OpenLP/2.2.0') - - # WHEN: The get_web_page() method is called - returned_page = get_web_page(fake_url, header=user_agent_header) - - # THEN: The correct methods are called with the correct arguments and a web page is returned - MockRequest.assert_called_with(fake_url) - mocked_request_object.add_header.assert_called_with(user_agent_header[0], user_agent_header[1]) - self.assertEqual(1, mocked_request_object.add_header.call_count, - 'There should only be 1 call to add_header') - self.assertEqual(0, mock_get_user_agent.call_count, '_get_user_agent should not have been called') - mock_urlopen.assert_called_with(mocked_request_object, timeout=30) - mocked_page_object.geturl.assert_called_with() - self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') - - def test_get_web_page_update_openlp(self): - """ - Test that passing "update_openlp" as true to get_web_page calls Registry().get('app').process_events() - """ - with patch('openlp.core.lib.webpagereader.urllib.request.Request') as MockRequest, \ - patch('openlp.core.lib.webpagereader.urllib.request.urlopen') as mock_urlopen, \ - patch('openlp.core.lib.webpagereader._get_user_agent') as mock_get_user_agent, \ - patch('openlp.core.lib.webpagereader.Registry') as MockRegistry: - # GIVEN: Mocked out objects, a fake URL - mocked_request_object = MagicMock() - MockRequest.return_value = mocked_request_object - mocked_page_object = MagicMock() - mock_urlopen.return_value = mocked_page_object - mock_get_user_agent.return_value = 'user_agent' - mocked_registry_object = MagicMock() - mocked_application_object = MagicMock() - mocked_registry_object.get.return_value = mocked_application_object - MockRegistry.return_value = mocked_registry_object - fake_url = 'this://is.a.fake/url' - - # WHEN: The get_web_page() method is called - returned_page = get_web_page(fake_url, update_openlp=True) - - # THEN: The correct methods are called with the correct arguments and a web page is returned - MockRequest.assert_called_with(fake_url) - mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent') - self.assertEqual(1, mocked_request_object.add_header.call_count, - 'There should only be 1 call to add_header') - mock_urlopen.assert_called_with(mocked_request_object, timeout=30) - mocked_page_object.geturl.assert_called_with() - mocked_registry_object.get.assert_called_with('application') - mocked_application_object.process_events.assert_called_with() - self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') diff --git a/tests/functional/openlp_core_ui/test_first_time.py b/tests/functional/openlp_core_ui/test_first_time.py index d8067dfbe..f23bf4db6 100644 --- a/tests/functional/openlp_core_ui/test_first_time.py +++ b/tests/functional/openlp_core_ui/test_first_time.py @@ -31,7 +31,7 @@ import urllib.parse from tests.functional import patch from tests.helpers.testmixin import TestMixin -from openlp.core.lib.webpagereader import CONNECTION_RETRIES, get_web_page +from openlp.core.common.httputils import CONNECTION_RETRIES, get_web_page class TestFirstTimeWizard(TestMixin, TestCase): From 95e70465de6737ddd4377b871874187bdb67c25f Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Tue, 20 Dec 2016 21:21:17 +0000 Subject: [PATCH 68/75] Add files --- openlp/core/common/httputils.py | 203 ++++++++++++++ .../openlp_core_common/test_httputils.py | 247 ++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 openlp/core/common/httputils.py create mode 100644 tests/functional/openlp_core_common/test_httputils.py diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py new file mode 100644 index 000000000..cab3ffb49 --- /dev/null +++ b/openlp/core/common/httputils.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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 # +############################################################################### +""" +The :mod:`openlp.core.utils` module provides the utility libraries for OpenLP. +""" +import logging +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from http.client import HTTPException +from random import randint + +from openlp.core.common import Registry + +log = logging.getLogger(__name__ + '.__init__') + +USER_AGENTS = { + 'win32': [ + 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36', + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36', + 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.71 Safari/537.36' + ], + 'darwin': [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_3) AppleWebKit/537.31 (KHTML, like Gecko) ' + 'Chrome/26.0.1410.43 Safari/537.31', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/536.11 (KHTML, like Gecko) ' + 'Chrome/20.0.1132.57 Safari/536.11', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/536.11 (KHTML, like Gecko) ' + 'Chrome/20.0.1132.47 Safari/536.11', + ], + 'linux2': [ + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Ubuntu Chromium/25.0.1364.160 ' + 'Chrome/25.0.1364.160 Safari/537.22', + 'Mozilla/5.0 (X11; CrOS armv7l 2913.260.0) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.99 ' + 'Safari/537.11', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.27 (KHTML, like Gecko) Chrome/26.0.1389.0 Safari/537.27' + ], + 'default': [ + 'Mozilla/5.0 (X11; NetBSD amd64; rv:18.0) Gecko/20130120 Firefox/18.0' + ] +} +CONNECTION_TIMEOUT = 30 +CONNECTION_RETRIES = 2 + + +class HTTPRedirectHandlerFixed(urllib.request.HTTPRedirectHandler): + """ + Special HTTPRedirectHandler used to work around http://bugs.python.org/issue22248 + (Redirecting to urls with special chars) + """ + def redirect_request(self, req, fp, code, msg, headers, new_url): + # + """ + Test if the new_url can be decoded to ascii + + :param req: + :param fp: + :param code: + :param msg: + :param headers: + :param new_url: + :return: + """ + try: + new_url.encode('latin1').decode('ascii') + fixed_url = new_url + except Exception: + # The url could not be decoded to ascii, so we do some url encoding + fixed_url = urllib.parse.quote(new_url.encode('latin1').decode('utf-8', 'replace'), safe='/:') + return super(HTTPRedirectHandlerFixed, self).redirect_request(req, fp, code, msg, headers, fixed_url) + + +def get_user_agent(): + """ + Return a user agent customised for the platform the user is on. + """ + browser_list = USER_AGENTS.get(sys.platform, None) + if not browser_list: + browser_list = USER_AGENTS['default'] + random_index = randint(0, len(browser_list) - 1) + return browser_list[random_index] + + +def get_web_page(url, header=None, update_openlp=False): + """ + Attempts to download the webpage at url and returns that page or None. + + :param url: The URL to be downloaded. + :param header: An optional HTTP header to pass in the request to the web server. + :param update_openlp: Tells OpenLP to update itself if the page is successfully downloaded. + Defaults to False. + """ + # TODO: Add proxy usage. Get proxy info from OpenLP settings, add to a + # proxy_handler, build into an opener and install the opener into urllib2. + # http://docs.python.org/library/urllib2.html + if not url: + return None + # This is needed to work around http://bugs.python.org/issue22248 and https://bugs.launchpad.net/openlp/+bug/1251437 + opener = urllib.request.build_opener(HTTPRedirectHandlerFixed()) + urllib.request.install_opener(opener) + req = urllib.request.Request(url) + if not header or header[0].lower() != 'user-agent': + user_agent = get_user_agent() + req.add_header('User-Agent', user_agent) + if header: + req.add_header(header[0], header[1]) + log.debug('Downloading URL = %s' % url) + retries = 0 + while retries <= CONNECTION_RETRIES: + retries += 1 + time.sleep(0.1) + try: + page = urllib.request.urlopen(req, timeout=CONNECTION_TIMEOUT) + log.debug('Downloaded page {text}'.format(text=page.geturl())) + break + except urllib.error.URLError as err: + log.exception('URLError on {text}'.format(text=url)) + log.exception('URLError: {text}'.format(text=err.reason)) + page = None + if retries > CONNECTION_RETRIES: + raise + except socket.timeout: + log.exception('Socket timeout: {text}'.format(text=url)) + page = None + if retries > CONNECTION_RETRIES: + raise + except socket.gaierror: + log.exception('Socket gaierror: {text}'.format(text=url)) + page = None + if retries > CONNECTION_RETRIES: + raise + except ConnectionRefusedError: + log.exception('ConnectionRefused: {text}'.format(text=url)) + page = None + if retries > CONNECTION_RETRIES: + raise + break + except ConnectionError: + log.exception('Connection error: {text}'.format(text=url)) + page = None + if retries > CONNECTION_RETRIES: + raise + except HTTPException: + log.exception('HTTPException error: {text}'.format(text=url)) + page = None + if retries > CONNECTION_RETRIES: + raise + except: + # Don't know what's happening, so reraise the original + raise + if update_openlp: + Registry().get('application').process_events() + if not page: + log.exception('{text} could not be downloaded'.format(text=url)) + return None + log.debug(page) + return page + + +def get_url_file_size(url): + """ + Get the size of a file. + + :param url: The URL of the file we want to download. + """ + retries = 0 + while True: + try: + site = urllib.request.urlopen(url, timeout=CONNECTION_TIMEOUT) + meta = site.info() + return int(meta.get("Content-Length")) + except urllib.error.URLError: + if retries > CONNECTION_RETRIES: + raise + else: + retries += 1 + time.sleep(0.1) + continue + + +__all__ = ['get_web_page'] diff --git a/tests/functional/openlp_core_common/test_httputils.py b/tests/functional/openlp_core_common/test_httputils.py new file mode 100644 index 000000000..7a8d478c5 --- /dev/null +++ b/tests/functional/openlp_core_common/test_httputils.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2016 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 # +############################################################################### +""" +Functional tests to test the AppLocation class and related methods. +""" +from unittest import TestCase + +from openlp.core.common.httputils import get_user_agent, get_web_page, get_url_file_size + +from tests.functional import MagicMock, patch + + +class TestHttpUtils(TestCase): + """ + A test suite to test out various methods around the AppLocation class. + """ + def test_get_user_agent_linux(self): + """ + Test that getting a user agent on Linux returns a user agent suitable for Linux + """ + with patch('openlp.core.common.httputils.sys') as mocked_sys: + + # GIVEN: The system is Linux + mocked_sys.platform = 'linux2' + + # WHEN: We call get_user_agent() + user_agent = get_user_agent() + + # THEN: The user agent is a Linux (or ChromeOS) user agent + result = 'Linux' in user_agent or 'CrOS' in user_agent + self.assertTrue(result, 'The user agent should be a valid Linux user agent') + + def test_get_user_agent_windows(self): + """ + Test that getting a user agent on Windows returns a user agent suitable for Windows + """ + with patch('openlp.core.common.httputils.sys') as mocked_sys: + + # GIVEN: The system is Linux + mocked_sys.platform = 'win32' + + # WHEN: We call get_user_agent() + user_agent = get_user_agent() + + # THEN: The user agent is a Linux (or ChromeOS) user agent + self.assertIn('Windows', user_agent, 'The user agent should be a valid Windows user agent') + + def test_get_user_agent_macos(self): + """ + Test that getting a user agent on OS X returns a user agent suitable for OS X + """ + with patch('openlp.core.common.httputils.sys') as mocked_sys: + + # GIVEN: The system is Linux + mocked_sys.platform = 'darwin' + + # WHEN: We call get_user_agent() + user_agent = get_user_agent() + + # THEN: The user agent is a Linux (or ChromeOS) user agent + self.assertIn('Mac OS X', user_agent, 'The user agent should be a valid OS X user agent') + + def test_get_user_agent_default(self): + """ + Test that getting a user agent on a non-Linux/Windows/OS X platform returns the default user agent + """ + with patch('openlp.core.common.httputils.sys') as mocked_sys: + + # GIVEN: The system is Linux + mocked_sys.platform = 'freebsd' + + # WHEN: We call get_user_agent() + user_agent = get_user_agent() + + # THEN: The user agent is a Linux (or ChromeOS) user agent + self.assertIn('NetBSD', user_agent, 'The user agent should be the default user agent') + + def test_get_web_page_no_url(self): + """ + Test that sending a URL of None to the get_web_page method returns None + """ + # GIVEN: A None url + test_url = None + + # WHEN: We try to get the test URL + result = get_web_page(test_url) + + # THEN: None should be returned + self.assertIsNone(result, 'The return value of get_web_page should be None') + + def test_get_web_page(self): + """ + Test that the get_web_page method works correctly + """ + with patch('openlp.core.common.httputils.urllib.request.Request') as MockRequest, \ + patch('openlp.core.common.httputils.urllib.request.urlopen') as mock_urlopen, \ + patch('openlp.core.common.httputils.get_user_agent') as mock_get_user_agent, \ + patch('openlp.core.common.Registry') as MockRegistry: + # GIVEN: Mocked out objects and a fake URL + mocked_request_object = MagicMock() + MockRequest.return_value = mocked_request_object + mocked_page_object = MagicMock() + mock_urlopen.return_value = mocked_page_object + mock_get_user_agent.return_value = 'user_agent' + fake_url = 'this://is.a.fake/url' + + # WHEN: The get_web_page() method is called + returned_page = get_web_page(fake_url) + + # THEN: The correct methods are called with the correct arguments and a web page is returned + MockRequest.assert_called_with(fake_url) + mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent') + self.assertEqual(1, mocked_request_object.add_header.call_count, + 'There should only be 1 call to add_header') + mock_get_user_agent.assert_called_with() + mock_urlopen.assert_called_with(mocked_request_object, timeout=30) + mocked_page_object.geturl.assert_called_with() + self.assertEqual(0, MockRegistry.call_count, 'The Registry() object should have never been called') + self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') + + def test_get_web_page_with_header(self): + """ + Test that adding a header to the call to get_web_page() adds the header to the request + """ + with patch('openlp.core.common.httputils.urllib.request.Request') as MockRequest, \ + patch('openlp.core.common.httputils.urllib.request.urlopen') as mock_urlopen, \ + patch('openlp.core.common.httputils.get_user_agent') as mock_get_user_agent: + # GIVEN: Mocked out objects, a fake URL and a fake header + mocked_request_object = MagicMock() + MockRequest.return_value = mocked_request_object + mocked_page_object = MagicMock() + mock_urlopen.return_value = mocked_page_object + mock_get_user_agent.return_value = 'user_agent' + fake_url = 'this://is.a.fake/url' + fake_header = ('Fake-Header', 'fake value') + + # WHEN: The get_web_page() method is called + returned_page = get_web_page(fake_url, header=fake_header) + + # THEN: The correct methods are called with the correct arguments and a web page is returned + MockRequest.assert_called_with(fake_url) + mocked_request_object.add_header.assert_called_with(fake_header[0], fake_header[1]) + self.assertEqual(2, mocked_request_object.add_header.call_count, + 'There should only be 2 calls to add_header') + mock_get_user_agent.assert_called_with() + mock_urlopen.assert_called_with(mocked_request_object, timeout=30) + mocked_page_object.geturl.assert_called_with() + self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') + + def test_get_web_page_with_user_agent_in_headers(self): + """ + Test that adding a user agent in the header when calling get_web_page() adds that user agent to the request + """ + with patch('openlp.core.common.httputils.urllib.request.Request') as MockRequest, \ + patch('openlp.core.common.httputils.urllib.request.urlopen') as mock_urlopen, \ + patch('openlp.core.common.httputils.get_user_agent') as mock_get_user_agent: + # GIVEN: Mocked out objects, a fake URL and a fake header + mocked_request_object = MagicMock() + MockRequest.return_value = mocked_request_object + mocked_page_object = MagicMock() + mock_urlopen.return_value = mocked_page_object + fake_url = 'this://is.a.fake/url' + user_agent_header = ('User-Agent', 'OpenLP/2.2.0') + + # WHEN: The get_web_page() method is called + returned_page = get_web_page(fake_url, header=user_agent_header) + + # THEN: The correct methods are called with the correct arguments and a web page is returned + MockRequest.assert_called_with(fake_url) + mocked_request_object.add_header.assert_called_with(user_agent_header[0], user_agent_header[1]) + self.assertEqual(1, mocked_request_object.add_header.call_count, + 'There should only be 1 call to add_header') + self.assertEqual(0, mock_get_user_agent.call_count, 'get_user_agent should not have been called') + mock_urlopen.assert_called_with(mocked_request_object, timeout=30) + mocked_page_object.geturl.assert_called_with() + self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') + + def test_get_web_page_update_openlp(self): + """ + Test that passing "update_openlp" as true to get_web_page calls Registry().get('app').process_events() + """ + with patch('openlp.core.common.httputils.urllib.request.Request') as MockRequest, \ + patch('openlp.core.common.httputils.urllib.request.urlopen') as mock_urlopen, \ + patch('openlp.core.common.httputils.get_user_agent') as mock_get_user_agent, \ + patch('openlp.core.common.httputils.Registry') as MockRegistry: + # GIVEN: Mocked out objects, a fake URL + mocked_request_object = MagicMock() + MockRequest.return_value = mocked_request_object + mocked_page_object = MagicMock() + mock_urlopen.return_value = mocked_page_object + mock_get_user_agent.return_value = 'user_agent' + mocked_registry_object = MagicMock() + mocked_application_object = MagicMock() + mocked_registry_object.get.return_value = mocked_application_object + MockRegistry.return_value = mocked_registry_object + fake_url = 'this://is.a.fake/url' + + # WHEN: The get_web_page() method is called + returned_page = get_web_page(fake_url, update_openlp=True) + + # THEN: The correct methods are called with the correct arguments and a web page is returned + MockRequest.assert_called_with(fake_url) + mocked_request_object.add_header.assert_called_with('User-Agent', 'user_agent') + self.assertEqual(1, mocked_request_object.add_header.call_count, + 'There should only be 1 call to add_header') + mock_urlopen.assert_called_with(mocked_request_object, timeout=30) + mocked_page_object.geturl.assert_called_with() + mocked_registry_object.get.assert_called_with('application') + mocked_application_object.process_events.assert_called_with() + self.assertEqual(mocked_page_object, returned_page, 'The returned page should be the mock object') + + def test_get_url_file_size(self): + """ + Test that passing "update_openlp" as true to get_web_page calls Registry().get('app').process_events() + """ + with patch('openlp.core.common.httputils.urllib.request.urlopen') as mock_urlopen, \ + patch('openlp.core.common.httputils.get_user_agent') as mock_get_user_agent: + # GIVEN: Mocked out objects, a fake URL + mocked_page_object = MagicMock() + mock_urlopen.return_value = mocked_page_object + mock_get_user_agent.return_value = 'user_agent' + fake_url = 'this://is.a.fake/url' + + # WHEN: The get_url_file_size() method is called + size = get_url_file_size(fake_url) + + # THEN: The correct methods are called with the correct arguments and a web page is returned + mock_urlopen.assert_called_with(fake_url, timeout=30) From a368a1d69566ce94988333534ed27088120762b2 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Tue, 20 Dec 2016 21:59:40 +0000 Subject: [PATCH 69/75] Move urg_get_file --- openlp/core/common/httputils.py | 52 +++++++++++++++++++++++++++++- openlp/core/ui/firsttimeform.py | 57 +++------------------------------ 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py index cab3ffb49..638e8a98a 100644 --- a/openlp/core/common/httputils.py +++ b/openlp/core/common/httputils.py @@ -22,7 +22,9 @@ """ The :mod:`openlp.core.utils` module provides the utility libraries for OpenLP. """ +import hashlib import logging +import os import socket import sys import time @@ -32,7 +34,7 @@ import urllib.request from http.client import HTTPException from random import randint -from openlp.core.common import Registry +from openlp.core.common import Registry, trace_error_handler log = logging.getLogger(__name__ + '.__init__') @@ -200,4 +202,52 @@ def get_url_file_size(url): continue +def url_get_file(callback, url, f_path, sha256=None): + """" + Download a file given a URL. The file is retrieved in chunks, giving the ability to cancel the download at any + point. Returns False on download error. + + :param url: URL to download + :param f_path: Destination file + """ + block_count = 0 + block_size = 4096 + retries = 0 + while True: + try: + filename = open(f_path, "wb") + url_file = urllib.request.urlopen(url, timeout=CONNECTION_TIMEOUT) + if sha256: + hasher = hashlib.sha256() + # Download until finished or canceled. + while not callback.was_cancelled: + data = url_file.read(block_size) + if not data: + break + filename.write(data) + if sha256: + hasher.update(data) + block_count += 1 + callback._download_progress(block_count, block_size) + filename.close() + if sha256 and hasher.hexdigest() != sha256: + log.error('sha256 sums did not match for file: {file}'.format(file=f_path)) + os.remove(f_path) + return False + except (urllib.error.URLError, socket.timeout) as err: + trace_error_handler(log) + filename.close() + os.remove(f_path) + if retries > CONNECTION_RETRIES: + return False + else: + retries += 1 + time.sleep(0.1) + continue + break + # Delete file if cancelled, it may be a partial file. + if callback.was_cancelled: + os.remove(f_path) + return True + __all__ = ['get_web_page'] diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index b59f31211..d331bf843 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -22,7 +22,6 @@ """ This module contains the first time wizard. """ -import hashlib import logging import os import socket @@ -39,7 +38,7 @@ from openlp.core.common import Registry, RegistryProperties, AppLocation, Settin translate, clean_button_text, trace_error_handler from openlp.core.lib import PluginStatus, build_icon from openlp.core.lib.ui import critical_error_message_box -from openlp.core.common.httputils import get_web_page, get_url_file_size, CONNECTION_RETRIES, CONNECTION_TIMEOUT +from openlp.core.common.httputils import get_web_page, get_url_file_size, url_get_file, CONNECTION_TIMEOUT from .firsttimewizard import UiFirstTimeWizard, FirstTimePage log = logging.getLogger(__name__) @@ -395,54 +394,6 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self.was_cancelled = True self.close() - def url_get_file(self, url, f_path, sha256=None): - """" - Download a file given a URL. The file is retrieved in chunks, giving the ability to cancel the download at any - point. Returns False on download error. - - :param url: URL to download - :param f_path: Destination file - """ - block_count = 0 - block_size = 4096 - retries = 0 - while True: - try: - filename = open(f_path, "wb") - url_file = urllib.request.urlopen(url, timeout=CONNECTION_TIMEOUT) - if sha256: - hasher = hashlib.sha256() - # Download until finished or canceled. - while not self.was_cancelled: - data = url_file.read(block_size) - if not data: - break - filename.write(data) - if sha256: - hasher.update(data) - block_count += 1 - self._download_progress(block_count, block_size) - filename.close() - if sha256 and hasher.hexdigest() != sha256: - log.error('sha256 sums did not match for file: {file}'.format(file=f_path)) - os.remove(f_path) - return False - except (urllib.error.URLError, socket.timeout) as err: - trace_error_handler(log) - filename.close() - os.remove(f_path) - if retries > CONNECTION_RETRIES: - return False - else: - retries += 1 - time.sleep(0.1) - continue - break - # Delete file if cancelled, it may be a partial file. - if self.was_cancelled: - os.remove(f_path) - return True - def _build_theme_screenshots(self): """ This method builds the theme screenshots' icons for all items in the ``self.themes_list_widget``. @@ -616,7 +567,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self._increment_progress_bar(self.downloading.format(name=filename), 0) self.previous_size = 0 destination = os.path.join(songs_destination, str(filename)) - if not self.url_get_file('{path}{name}'.format(path=self.songs_url, name=filename), + if not url_get_file(self, '{path}{name}'.format(path=self.songs_url, name=filename), destination, sha256): missed_files.append('Song: {name}'.format(name=filename)) # Download Bibles @@ -628,7 +579,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): # TODO: Tested at home self._increment_progress_bar(self.downloading.format(name=bible), 0) self.previous_size = 0 - if not self.url_get_file('{path}{name}'.format(path=self.bibles_url, name=bible), + if not url_get_file(self, '{path}{name}'.format(path=self.bibles_url, name=bible), os.path.join(bibles_destination, bible), sha256): missed_files.append('Bible: {name}'.format(name=bible)) @@ -643,7 +594,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self.previous_size = 0 if not self.url_get_file('{path}{name}'.format(path=self.themes_url, name=theme), os.path.join(themes_destination, theme), - sha256): + sha256, self): missed_files.append('Theme: {name}'.format(name=theme)) if missed_files: file_list = '' From 8570fb5e8cf50aeb37a63870af7bdc6abed03246 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Wed, 21 Dec 2016 09:41:57 +0000 Subject: [PATCH 70/75] Fix tests --- openlp/core/common/httputils.py | 2 ++ .../openlp_core_common/test_httputils.py | 19 ++++++++++++++++++- .../openlp_core_ui/test_firsttimeform.py | 18 ------------------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py index 638e8a98a..b3cb50ef3 100644 --- a/openlp/core/common/httputils.py +++ b/openlp/core/common/httputils.py @@ -207,8 +207,10 @@ def url_get_file(callback, url, f_path, sha256=None): Download a file given a URL. The file is retrieved in chunks, giving the ability to cancel the download at any point. Returns False on download error. + :param callback: the class which needs to be updated :param url: URL to download :param f_path: Destination file + :param sha256: The check sum value to be checked against the download value """ block_count = 0 block_size = 4096 diff --git a/tests/functional/openlp_core_common/test_httputils.py b/tests/functional/openlp_core_common/test_httputils.py index 7a8d478c5..8709e9cfb 100644 --- a/tests/functional/openlp_core_common/test_httputils.py +++ b/tests/functional/openlp_core_common/test_httputils.py @@ -22,9 +22,11 @@ """ Functional tests to test the AppLocation class and related methods. """ +import socket +import os from unittest import TestCase -from openlp.core.common.httputils import get_user_agent, get_web_page, get_url_file_size +from openlp.core.common.httputils import get_user_agent, get_web_page, get_url_file_size, url_get_file from tests.functional import MagicMock, patch @@ -245,3 +247,18 @@ class TestHttpUtils(TestCase): # THEN: The correct methods are called with the correct arguments and a web page is returned mock_urlopen.assert_called_with(fake_url, timeout=30) + + @patch('openlp.core.ui.firsttimeform.urllib.request.urlopen') + def test_socket_timeout(self, mocked_urlopen): + """ + Test socket timeout gets caught + """ + # GIVEN: Mocked urlopen to fake a network disconnect in the middle of a download + mocked_urlopen.side_effect = socket.timeout() + + # WHEN: Attempt to retrieve a file + url_get_file(url='http://localhost/test', f_path=self.tempfile) + + # THEN: socket.timeout should have been caught + # NOTE: Test is if $tmpdir/tempfile is still there, then test fails since ftw deletes bad downloaded files + self.assertFalse(os.path.exists(self.tempfile), 'FTW url_get_file should have caught socket.timeout') \ No newline at end of file diff --git a/tests/functional/openlp_core_ui/test_firsttimeform.py b/tests/functional/openlp_core_ui/test_firsttimeform.py index 5dd1430cd..ec26f60fe 100644 --- a/tests/functional/openlp_core_ui/test_firsttimeform.py +++ b/tests/functional/openlp_core_ui/test_firsttimeform.py @@ -23,7 +23,6 @@ Package to test the openlp.core.ui.firsttimeform package. """ import os -import socket import tempfile import urllib from unittest import TestCase @@ -236,20 +235,3 @@ class TestFirstTimeForm(TestCase, TestMixin): # THEN: the critical_error_message_box should have been called self.assertEquals(mocked_message_box.mock_calls[1][1][0], 'Network Error 407', 'first_time_form should have caught Network Error') - - @patch('openlp.core.ui.firsttimeform.urllib.request.urlopen') - def test_socket_timeout(self, mocked_urlopen): - """ - Test socket timeout gets caught - """ - # GIVEN: Mocked urlopen to fake a network disconnect in the middle of a download - first_time_form = FirstTimeForm(None) - first_time_form.initialize(MagicMock()) - mocked_urlopen.side_effect = socket.timeout() - - # WHEN: Attempt to retrieve a file - first_time_form.url_get_file(url='http://localhost/test', f_path=self.tempfile) - - # THEN: socket.timeout should have been caught - # NOTE: Test is if $tmpdir/tempfile is still there, then test fails since ftw deletes bad downloaded files - self.assertFalse(os.path.exists(self.tempfile), 'FTW url_get_file should have caught socket.timeout') From a69df60978644d47365f7d98e0db059377c288ea Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Wed, 21 Dec 2016 09:47:33 +0000 Subject: [PATCH 71/75] Fix tests again --- .../openlp_core_common/test_httputils.py | 18 ++++++++++++++---- .../openlp_core_ui/test_firsttimeform.py | 3 ++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/functional/openlp_core_common/test_httputils.py b/tests/functional/openlp_core_common/test_httputils.py index 8709e9cfb..6bf7aaa4c 100644 --- a/tests/functional/openlp_core_common/test_httputils.py +++ b/tests/functional/openlp_core_common/test_httputils.py @@ -22,19 +22,29 @@ """ Functional tests to test the AppLocation class and related methods. """ -import socket import os +import tempfile +import socket from unittest import TestCase from openlp.core.common.httputils import get_user_agent, get_web_page, get_url_file_size, url_get_file from tests.functional import MagicMock, patch +from tests.helpers.testmixin import TestMixin -class TestHttpUtils(TestCase): +class TestHttpUtils(TestCase, TestMixin): + """ - A test suite to test out various methods around the AppLocation class. + A test suite to test out various http helper functions. """ + def setUp(self): + self.tempfile = os.path.join(tempfile.gettempdir(), 'testfile') + + def tearDown(self): + if os.path.isfile(self.tempfile): + os.remove(self.tempfile) + def test_get_user_agent_linux(self): """ Test that getting a user agent on Linux returns a user agent suitable for Linux @@ -257,7 +267,7 @@ class TestHttpUtils(TestCase): mocked_urlopen.side_effect = socket.timeout() # WHEN: Attempt to retrieve a file - url_get_file(url='http://localhost/test', f_path=self.tempfile) + url_get_file(MagicMock(), url='http://localhost/test', f_path=self.tempfile) # THEN: socket.timeout should have been caught # NOTE: Test is if $tmpdir/tempfile is still there, then test fails since ftw deletes bad downloaded files diff --git a/tests/functional/openlp_core_ui/test_firsttimeform.py b/tests/functional/openlp_core_ui/test_firsttimeform.py index ec26f60fe..ec77a3134 100644 --- a/tests/functional/openlp_core_ui/test_firsttimeform.py +++ b/tests/functional/openlp_core_ui/test_firsttimeform.py @@ -224,7 +224,8 @@ class TestFirstTimeForm(TestCase, TestMixin): # GIVEN: Initial setup and mocks first_time_form = FirstTimeForm(None) first_time_form.initialize(MagicMock()) - mocked_get_web_page.side_effect = urllib.error.HTTPError(url='http//localhost', + mocked_get_web_page.side_effect = urllib.error.HTTPError(MagicMock(), + url='http//localhost', code=407, msg='Network proxy error', hdrs=None, From 5896e053bc5c5705fff8b58e6058052f10f027fa Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Wed, 21 Dec 2016 09:52:05 +0000 Subject: [PATCH 72/75] Fix tests again --- tests/functional/openlp_core_ui/test_firsttimeform.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/functional/openlp_core_ui/test_firsttimeform.py b/tests/functional/openlp_core_ui/test_firsttimeform.py index ec77a3134..ec26f60fe 100644 --- a/tests/functional/openlp_core_ui/test_firsttimeform.py +++ b/tests/functional/openlp_core_ui/test_firsttimeform.py @@ -224,8 +224,7 @@ class TestFirstTimeForm(TestCase, TestMixin): # GIVEN: Initial setup and mocks first_time_form = FirstTimeForm(None) first_time_form.initialize(MagicMock()) - mocked_get_web_page.side_effect = urllib.error.HTTPError(MagicMock(), - url='http//localhost', + mocked_get_web_page.side_effect = urllib.error.HTTPError(url='http//localhost', code=407, msg='Network proxy error', hdrs=None, From 7bd645bc7212fc83dfe9781eda289cd2126864c3 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Wed, 21 Dec 2016 10:00:14 +0000 Subject: [PATCH 73/75] pep8 --- openlp/core/ui/firsttimeform.py | 6 +++--- tests/functional/openlp_core_common/test_httputils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index d331bf843..10456b979 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -568,7 +568,7 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self.previous_size = 0 destination = os.path.join(songs_destination, str(filename)) if not url_get_file(self, '{path}{name}'.format(path=self.songs_url, name=filename), - destination, sha256): + destination, sha256): missed_files.append('Song: {name}'.format(name=filename)) # Download Bibles bibles_iterator = QtWidgets.QTreeWidgetItemIterator(self.bibles_tree_widget) @@ -580,8 +580,8 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): self._increment_progress_bar(self.downloading.format(name=bible), 0) self.previous_size = 0 if not url_get_file(self, '{path}{name}'.format(path=self.bibles_url, name=bible), - os.path.join(bibles_destination, bible), - sha256): + os.path.join(bibles_destination, bible), + sha256): missed_files.append('Bible: {name}'.format(name=bible)) bibles_iterator += 1 # Download themes diff --git a/tests/functional/openlp_core_common/test_httputils.py b/tests/functional/openlp_core_common/test_httputils.py index 6bf7aaa4c..98b24a994 100644 --- a/tests/functional/openlp_core_common/test_httputils.py +++ b/tests/functional/openlp_core_common/test_httputils.py @@ -271,4 +271,4 @@ class TestHttpUtils(TestCase, TestMixin): # THEN: socket.timeout should have been caught # NOTE: Test is if $tmpdir/tempfile is still there, then test fails since ftw deletes bad downloaded files - self.assertFalse(os.path.exists(self.tempfile), 'FTW url_get_file should have caught socket.timeout') \ No newline at end of file + self.assertFalse(os.path.exists(self.tempfile), 'FTW url_get_file should have caught socket.timeout') From 7323b3797d222391b0ac3b6b022f3ee82fc6f944 Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Wed, 21 Dec 2016 10:20:35 +0000 Subject: [PATCH 74/75] missed one --- openlp/core/ui/firsttimeform.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index 10456b979..3f56f4089 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -592,9 +592,9 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): # TODO: Tested at home self._increment_progress_bar(self.downloading.format(name=theme), 0) self.previous_size = 0 - if not self.url_get_file('{path}{name}'.format(path=self.themes_url, name=theme), + if not self.url_get_file(self, '{path}{name}'.format(path=self.themes_url, name=theme), os.path.join(themes_destination, theme), - sha256, self): + sha256): missed_files.append('Theme: {name}'.format(name=theme)) if missed_files: file_list = '' From 0643ecdd103e3a13a7506399a663008e947eef0d Mon Sep 17 00:00:00 2001 From: Tim Bentley Date: Wed, 21 Dec 2016 12:46:35 +0000 Subject: [PATCH 75/75] Remove extra self --- openlp/core/ui/firsttimeform.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openlp/core/ui/firsttimeform.py b/openlp/core/ui/firsttimeform.py index 3f56f4089..8fd7a4f52 100644 --- a/openlp/core/ui/firsttimeform.py +++ b/openlp/core/ui/firsttimeform.py @@ -592,9 +592,9 @@ class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties): # TODO: Tested at home self._increment_progress_bar(self.downloading.format(name=theme), 0) self.previous_size = 0 - if not self.url_get_file(self, '{path}{name}'.format(path=self.themes_url, name=theme), - os.path.join(themes_destination, theme), - sha256): + if not url_get_file(self, '{path}{name}'.format(path=self.themes_url, name=theme), + os.path.join(themes_destination, theme), + sha256): missed_files.append('Theme: {name}'.format(name=theme)) if missed_files: file_list = ''