From 483f14ec1592e55448c56040bc31c47364477e00 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 6 Aug 2020 02:26:56 +0000 Subject: [PATCH] working webview implementation of the ccli importer Still a WIP: - I'll add the old preview thing - Need to make default size of the window larger - Need to make UI look better --- .../plugins/songs/forms/songselectdialog.py | 215 +-- openlp/plugins/songs/forms/songselectform.py | 391 ++---- openlp/plugins/songs/forms/webengine.py | 50 + openlp/plugins/songs/lib/songselect.py | 283 ++-- openlp/plugins/songs/lib/songstab.py | 37 + .../openlp_plugins/songs/test_songselect.py | 1148 +++++++++-------- .../openlp_plugins/songs/test_songstab.py | 38 +- 7 files changed, 968 insertions(+), 1194 deletions(-) create mode 100644 openlp/plugins/songs/forms/webengine.py diff --git a/openlp/plugins/songs/forms/songselectdialog.py b/openlp/plugins/songs/forms/songselectdialog.py index 5a249de50..290c82300 100644 --- a/openlp/plugins/songs/forms/songselectdialog.py +++ b/openlp/plugins/songs/forms/songselectdialog.py @@ -27,7 +27,7 @@ from PyQt5 import QtCore, QtWidgets from openlp.core.common.i18n import translate from openlp.core.ui import SingleColumnTableWidget from openlp.core.ui.icons import UiIcons -from openlp.core.widgets.edits import HistoryComboBox +from openlp.plugins.songs.forms.webengine import WebEngineView class Ui_SongSelectDialog(object): @@ -36,122 +36,23 @@ class Ui_SongSelectDialog(object): """ def setup_ui(self, songselect_dialog): songselect_dialog.setObjectName('songselect_dialog') - songselect_dialog.resize(616, 378) + songselect_dialog.resize(800, 600) self.songselect_layout = QtWidgets.QVBoxLayout(songselect_dialog) - self.songselect_layout.setSpacing(0) - self.songselect_layout.setContentsMargins(0, 0, 0, 0) + self.songselect_layout.setSpacing(8) + self.songselect_layout.setContentsMargins(8, 8, 8, 8) self.songselect_layout.setObjectName('songselect_layout') self.stacked_widget = QtWidgets.QStackedWidget(songselect_dialog) self.stacked_widget.setObjectName('stacked_widget') - self.login_page = QtWidgets.QWidget() - self.login_page.setObjectName('login_page') - self.login_layout = QtWidgets.QFormLayout(self.login_page) - self.login_layout.setContentsMargins(120, 100, 120, 100) - self.login_layout.setSpacing(8) - self.login_layout.setObjectName('login_layout') - self.notice_layout = QtWidgets.QHBoxLayout() - self.notice_layout.setObjectName('notice_layout') - self.notice_label = QtWidgets.QLabel(self.login_page) - self.notice_label.setWordWrap(True) - self.notice_label.setObjectName('notice_label') - self.notice_layout.addWidget(self.notice_label) - self.login_layout.setLayout(0, QtWidgets.QFormLayout.SpanningRole, self.notice_layout) - self.username_label = QtWidgets.QLabel(self.login_page) - self.username_label.setObjectName('usernameLabel') - self.login_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.username_label) - self.username_edit = QtWidgets.QLineEdit(self.login_page) - self.username_edit.setObjectName('usernameEdit') - self.login_layout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.username_edit) - self.password_label = QtWidgets.QLabel(self.login_page) - self.password_label.setObjectName('passwordLabel') - self.login_layout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.password_label) - self.password_edit = QtWidgets.QLineEdit(self.login_page) - self.password_edit.setEchoMode(QtWidgets.QLineEdit.Password) - self.password_edit.setObjectName('passwordEdit') - self.login_layout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.password_edit) - self.save_password_checkbox = QtWidgets.QCheckBox(self.login_page) - self.save_password_checkbox.setTristate(False) - self.save_password_checkbox.setObjectName('save_password_checkbox') - self.login_layout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.save_password_checkbox) - self.login_button_layout = QtWidgets.QHBoxLayout() - self.login_button_layout.setSpacing(8) - self.login_button_layout.setContentsMargins(0, -1, -1, -1) - self.login_button_layout.setObjectName('login_button_layout') - self.login_spacer = QtWidgets.QWidget(self.login_page) - self.login_spacer.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.login_spacer.setObjectName('login_spacer') - self.login_button_layout.addWidget(self.login_spacer) - self.login_progress_bar = QtWidgets.QProgressBar(self.login_page) - self.login_progress_bar.setMinimum(0) - self.login_progress_bar.setMaximum(3) - self.login_progress_bar.setValue(0) - self.login_progress_bar.setMinimumWidth(200) - self.login_progress_bar.setVisible(False) - self.login_button_layout.addWidget(self.login_progress_bar) - self.login_button = QtWidgets.QPushButton(self.login_page) - self.login_button.setIcon(UiIcons().edit) - self.login_button.setObjectName('login_button') - self.login_button_layout.addWidget(self.login_button) - self.login_layout.setLayout(4, QtWidgets.QFormLayout.SpanningRole, self.login_button_layout) - self.stacked_widget.addWidget(self.login_page) - self.search_page = QtWidgets.QWidget() - self.search_page.setObjectName('search_page') - self.search_layout = QtWidgets.QVBoxLayout(self.search_page) - self.search_layout.setSpacing(8) - self.search_layout.setContentsMargins(8, 8, 8, 8) - self.search_layout.setObjectName('search_layout') - self.search_input_layout = QtWidgets.QHBoxLayout() - self.search_input_layout.setSpacing(8) - self.search_input_layout.setObjectName('search_input_layout') - self.search_label = QtWidgets.QLabel(self.search_page) - self.search_label.setObjectName('search_label') - self.search_input_layout.addWidget(self.search_label) - self.search_combobox = HistoryComboBox(self.search_page) - self.search_combobox.setObjectName('search_combobox') - self.search_input_layout.addWidget(self.search_combobox) - self.search_button = QtWidgets.QPushButton(self.search_page) - self.search_button.setIcon(UiIcons().search) - self.search_button.setObjectName('search_button') - self.search_input_layout.addWidget(self.search_button) - self.search_layout.addLayout(self.search_input_layout) - self.search_progress_layout = QtWidgets.QHBoxLayout() - self.search_progress_layout.setSpacing(8) - self.search_progress_layout.setObjectName('search_progress_layout') - self.search_progress_bar = QtWidgets.QProgressBar(self.search_page) - self.search_progress_bar.setMinimum(0) - self.search_progress_bar.setMaximum(3) - self.search_progress_bar.setValue(0) - self.search_progress_layout.addWidget(self.search_progress_bar) - self.stop_button = QtWidgets.QPushButton(self.search_page) - self.stop_button.setIcon(UiIcons().stop) - self.stop_button.setObjectName('stop_button') - self.search_progress_layout.addWidget(self.stop_button) - self.search_layout.addLayout(self.search_progress_layout) - self.search_results_widget = QtWidgets.QListWidget(self.search_page) - self.search_results_widget.setProperty("showDropIndicator", False) - self.search_results_widget.setAlternatingRowColors(True) - self.search_results_widget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.search_results_widget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.search_results_widget.setObjectName('search_results_widget') - self.search_layout.addWidget(self.search_results_widget) - self.result_count_label = QtWidgets.QLabel(self.search_page) - self.result_count_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignCenter) - self.result_count_label.setObjectName('result_count_label') - self.search_layout.addWidget(self.result_count_label) - self.view_layout = QtWidgets.QHBoxLayout() - self.view_layout.setSpacing(8) - self.view_layout.setObjectName('view_layout') - self.logout_button = QtWidgets.QPushButton(self.search_page) - self.logout_button.setIcon(UiIcons().edit) - self.view_layout.addWidget(self.logout_button) - self.view_spacer = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.view_layout.addItem(self.view_spacer) - self.view_button = QtWidgets.QPushButton(self.search_page) - self.view_button.setIcon(UiIcons().search) - self.view_button.setObjectName('view_button') - self.view_layout.addWidget(self.view_button) - self.search_layout.addLayout(self.view_layout) - self.stacked_widget.addWidget(self.search_page) + # Webview page + self.webview_page = QtWidgets.QWidget() + self.webview_page.setObjectName('webview_page') + self.webview_layout = QtWidgets.QGridLayout(self.webview_page) + self.webview_layout.setObjectName('webview_layout') + self.webview_layout.setContentsMargins(0, 0, 0, 0) + self.webview = WebEngineView(self) + self.webview_layout.addWidget(self.webview, 1, 0, 3, 1) + self.stacked_widget.addWidget(self.webview_page) + # Song page self.song_page = QtWidgets.QWidget() self.song_page.setObjectName('song_page') self.song_layout = QtWidgets.QGridLayout(self.song_page) @@ -193,36 +94,54 @@ class Ui_SongSelectDialog(object): self.lyrics_table_widget.setObjectName('lyrics_table_widget') self.lyrics_table_widget.setRowCount(0) self.song_layout.addWidget(self.lyrics_table_widget, 3, 1, 1, 3) - self.song_progress_bar = QtWidgets.QProgressBar(self.song_page) - self.song_progress_bar.setMinimum(0) - self.song_progress_bar.setMaximum(3) - self.song_progress_bar.setValue(0) - self.song_progress_bar.setVisible(False) - self.song_layout.addWidget(self.song_progress_bar, 4, 0, 1, 4) - self.import_layout = QtWidgets.QHBoxLayout() - self.import_layout.setObjectName('import_layout') - self.back_button = QtWidgets.QPushButton(self.song_page) - self.back_button.setIcon(UiIcons().back) - self.back_button.setObjectName('back_button') - self.import_layout.addWidget(self.back_button) - self.import_spacer = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Minimum) - self.import_layout.addItem(self.import_spacer) - self.import_button = QtWidgets.QPushButton(self.song_page) - self.import_button.setIcon(UiIcons().download) - self.import_button.setObjectName('import_button') - self.import_layout.addWidget(self.import_button) - self.song_layout.addLayout(self.import_layout, 5, 0, 1, 5) - self.stacked_widget.addWidget(self.song_page) - self.songselect_layout.addWidget(self.stacked_widget) - self.username_label.setBuddy(self.username_edit) - self.password_label.setBuddy(self.password_edit) self.title_label.setBuddy(self.title_edit) self.authors_label.setBuddy(self.author_list_widget) self.copyright_label.setBuddy(self.copyright_edit) self.ccli_label.setBuddy(self.ccli_edit) self.lyrics_label.setBuddy(self.lyrics_table_widget) - + self.stacked_widget.addWidget(self.song_page) + # The top panel + self.top_button_layout = QtWidgets.QGridLayout() + self.top_button_layout.setContentsMargins(0, 0, 0, 0) + self.top_button_layout.setSpacing(8) + self.top_button_layout.setObjectName('top_button_layout') + self.back_button = QtWidgets.QPushButton(songselect_dialog) + self.back_button.setIcon(UiIcons().back) + self.back_button.setObjectName('back_button') + self.top_button_layout.addWidget(self.back_button, 0, 0, 1, 1) + self.url_bar = QtWidgets.QLineEdit(songselect_dialog) + self.url_bar.setObjectName('ccli_edit') + self.top_button_layout.addWidget(self.url_bar, 0, 1, 1, 8) + self.song_progress_bar = QtWidgets.QProgressBar(songselect_dialog) + self.song_progress_bar.setMinimum(0) + self.song_progress_bar.setMaximum(3) + self.song_progress_bar.setValue(0) + self.song_progress_bar.setVisible(False) + self.top_button_layout.addWidget(self.song_progress_bar, 0, 1, 1, 8) + # The bottom panel + self.bottom_button_layout = QtWidgets.QGridLayout() + self.bottom_button_layout.setContentsMargins(0, 0, 0, 0) + self.bottom_button_layout.setSpacing(8) + self.bottom_button_layout.setObjectName('bottom_button_layout') + self.close_button = QtWidgets.QPushButton(songselect_dialog) + self.close_button.setIcon(UiIcons().close) + self.close_button.setObjectName('close_button') + self.bottom_button_layout.addWidget(self.close_button, 0, 0, 1, 1) + self.spacer = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Minimum) + self.bottom_button_layout.addItem(self.spacer, 0, 1, 1, 7) + self.view_button = QtWidgets.QPushButton(songselect_dialog) + self.view_button.setIcon(UiIcons().search) + self.view_button.setObjectName('view_button') + self.bottom_button_layout.addWidget(self.view_button, 0, 8, 1, 1) + self.import_button = QtWidgets.QPushButton(songselect_dialog) + self.import_button.setIcon(UiIcons().download) + self.import_button.setObjectName('import_button') + self.bottom_button_layout.addWidget(self.import_button, 0, 9, 1, 1) + # Add everything to the interface layout + self.songselect_layout.addLayout(self.top_button_layout) + self.songselect_layout.addWidget(self.stacked_widget) + self.songselect_layout.addLayout(self.bottom_button_layout) self.retranslate_ui(songselect_dialog) self.stacked_widget.setCurrentIndex(0) @@ -231,25 +150,13 @@ class Ui_SongSelectDialog(object): Translate the GUI. """ songselect_dialog.setWindowTitle(translate('SongsPlugin.SongSelectForm', 'CCLI SongSelect Importer')) - self.notice_label.setText( - translate('SongsPlugin.SongSelectForm', 'Note: ' - 'An Internet connection is required in order to import songs from CCLI SongSelect.') - ) - self.username_label.setText(translate('SongsPlugin.SongSelectForm', 'Username:')) - self.password_label.setText(translate('SongsPlugin.SongSelectForm', 'Password:')) - self.save_password_checkbox.setText(translate('SongsPlugin.SongSelectForm', 'Save username and password')) - self.login_button.setText(translate('SongsPlugin.SongSelectForm', 'Login')) - self.search_label.setText(translate('SongsPlugin.SongSelectForm', 'Search Text:')) - self.search_button.setText(translate('SongsPlugin.SongSelectForm', 'Search')) - self.stop_button.setText(translate('SongsPlugin.SongSelectForm', 'Stop')) - self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', - 'Found {count:d} song(s)').format(count=0)) - self.logout_button.setText(translate('SongsPlugin.SongSelectForm', 'Logout')) - self.view_button.setText(translate('SongsPlugin.SongSelectForm', 'View')) + self.view_button.setText(translate('SongsPlugin.SongSelectForm', 'Preview')) self.title_label.setText(translate('SongsPlugin.SongSelectForm', 'Title:')) self.authors_label.setText(translate('SongsPlugin.SongSelectForm', 'Author(s):')) self.copyright_label.setText(translate('SongsPlugin.SongSelectForm', 'Copyright:')) self.ccli_label.setText(translate('SongsPlugin.SongSelectForm', 'CCLI Number:')) self.lyrics_label.setText(translate('SongsPlugin.SongSelectForm', 'Lyrics:')) - self.back_button.setText(translate('SongsPlugin.SongSelectForm', 'Back')) + self.back_button.setText(translate('SongsPlugin.SongSelectForm', 'Back', + 'When pressed takes user to the CCLI home page')) self.import_button.setText(translate('SongsPlugin.SongSelectForm', 'Import')) + self.close_button.setText(translate('SongsPlugin.SongSelectForm', 'Close')) diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py index fb12e8477..a4b9ddde7 100644 --- a/openlp/plugins/songs/forms/songselectform.py +++ b/openlp/plugins/songs/forms/songselectform.py @@ -22,56 +22,20 @@ The :mod:`~openlp.plugins.songs.forms.songselectform` module contains the GUI for the SongSelect importer """ import logging -from time import sleep from PyQt5 import QtCore, QtWidgets +from sqlalchemy.sql import and_ from openlp.core.common.i18n import translate from openlp.core.common.mixins import RegistryProperties -from openlp.core.threading import ThreadWorker, run_thread from openlp.plugins.songs.forms.songselectdialog import Ui_SongSelectDialog -from openlp.plugins.songs.lib.songselect import SongSelectImport +from openlp.plugins.songs.lib.db import Song +from openlp.plugins.songs.lib.songselect import SongSelectImport, Pages log = logging.getLogger(__name__) -class SearchWorker(ThreadWorker): - """ - Run the actual SongSelect search, and notify the GUI when we find each song. - """ - show_info = QtCore.pyqtSignal(str, str) - found_song = QtCore.pyqtSignal(dict) - finished = QtCore.pyqtSignal() - - def __init__(self, importer, search_text): - super().__init__() - self.importer = importer - self.search_text = search_text - - def start(self): - """ - Run a search and then parse the results page of the search. - """ - songs = self.importer.search(self.search_text, 1000, self._found_song_callback) - if len(songs) >= 1000: - self.show_info.emit( - translate('SongsPlugin.SongSelectForm', 'More than 1000 results'), - translate('SongsPlugin.SongSelectForm', 'Your search has returned more than 1000 results, it has ' - 'been stopped. Please refine your search to fetch better ' - 'results.')) - self.finished.emit() - self.quit.emit() - - def _found_song_callback(self, song): - """ - A callback used by the paginate function to notify watching processes when it finds a song. - - :param song: The song that was found - """ - self.found_song.emit(song) - - class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties): """ The :class:`SongSelectForm` class is the SongSelect dialog. @@ -88,43 +52,26 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties) """ Initialise the SongSelectForm """ - self.song_count = 0 self.song = None - self.set_progress_visible(False) - self.song_select_importer = SongSelectImport(self.db_manager) - self.save_password_checkbox.toggled.connect(self.on_save_password_checkbox_toggled) - self.login_button.clicked.connect(self.on_login_button_clicked) - self.search_button.clicked.connect(self.on_search_button_clicked) - self.search_combobox.returnPressed.connect(self.on_search_button_clicked) - self.stop_button.clicked.connect(self.on_stop_button_clicked) - self.logout_button.clicked.connect(self.done) - self.search_results_widget.itemDoubleClicked.connect(self.on_search_results_widget_double_clicked) - self.search_results_widget.itemSelectionChanged.connect(self.on_search_results_widget_selection_changed) + self.song_select_importer = SongSelectImport(self.db_manager, self.webview) + self.url_bar.returnPressed.connect(self.on_url_bar_return_pressed) self.view_button.clicked.connect(self.on_view_button_clicked) self.back_button.clicked.connect(self.on_back_button_clicked) + self.close_button.clicked.connect(self.done) self.import_button.clicked.connect(self.on_import_button_clicked) + self.webview.page().loadStarted.connect(self.page_load_started) + self.webview.page().loadFinished.connect(self.page_loaded) def exec(self): """ Execute the dialog. This method sets everything back to its initial values. """ - self.stacked_widget.setCurrentIndex(0) - self.username_edit.setEnabled(True) - self.password_edit.setEnabled(True) - self.save_password_checkbox.setEnabled(True) - self.search_combobox.clearEditText() - self.search_combobox.clear() - self.search_results_widget.clear() + self.song_select_importer.reset_webview() self.view_button.setEnabled(False) - if self.settings.contains('songs/songselect password'): - self.username_edit.setText(self.settings.value('songs/songselect username')) - self.password_edit.setText(self.settings.value('songs/songselect password')) - self.save_password_checkbox.setChecked(True) - if self.settings.contains('songs/songselect searches'): - self.search_combobox.addItems( - self.settings.value('songs/songselect searches').split('|')) - self.username_edit.setFocus() + self.back_button.setEnabled(False) + self.import_button.setEnabled(False) + self.stacked_widget.setCurrentIndex(0) return QtWidgets.QDialog.exec(self) def done(self, result_code): @@ -133,279 +80,127 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties) :param result_code: The result of the dialog. """ - log.debug('Closing SongSelectForm') - if self.stacked_widget.currentIndex() > 0: - progress_dialog = QtWidgets.QProgressDialog( - translate('SongsPlugin.SongSelectForm', 'Logging out...'), '', 0, 2, self) - progress_dialog.setWindowModality(QtCore.Qt.WindowModal) - progress_dialog.setCancelButton(None) - progress_dialog.setValue(1) - progress_dialog.show() - progress_dialog.setFocus() - self.application.process_events() - sleep(0.5) - self.application.process_events() - self.song_select_importer.logout() - self.application.process_events() - progress_dialog.setValue(2) return QtWidgets.QDialog.done(self, result_code) - def _update_login_progress(self): - """ - Update the progress bar as the user logs in. - """ - self.login_progress_bar.setValue(self.login_progress_bar.value() + 1) - self.application.process_events() + def page_load_started(self): + self.song_progress_bar.setMaximum(0) + self.song_progress_bar.setValue(0) + self.song_progress_bar.setVisible(True) + self.url_bar.setVisible(False) + self.import_button.setEnabled(False) + self.view_button.setEnabled(False) + self.back_button.setEnabled(False) + + def page_loaded(self, successful): + self.song = None + page_type = self.song_select_importer.get_page_type() + if page_type == Pages.Login: + self.signin_page_loaded() + elif page_type == Pages.Song: + self.song_progress_bar.setMaximum(3) + self.song_progress_bar.setValue(0) + self.song = self.song_select_importer.get_song(self._update_song_progress) + self.import_button.setEnabled(True) + self.view_button.setEnabled(True) + self.back_button.setEnabled(True) + if page_type == Pages.Other: + self.back_button.setEnabled(True) + self.song_progress_bar.setVisible(False) + self.url_bar.setText(self.webview.url().toString()) + self.url_bar.setCursorPosition(0) + self.url_bar.setVisible(True) + + def signin_page_loaded(self): + username = self.settings.value('songs/songselect username') + password = self.settings.value('songs/songselect password') + self.song_select_importer.set_login_fields(username, password) def _update_song_progress(self): """ - Update the progress bar as the song is being downloaded. + Update the progress bar. """ self.song_progress_bar.setValue(self.song_progress_bar.value() + 1) self.application.process_events() - def _view_song(self, current_item): + def _view_song(self): """ Load a song into the song view. """ - if not current_item: - return - else: - current_item = current_item.data(QtCore.Qt.UserRole) - # Stop the current search, if it's running - self.song_select_importer.stop() - # Clear up the UI - self.song_progress_bar.setVisible(True) - self.import_button.setEnabled(False) - self.back_button.setEnabled(False) - self.title_edit.setText('') - self.title_edit.setEnabled(False) - self.copyright_edit.setText('') - self.copyright_edit.setEnabled(False) - self.ccli_edit.setText('') - self.ccli_edit.setEnabled(False) - self.author_list_widget.clear() - self.author_list_widget.setEnabled(False) - self.lyrics_table_widget.clear() - self.lyrics_table_widget.setRowCount(0) - self.lyrics_table_widget.setEnabled(False) - self.stacked_widget.setCurrentIndex(2) - song = {} - for key, value in current_item.items(): - song[key] = value - self.song_progress_bar.setValue(0) - self.application.process_events() - # Get the full song - song = self.song_select_importer.get_song(song, self._update_song_progress) - if not song: + if not self.song: QtWidgets.QMessageBox.critical( self, translate('SongsPlugin.SongSelectForm', 'Incomplete song'), translate('SongsPlugin.SongSelectForm', 'This song is missing some information, like the lyrics, ' 'and cannot be imported.'), QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok), QtWidgets.QMessageBox.Ok) - self.stacked_widget.setCurrentIndex(1) return + # Clear up the UI + self.author_list_widget.clear() + self.lyrics_table_widget.clear() + self.lyrics_table_widget.setRowCount(0) # Update the UI - self.title_edit.setText(song['title']) - self.copyright_edit.setText(song['copyright']) - self.ccli_edit.setText(song['ccli_number']) - for author in song['authors']: - QtWidgets.QListWidgetItem(author, self.author_list_widget) - for counter, verse in enumerate(song['verses']): + self.title_edit.setText(self.song['title']) + self.copyright_edit.setText(self.song['copyright']) + self.ccli_edit.setText(self.song['ccli_number']) + for author in self.song['authors']: + self.author_list_widget.addItem(QtWidgets.QListWidgetItem(author, self.author_list_widget)) + for counter, verse in enumerate(self.song['verses']): self.lyrics_table_widget.setRowCount(self.lyrics_table_widget.rowCount() + 1) item = QtWidgets.QTableWidgetItem(verse['lyrics']) item.setData(QtCore.Qt.UserRole, verse['label']) item.setFlags(item.flags() ^ QtCore.Qt.ItemIsEditable) self.lyrics_table_widget.setItem(counter, 0, item) - self.lyrics_table_widget.setVerticalHeaderLabels([verse['label'] for verse in song['verses']]) + self.lyrics_table_widget.setVerticalHeaderLabels([verse['label'] for verse in self.song['verses']]) self.lyrics_table_widget.resizeRowsToContents() - self.title_edit.setEnabled(True) - self.copyright_edit.setEnabled(True) - self.ccli_edit.setEnabled(True) - self.author_list_widget.setEnabled(True) - self.lyrics_table_widget.setEnabled(True) - self.lyrics_table_widget.repaint() - self.import_button.setEnabled(True) - self.back_button.setEnabled(True) - self.song_progress_bar.setVisible(False) - self.song_progress_bar.setValue(0) - self.song = song - self.application.process_events() + self.lyrics_table_widget.scrollToTop() + self.stacked_widget.setCurrentIndex(1) - def on_save_password_checkbox_toggled(self, checked): + def on_url_bar_return_pressed(self): """ - Show a warning dialog when the user toggles the save checkbox on or off. - - :param checked: If the combobox is checked or not + Go to the url in the url bar """ - if checked and self.login_page.isVisible(): - answer = QtWidgets.QMessageBox.question( - self, translate('SongsPlugin.SongSelectForm', 'Save Username and Password'), - translate('SongsPlugin.SongSelectForm', 'WARNING: Saving your username and password is INSECURE, your ' - 'password is stored in PLAIN TEXT. Click Yes to save your ' - 'password or No to cancel this.'), - defaultButton=QtWidgets.QMessageBox.No) - if answer == QtWidgets.QMessageBox.No: - self.save_password_checkbox.setChecked(False) - - def on_login_button_clicked(self): - """ - Log the user in to SongSelect. - """ - self.username_edit.setEnabled(False) - self.password_edit.setEnabled(False) - self.save_password_checkbox.setEnabled(False) - self.login_button.setEnabled(False) - self.login_spacer.setVisible(False) - self.login_progress_bar.setValue(0) - self.login_progress_bar.setVisible(True) - self.application.process_events() - # Log the user in - subscription_level = self.song_select_importer.login( - self.username_edit.text(), self.password_edit.text(), self._update_login_progress) - if not subscription_level: - QtWidgets.QMessageBox.critical( - self, - translate('SongsPlugin.SongSelectForm', 'Error Logging In'), - translate('SongsPlugin.SongSelectForm', - 'There was a problem logging in, perhaps your username or password is incorrect?') - ) - else: - if subscription_level == 'Free': - QtWidgets.QMessageBox.information( - self, - translate('SongsPlugin.SongSelectForm', 'Free user'), - translate('SongsPlugin.SongSelectForm', 'You logged in with a free account, ' - 'the search will be limited to songs ' - 'in the public domain.') - ) - if self.save_password_checkbox.isChecked(): - self.settings.setValue('songs/songselect username', self.username_edit.text()) - self.settings.setValue('songs/songselect password', self.password_edit.text()) - else: - self.settings.remove('songs/songselect username') - self.settings.remove('songs/songselect password') - self.stacked_widget.setCurrentIndex(1) - self.login_progress_bar.setVisible(False) - self.login_progress_bar.setValue(0) - self.login_spacer.setVisible(True) - self.login_button.setEnabled(True) - self.username_edit.setEnabled(True) - self.password_edit.setEnabled(True) - self.save_password_checkbox.setEnabled(True) - self.search_combobox.setFocus() - self.application.process_events() - - def on_search_button_clicked(self): - """ - Run a search on SongSelect. - """ - # Set up UI components - self.view_button.setEnabled(False) - self.search_button.setEnabled(False) - self.search_combobox.setEnabled(False) - self.search_progress_bar.setMinimum(0) - self.search_progress_bar.setMaximum(0) - self.search_progress_bar.setValue(0) - self.set_progress_visible(True) - self.search_results_widget.clear() - self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', - 'Found {count:d} song(s)').format(count=self.song_count)) - self.application.process_events() - self.song_count = 0 - search_history = self.search_combobox.getItems() - self.settings.setValue('songs/songselect searches', '|'.join(search_history)) - # Create thread and run search - worker = SearchWorker(self.song_select_importer, self.search_combobox.currentText()) - worker.show_info.connect(self.on_search_show_info) - worker.found_song.connect(self.on_search_found_song) - worker.finished.connect(self.on_search_finished) - run_thread(worker, 'songselect') - - def on_stop_button_clicked(self): - """ - Stop the search when the stop button is clicked. - """ - self.song_select_importer.stop() - - def on_search_show_info(self, title, message): - """ - Show an informational message from the search thread - :param title: - :param message: - """ - QtWidgets.QMessageBox.information(self, title, message) - - def on_search_found_song(self, song): - """ - Add a song to the list when one is found. - :param song: - """ - self.song_count += 1 - self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', - 'Found {count:d} song(s)').format(count=self.song_count)) - item_title = song['title'] - if song['authors']: - item_title += ' (' + ', '.join(song['authors']) + ')' - song_item = QtWidgets.QListWidgetItem(item_title, self.search_results_widget) - song_item.setData(QtCore.Qt.UserRole, song) - - def on_search_finished(self): - """ - Slot which is called when the search is completed. - """ - self.application.process_events() - self.set_progress_visible(False) - self.search_button.setEnabled(True) - self.search_combobox.setEnabled(True) - self.application.process_events() - - def on_search_results_widget_selection_changed(self): - """ - Enable or disable the view button when the selection changes. - """ - self.view_button.setEnabled(len(self.search_results_widget.selectedItems()) > 0) + url = self.url_bar.text() + self.song_select_importer.set_page(url) def on_view_button_clicked(self): """ - View a song from SongSelect. + Import a song from SongSelect. """ - self._view_song(self.search_results_widget.currentItem()) + self.view_button.setEnabled(False) + self.url_bar.setEnabled(False) + self._view_song() - def on_search_results_widget_double_clicked(self, current_item): + def on_back_button_clicked(self, force_return_to_home=False): """ - View a song from SongSelect - - :param current_item: + Go back to the search page or just to the webview if on the preview screen """ - self._view_song(current_item) - - def on_back_button_clicked(self): - """ - Go back to the search page. - """ - self.stacked_widget.setCurrentIndex(1) - self.search_combobox.setFocus() + if (self.stacked_widget.currentIndex() == 0 or force_return_to_home): + self.song_select_importer.set_home_page() + else: + self.view_button.setEnabled(True) + self.url_bar.setEnabled(True) + self.stacked_widget.setCurrentIndex(0) def on_import_button_clicked(self): """ Import a song from SongSelect. """ + # Warn user if a song exists in the database with the same ccli_number + songs_with_same_ccli_number = self.plugin.manager.get_all_objects( + Song, and_(Song.ccli_number.like(self.song['ccli_number']), Song.ccli_number != '')) + if len(songs_with_same_ccli_number) > 0: + continue_import = QtWidgets.QMessageBox.question(self, + translate('SongsPlugin.SongSelectForm', + 'Song Duplicate Warning'), + translate('SongsPlugin.SongSelectForm', + 'A song with the same CCLI number is already in ' + 'your database.\n\n' + 'Are you sure you want to import this song?'), + defaultButton=QtWidgets.QMessageBox.No) + if continue_import == QtWidgets.QMessageBox.No: + return self.song_select_importer.save_song(self.song) self.song = None - if QtWidgets.QMessageBox.question(self, translate('SongsPlugin.SongSelectForm', 'Song Imported'), + QtWidgets.QMessageBox.information(self, translate('SongsPlugin.SongSelectForm', 'Song Imported'), translate('SongsPlugin.SongSelectForm', - 'Your song has been imported, would you ' - 'like to import more songs?'), - defaultButton=QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes: - self.on_back_button_clicked() - else: - self.application.process_events() - self.done(QtWidgets.QDialog.Accepted) - - def set_progress_visible(self, is_visible): - """ - Show or hide the search progress, including the stop button. - """ - self.search_progress_bar.setVisible(is_visible) - self.stop_button.setVisible(is_visible) + 'Your song has been imported')) + self.on_back_button_clicked(True) diff --git a/openlp/plugins/songs/forms/webengine.py b/openlp/plugins/songs/forms/webengine.py new file mode 100644 index 000000000..5c40ae469 --- /dev/null +++ b/openlp/plugins/songs/forms/webengine.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2020 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, either version 3 of the License, or # +# (at your option) any later version. # +# # +# 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, see . # +########################################################################## +""" +Subclass of QWebEngineView used to block js messages; Useful for public sites +where we have no control on the code run in the webpage, and do not want to +see the error messages. +""" +from PyQt5 import QtWebEngineWidgets + + +class WebEnginePage(QtWebEngineWidgets.QWebEnginePage): + """ + A custom WebEngine page to capture Javascript console logging + """ + def javaScriptConsoleMessage(self, level, message, line_number, source_id): + """ + Override the parent method in order to hide the ccli site messages + """ + pass + + +class WebEngineView(QtWebEngineWidgets.QWebEngineView): + """ + A sub-classed QWebEngineView + """ + + def __init__(self, parent=None): + """ + Constructor + """ + super(WebEngineView, self).__init__(parent) + self.setPage(WebEnginePage(self)) diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py index 7720501f8..cc4836c03 100644 --- a/openlp/plugins/songs/lib/songselect.py +++ b/openlp/plugins/songs/lib/songselect.py @@ -22,29 +22,18 @@ The :mod:`~openlp.plugins.songs.lib.songselect` module contains the SongSelect importer itself. """ import logging -import random import re from html import unescape -from html.parser import HTMLParser -from http.cookiejar import CookieJar -from urllib.parse import urlencode -from urllib.request import HTTPCookieProcessor, URLError, build_opener - +from urllib.request import URLError +from PyQt5 import QtCore from bs4 import BeautifulSoup, NavigableString from openlp.plugins.songs.lib import VerseType, clean_song from openlp.plugins.songs.lib.db import Song, Author, Topic from openlp.plugins.songs.lib.openlyricsxml import SongXML +from openlp.core.common.utils import wait_for -USER_AGENTS = [ - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) ' - 'Chrome/52.0.2743.116 Safari/537.36', - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36', - 'Mozilla/5.0 (X11; Linux x86_64; rv:47.0) Gecko/20100101 Firefox/47.0', - 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:46.0) Gecko/20100101 Firefox/46.0', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:47.0) Gecko/20100101 Firefox/47.0' -] BASE_URL = 'https://songselect.ccli.com' LOGIN_PAGE = 'https://profile.ccli.com/account/signin?appContext=SongSelect&returnUrl='\ 'https%3a%2f%2fsongselect.ccli.com%2f' @@ -56,177 +45,140 @@ SONG_PAGE = BASE_URL + '/Songs/' log = logging.getLogger(__name__) +class Pages(object): + """ + Songselect web page types. + """ + Login = 0 + Home = 1 + Search = 2 + Song = 3 + Other = 4 + + class SongSelectImport(object): """ The :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class contains all the code which interfaces with CCLI's SongSelect service and downloads the songs. """ - def __init__(self, db_manager): + def __init__(self, db_manager, webview): """ Set up the song select importer :param db_manager: The song database manager """ self.db_manager = db_manager - self.html_parser = HTMLParser() - self.opener = build_opener(HTTPCookieProcessor(CookieJar())) - self.opener.addheaders = [('User-Agent', random.choice(USER_AGENTS))] - self.run_search = True + self.webview = webview - def login(self, username, password, callback=None): + def get_page_type(self): """ - Log the user into SongSelect. This method takes a username and password, and runs ``callback()`` at various - points which can be used to give the user some form of feedback. + Get the type of page the user is currently ono - :param username: SongSelect username - :param password: SongSelect password - :param callback: Method to notify of progress. - :return: subscription level on success, None on failure. + :return: The page the user is on """ - if callback: - callback() - try: - login_page = BeautifulSoup(self.opener.open(LOGIN_PAGE).read(), 'lxml') - except (TypeError, URLError) as error: - log.exception('Could not login to SongSelect, {error}'.format(error=error)) - return False - if callback: - callback() - token_input = login_page.find('input', attrs={'name': '__RequestVerificationToken'}) - data = urlencode({ - '__RequestVerificationToken': token_input['value'], - 'emailAddress': username, - 'password': password, - 'RememberMe': 'false' - }) - login_form = login_page.find('form') - if login_form: - login_url = login_form.attrs['action'] - else: - login_url = '/Account/SignIn' - if not login_url.startswith('http'): - if login_url[0] != '/': - login_url = '/' + login_url - login_url = LOGIN_URL + login_url - try: - posted_page = BeautifulSoup(self.opener.open(login_url, data.encode('utf-8')).read(), 'lxml') - except (TypeError, URLError) as error: - log.exception('Could not login to SongSelect, {error}'.format(error=error)) - return False - if callback: - callback() - # Page if user is in an organization - if posted_page.find('input', id='SearchText') is not None: - self.subscription_level = self.find_subscription_level(posted_page) - return self.subscription_level - # Page if user is not in an organization - elif posted_page.find('div', id="select-organization") is not None: - try: - home_page = BeautifulSoup(self.opener.open(BASE_URL).read(), 'lxml') - self.subscription_level = self.find_subscription_level(home_page) - except (TypeError, URLError) as error: - log.exception('Could not reach SongSelect, {error}'.format(error=error)) - self.subscription_level = None - return self.subscription_level - else: - log.debug(posted_page) - return None + current_url_host = self.webview.page().url().host() + current_url_path = self.webview.page().url().path() + if (current_url_host == QtCore.QUrl(LOGIN_URL).host() and current_url_path == QtCore.QUrl(LOGIN_PAGE).path()): + return Pages.Login + elif (current_url_host == QtCore.QUrl(BASE_URL).host()): + if (current_url_path == '/' or current_url_path == ''): + return Pages.Home + elif (current_url_path == QtCore.QUrl(SEARCH_URL).path()): + return Pages.Search + elif (self.get_song_number_from_url(current_url_path) is not None): + return Pages.Song + return Pages.Other - def find_subscription_level(self, page): - scripts = page.find_all('script') - for tag in scripts: - if tag.string: - match = re.search("'Subscription': '(?P[^']+)", tag.string) - if match: - return match.group('subscription_level') - log.error('Could not determine SongSelect subscription level') + def _run_javascript(self, script): + """ + Run a script and returns the result + + :param script: The javascript to be run + :return: The evaluated result + """ + self.web_stuff = "" + self.got_web_stuff = False + + def handle_result(result): + """ + Handle the result from the asynchronous call + """ + self.got_web_stuff = True + self.web_stuff = result + self.webview.page().runJavaScript(script, handle_result) + wait_for(lambda: self.got_web_stuff) + return self.web_stuff + + def reset_webview(self): + """ + Sets the webview back to the login page using the Qt setUrl method + """ + url = QtCore.QUrl(LOGIN_PAGE) + self.webview.setUrl(url) + + def set_home_page(self): + """ + Sets the webview to the search page + """ + self.set_page(BASE_URL) + + def set_page(self, url): + """ + Sets the active page in the webview + + :param url: The new page location + """ + script = 'document.location = "{}"'.format(url) + self._run_javascript(script) + + def set_login_fields(self, username, password): + script_set_login_fields = ('document.getElementById("EmailAddress").value = "{email}";' + 'document.getElementById("Password").value = "{password}";' + ).format(email=username, password=password) + self._run_javascript(script_set_login_fields) + + def get_page(self, url): + """ + Gets the html for the url through the active webview + + :return: String containing a html document + """ + script_get_page = ('var openlp_page_data = null;' + 'fetch("{}").then(data => {{return data.text()}})' + ' .then(data => {{openlp_page_data = data}})').format(url) + self._run_javascript(script_get_page) + wait_for(lambda: self._run_javascript('openlp_page_data != null')) + return self._run_javascript('openlp_page_data') + + def get_song_number_from_url(self, url): + """ + Gets the ccli song number for a song from the url + + :return: String containg ccli song number, None is returned if not found + """ + ccli_number_regex = re.compile(r'.*?Songs\/([0-9]+).*', re.IGNORECASE) + regex_matches = ccli_number_regex.match(url) + if regex_matches: + return regex_matches.group(1) return None - def logout(self): - """ - Log the user out of SongSelect - """ - try: - self.opener.open(LOGOUT_URL) - except (TypeError, URLError) as error: - log.exception('Could not log out of SongSelect, {error}'.format(error=error)) - - def search(self, search_text, max_results, callback=None): - """ - Set up a search. - - :param search_text: The text to search for. - :param max_results: Maximum number of results to fetch. - :param callback: A method which is called when each song is found, with the song as a parameter. - :return: List of songs - """ - self.run_search = True - search_text = search_text.strip() - params = { - 'SongContent': '', - 'PrimaryLanguage': '', - 'Keys': '', - 'Themes': '', - 'List': 'publicdomain' if self.subscription_level == 'Free' else '', - 'Sort': '', - 'SearchText': search_text - } - current_page = 1 - songs = [] - while self.run_search: - if current_page > 1: - params['CurrentPage'] = current_page - try: - results_page = BeautifulSoup(self.opener.open(SEARCH_URL + '?' + urlencode(params)).read(), 'lxml') - search_results = results_page.find_all('div', 'song-result') - except (TypeError, URLError) as error: - log.exception('Could not search SongSelect, {error}'.format(error=error)) - results_page = None - search_results = None - if not search_results: - if results_page and re.compile('^[0-9]+$').match(search_text): - author_elements = results_page.find('ul', class_='authors').find_all('li') - song = { - 'link': SONG_PAGE + search_text, - 'authors': [unescape(li.find('a').string).strip() for li in author_elements], - 'title': unescape(results_page.find('div', 'content-title').find('h1').string).strip() - } - if callback: - callback(song) - songs.append(song) - break - for result in search_results: - authors = result.find('p', 'song-result-subtitle').string - if authors: - authors = unescape(authors).strip().split(', ') - else: - authors = "" - song = { - 'title': unescape(result.find('p', 'song-result-title').find('a').string).strip(), - 'authors': authors, - 'link': BASE_URL + result.find('p', 'song-result-title').find('a')['href'] - } - if callback: - callback(song) - songs.append(song) - if len(songs) >= max_results: - self.run_search = False - break - current_page += 1 - return songs - - def get_song(self, song, callback=None): + def get_song(self, callback=None): """ Get the full song from SongSelect - :param song: The song dictionary to update + :param song: The song page url :param callback: A callback which can be used to indicate progress - :return: The updated song dictionary + :return: Dictionary containing the song info """ + song = {} + # Get current song + current_url = self.webview.url().path() + ccli_number = self.get_song_number_from_url(current_url) + if callback: callback() try: - song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') + song_page = BeautifulSoup(self.get_page(SONG_PAGE + ccli_number), 'lxml') except (TypeError, URLError) as error: log.exception('Could not get song from SongSelect, {error}'.format(error=error)) return None @@ -234,7 +186,7 @@ class SongSelectImport(object): if callback: callback() try: - lyrics_page = BeautifulSoup(self.opener.open(BASE_URL + lyrics_link).read(), 'lxml') + lyrics_page = BeautifulSoup(self.get_page(BASE_URL + lyrics_link), 'lxml') except (TypeError, URLError): log.exception('Could not get lyrics from SongSelect') return None @@ -249,6 +201,9 @@ class SongSelectImport(object): copyright_elements.extend(ul.find_all('li')[1:]) if ul.find('li', string=themes_regex): theme_elements.extend(ul.find_all('li')[1:]) + author_elements = song_page.find('div', 'content-title').find('ul', 'authors').find_all('li') + song['title'] = unescape(song_page.find('div', 'content-title').find('h1').string.strip()) + song['authors'] = [unescape(li.find('a').string).strip() for li in author_elements] song['copyright'] = '/'.join([unescape(li.string).strip() for li in copyright_elements]) song['topics'] = [unescape(li.string).strip() for li in theme_elements] song['ccli_number'] = song_page.find('div', 'song-content-data').find('ul').find('li')\ @@ -273,7 +228,7 @@ class SongSelectImport(object): """ Save a song to the database, using the db_manager - :param song: + :param song: Dictionary of the song to save :return: """ db_song = Song.populate(title=song['title'], copyright=song['copyright'], ccli_number=song['ccli_number']) @@ -312,9 +267,3 @@ class SongSelectImport(object): db_song.topics.append(topic) self.db_manager.save_object(db_song) return db_song - - def stop(self): - """ - Stop the search. - """ - self.run_search = False diff --git a/openlp/plugins/songs/lib/songstab.py b/openlp/plugins/songs/lib/songstab.py index 4d182d33b..462c43bd5 100644 --- a/openlp/plugins/songs/lib/songstab.py +++ b/openlp/plugins/songs/lib/songstab.py @@ -79,6 +79,25 @@ class SongsTab(SettingsTab): self.neolatin_notation_radio_button.setObjectName('neolatin_notation_radio_button') self.chords_layout.addWidget(self.neolatin_notation_radio_button) self.left_layout.addWidget(self.chords_group_box) + # CCLI SongSelect login group box + + self.ccli_login_group_box = QtWidgets.QGroupBox(self.left_column) + self.ccli_login_group_box.setObjectName('ccli_login_group_box') + self.ccli_login_layout = QtWidgets.QFormLayout(self.ccli_login_group_box) + self.ccli_login_layout.setObjectName('ccli_login_layout') + self.ccli_username_label = QtWidgets.QLabel(self.ccli_login_group_box) + self.ccli_username_label.setObjectName('ccli_username_label') + self.ccli_username = QtWidgets.QLineEdit(self.ccli_login_group_box) + self.ccli_username.setObjectName('ccli_username') + self.ccli_login_layout.addRow(self.ccli_username_label, self.ccli_username) + self.ccli_password_label = QtWidgets.QLabel(self.ccli_login_group_box) + self.ccli_password_label.setObjectName('ccli_password_label') + self.ccli_password = QtWidgets.QLineEdit(self.ccli_login_group_box) + self.ccli_password.setEchoMode(QtWidgets.QLineEdit.Password) + self.ccli_password.setObjectName('ccli_password') + self.ccli_login_layout.addRow(self.ccli_password_label, self.ccli_password) + self.left_layout.addWidget(self.ccli_login_group_box) + # Footer group box self.footer_group_box = QtWidgets.QGroupBox(self.left_column) self.footer_group_box.setObjectName('footer_group_box') @@ -121,6 +140,9 @@ class SongsTab(SettingsTab): self.chords_group_box.setTitle(translate('SongsPlugin.SongsTab', 'Chords')) self.disable_chords_import_check_box.setText(translate('SongsPlugin.SongsTab', 'Ignore chords when importing songs')) + self.ccli_login_group_box.setTitle(translate('SongsPlugin.SongsTab', 'SongSelect Login')) + self.ccli_username_label.setText(translate('SongsPlugin.SongsTab', 'Username:')) + self.ccli_password_label.setText(translate('SongsPlugin.SongsTab', 'Password:')) self.chord_notation_label.setText(translate('SongsPlugin.SongsTab', 'Chord notation to use:')) self.english_notation_radio_button.setText(translate('SongsPlugin.SongsTab', 'English') + ' (C-D-E-F-G-A-B)') self.german_notation_radio_button.setText(translate('SongsPlugin.SongsTab', 'German') + ' (C-D-E-F-G-A-H)') @@ -222,6 +244,8 @@ class SongsTab(SettingsTab): self.neolatin_notation_radio_button.setChecked(True) else: self.english_notation_radio_button.setChecked(True) + self.ccli_username.setText(self.settings.value('songs/songselect username')) + self.ccli_password.setText(self.settings.value('songs/songselect password')) self.footer_edit_box.setPlainText(self.settings.value('songs/footer template')) def save(self): @@ -231,6 +255,19 @@ class SongsTab(SettingsTab): self.settings.setValue('songs/enable chords', self.chords_group_box.isChecked()) self.settings.setValue('songs/disable chords import', self.disable_chords_import) self.settings.setValue('songs/chord notation', self.chord_notation) + self.settings.setValue('songs/songselect username', self.ccli_username.text()) + # Only save password if it's blank or the user acknowleges the warning + if (self.ccli_password.text() == ''): + self.settings.setValue('songs/songselect password', '') + elif (self.ccli_password.text() != self.settings.value('songs/songselect password')): + answer = QtWidgets.QMessageBox.question( + self, translate('SongsPlugin.SongsTab', 'Save Username and Password'), + translate('SongsPlugin.SongsTab', 'WARNING: Saving your SongSelect password is INSECURE, ' + 'your password is stored in PLAIN TEXT. Click Yes to save ' + 'your password or No to cancel this.'), + defaultButton=QtWidgets.QMessageBox.No) + if answer == QtWidgets.QMessageBox.Yes: + self.settings.setValue('songs/songselect password', self.ccli_password.text()) # Only save footer template if it has been changed. This allows future updates if self.footer_edit_box.toPlainText() != self.settings.get_default_value('songs/footer template'): self.settings.setValue('songs/footer template', self.footer_edit_box.toPlainText()) diff --git a/tests/functional/openlp_plugins/songs/test_songselect.py b/tests/functional/openlp_plugins/songs/test_songselect.py index 82e475646..0d8e26cc6 100644 --- a/tests/functional/openlp_plugins/songs/test_songselect.py +++ b/tests/functional/openlp_plugins/songs/test_songselect.py @@ -27,15 +27,14 @@ their page layout, changing the tests would just be a case of re-downloading the HTML pages and changing the code to use the new layout. """ from unittest import TestCase -from unittest.mock import MagicMock, call, patch -from urllib.error import URLError +from unittest.mock import MagicMock, patch, sentinel -from PyQt5 import QtWidgets +from PyQt5 import QtWidgets, QtCore from openlp.core.common.registry import Registry -from openlp.plugins.songs.forms.songselectform import SearchWorker, SongSelectForm +from openlp.plugins.songs.forms.songselectform import SongSelectForm from openlp.plugins.songs.lib import Song -from openlp.plugins.songs.lib.songselect import BASE_URL, LOGOUT_URL, SongSelectImport +from openlp.plugins.songs.lib.songselect import BASE_URL, LOGIN_PAGE, Pages, SongSelectImport from tests.helpers.songfileimport import SongImportTestHelper from tests.helpers.testmixin import TestMixin from tests.utils.constants import RESOURCE_PATH @@ -48,327 +47,242 @@ class TestSongSelectImport(TestCase, TestMixin): """ Test the :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class """ - @patch('openlp.plugins.songs.lib.songselect.build_opener') - def test_constructor(self, mocked_build_opener): + def test_constructor(self): """ Test that constructing a basic SongSelectImport object works correctly """ # GIVEN: The SongSelectImporter class and a mocked out build_opener # WHEN: An object is instantiated - importer = SongSelectImport(None) + importer = SongSelectImport(sentinel.db_manager, sentinel.webview) # THEN: The object should have the correct properties - assert importer.db_manager is None, 'The db_manager should be None' - assert importer.html_parser is not None, 'There should be a valid html_parser object' - assert importer.opener is not None, 'There should be a valid opener object' - assert 1 == mocked_build_opener.call_count, 'The build_opener method should have been called once' + assert importer.db_manager is sentinel.db_manager, 'The db_manager should be set' + assert importer.webview is sentinel.webview, 'The webview should be set' - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_login_fails(self, MockedBeautifulSoup, mocked_build_opener): + def test_get_page_type_login(self): """ - Test that when logging in to SongSelect fails, the login method returns None + Test get_page_type to spot the login page """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_login_page = MagicMock() - mocked_login_page.find.side_effect = [{'value': 'blah'}, None] - mocked_posted_page = MagicMock() - mocked_posted_page.find.return_value = None - MockedBeautifulSoup.side_effect = [mocked_login_page, mocked_posted_page] - mock_callback = MagicMock() - importer = SongSelectImport(None) + # GIVEN: A importer object, and a mocked url + importer = SongSelectImport(None, None) + url = QtCore.QUrl('https://profile.ccli.com/account/signin?appContext=SongSelect&' + 'returnUrl=https%3a%2f%2fsongselect.ccli.com%2f') + page = MagicMock(url=MagicMock(return_value=url)) + importer.webview = MagicMock(page=MagicMock(return_value=page)) - # WHEN: The login method is called after being rigged to fail - result = importer.login('username', 'password', mock_callback) + # WHEN: The method is run + result = importer.get_page_type() - # THEN: callback was called 3 times, open was called twice, find was called twice, and None was returned - assert 3 == mock_callback.call_count, 'callback should have been called 3 times' - assert 2 == mocked_login_page.find.call_count, 'find should have been called twice' - assert 2 == mocked_posted_page.find.call_count, 'find should have been called twice' - assert 2 == mocked_opener.open.call_count, 'opener should have been called twice' - assert result is None, 'The login method should have returned None' + # THEN: The correct type should be returned + assert result == Pages.Login - @patch('openlp.plugins.songs.lib.songselect.build_opener') - def test_login_except(self, mocked_build_opener): + def test_get_page_type_home(self): """ - Test that when logging in to SongSelect fails, the login method raises URLError + Test get_page_type to spot the home page """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_build_opener.open.side_effect = URLError('Fake URLError') - mock_callback = MagicMock() - importer = SongSelectImport(None) + # GIVEN: A importer object, and a mocked url + importer = SongSelectImport(None, None) + url = QtCore.QUrl('https://songselect.ccli.com') + page = MagicMock(url=MagicMock(return_value=url)) + importer.webview = MagicMock(page=MagicMock(return_value=page)) - # WHEN: The login method is called after being rigged to fail - result = importer.login('username', 'password', mock_callback) + # WHEN: The method is run + result = importer.get_page_type() - # THEN: callback was called 1 time and False was returned - assert 1 == mock_callback.call_count, 'callback should have been called 1 times' - assert result is False, 'The login method should have returned False' + # THEN: The correct type should be returned + assert result == Pages.Home - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_login_succeeds(self, MockedBeautifulSoup, mocked_build_opener): + def test_get_page_type_search(self): """ - Test that when logging in to SongSelect succeeds, the login method returns True + Test get_page_type to spot the search page """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_login_page = MagicMock() - mocked_login_page.find.side_effect = [{'value': 'blah'}, None] - mocked_posted_page = MagicMock() - mocked_posted_page.find.return_value = MagicMock() - MockedBeautifulSoup.side_effect = [mocked_login_page, mocked_posted_page] - mock_callback = MagicMock() - importer = SongSelectImport(None) + # GIVEN: A importer object, and a mocked url + importer = SongSelectImport(None, None) + url = QtCore.QUrl('https://songselect.ccli.com/search/results?SearchText=test') + page = MagicMock(url=MagicMock(return_value=url)) + importer.webview = MagicMock(page=MagicMock(return_value=page)) - # WHEN: The login method is called after being rigged to fail - result = importer.login('username', 'password', mock_callback) + # WHEN: The method is run + result = importer.get_page_type() - # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned - assert 3 == mock_callback.call_count, 'callback should have been called 3 times' - assert 2 == mocked_login_page.find.call_count, 'find should have been called twice on the login page' - assert 1 == mocked_posted_page.find.call_count, 'find should have been called once on the posted page' - assert 2 == mocked_opener.open.call_count, 'opener should have been called twice' - assert result is None, 'The login method should have returned the subscription level' + # THEN: The correct type should be returned + assert result == Pages.Search - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_login_url_from_form(self, MockedBeautifulSoup, mocked_build_opener): + def test_get_page_type_song(self): """ - Test that the login URL is from the form + Test get_page_type to spot the login page """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_form = MagicMock() - mocked_form.attrs = {'action': 'do/login'} - mocked_login_page = MagicMock() - mocked_login_page.find.side_effect = [{'value': 'blah'}, mocked_form] - mocked_posted_page = MagicMock() - mocked_posted_page.find.return_value = MagicMock() - mocked_home_page = MagicMock() - MockedBeautifulSoup.side_effect = [mocked_login_page, mocked_posted_page, mocked_home_page] - mock_callback = MagicMock() - importer = SongSelectImport(None) + # GIVEN: A importer object, and a mocked url + importer = SongSelectImport(None, None) + url = QtCore.QUrl('https://songselect.ccli.com/Songs/7115744/song_name/view_lyrics') + page = MagicMock(url=MagicMock(return_value=url)) + importer.webview = MagicMock(page=MagicMock(return_value=page)) - # WHEN: The login method is called after being rigged to fail - result = importer.login('username', 'password', mock_callback) + # WHEN: The method is run + result = importer.get_page_type() - # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned - assert 3 == mock_callback.call_count, 'callback should have been called 3 times' - assert 2 == mocked_login_page.find.call_count, 'find should have been called twice on the login page' - assert 1 == mocked_posted_page.find.call_count, 'find should have been called once on the posted page' - assert 'https://profile.ccli.com/do/login', mocked_opener.open.call_args_list[1][0][0] - assert result is None, 'The login method should have returned the subscription level' + # THEN: The correct type should be returned + assert result == Pages.Song - @patch('openlp.plugins.songs.lib.songselect.build_opener') - def test_logout(self, mocked_build_opener): + def test_get_page_type_other(self): """ - Test that when the logout method is called, it logs the user out of SongSelect + Test get_page_type to spot the login page """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - importer = SongSelectImport(None) + # GIVEN: A importer object, and a mocked url + importer = SongSelectImport(None, None) + url = QtCore.QUrl('https://openlp.org') + page = MagicMock(url=MagicMock(return_value=url)) + importer.webview = MagicMock(page=MagicMock(return_value=page)) - # WHEN: The login method is called after being rigged to fail - importer.logout() + # WHEN: The method is run + result = importer.get_page_type() - # THEN: The opener is called once with the logout url - assert 1 == mocked_opener.open.call_count, 'opener should have been called once' - mocked_opener.open.assert_called_with(LOGOUT_URL) + # THEN: The correct type should be returned + assert result == Pages.Other - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_search_returns_no_results(self, MockedBeautifulSoup, mocked_build_opener): + @patch('openlp.plugins.songs.lib.songselect.wait_for') + def test_run_javascript(self, mocked_wait_for): """ - Test that when the search finds no results, it simply returns an empty list + Test run javascript calls the page object """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_results_page = MagicMock() - mocked_results_page.find_all.return_value = [] - MockedBeautifulSoup.return_value = mocked_results_page - mock_callback = MagicMock() - importer = SongSelectImport(None) - importer.subscription_level = 'premium' + # GIVEN: A importer object and mocked run js fn + def runJs(script, handle_result): + handle_result('processed_{}'.format(script)) + importer = SongSelectImport(None, None) + importer.webview = MagicMock() + page = MagicMock() + page.runJavaScript = runJs + importer.webview.page.return_value = page - # WHEN: The login method is called after being rigged to fail - results = importer.search('text', 1000, mock_callback) + # WHEN: The set login field method is called + result = importer._run_javascript('2 + 2') - # THEN: callback was never called, open was called once, find_all was called once, an empty list returned - assert 0 == mock_callback.call_count, 'callback should not have been called' - assert 1 == mocked_opener.open.call_count, 'open should have been called once' - assert 1 == mocked_results_page.find_all.call_count, 'find_all should have been called once' - mocked_results_page.find_all.assert_called_with('div', 'song-result') - assert [] == results, 'The search method should have returned an empty list' + # THEN: The javascript should have been called on the page object + assert result == 'processed_2 + 2' - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_search_returns_ccli_song_number_result(self, MockedBeautifulSoup, mocked_build_opener): + def test_reset_webview(self): """ - Test that search can find a single song by CCLI number + Check that the setUrl method is called when the reset webview method is called """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_results_page = MagicMock() - mocked_results_page.find_all.return_value = [] - MockedBeautifulSoup.return_value = mocked_results_page - mock_callback = MagicMock() - importer = SongSelectImport(None) - importer.subscription_level = 'premium' + # GIVEN: A importer object and mock webview + importer = SongSelectImport(None, None) + importer.webview = MagicMock() - # WHEN: The search is performed - results = importer.search('1234567', 1000, mock_callback) + # WHEN: The reset_webview method is called + importer.reset_webview() - # THEN: callback was called once and the results are as expected - assert 1 == mock_callback.call_count, 'callback should not have been called' - assert 1 == mocked_opener.open.call_count, 'open should have been called once' - assert 1 == mocked_results_page.find_all.call_count, 'find_all should have been called once' - mocked_results_page.find_all.assert_called_with('div', 'song-result') + # THEN: The setUrl function should have been called + importer.webview.setUrl.assert_called_with(QtCore.QUrl(LOGIN_PAGE)) - assert 1 == len(results), 'The search method should have returned an single song in a list' - assert 'https://songselect.ccli.com/Songs/1234567' == results[0]['link'],\ - 'The correct link should have been returned' - - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_search_returns_two_results(self, MockedBeautifulSoup, mocked_build_opener): + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.set_page') + def test_set_home_page(self, mocked_set_page): """ - Test that when the search finds 2 results, it simply returns a list with 2 results + Test that when the home method is called, it attempts to go to the home page """ - # GIVEN: A bunch of mocked out stuff and an importer object - # first search result - mocked_result1 = MagicMock() - mocked_result1.find.side_effect = [ - MagicMock(string='James, John'), - MagicMock(find=MagicMock(return_value=MagicMock(string='Title 1'))), - MagicMock(find=MagicMock(return_value={'href': '/url1'})) - ] - # second search result - mocked_result2 = MagicMock() - mocked_result2.find.side_effect = [ - MagicMock(string='Philip'), - MagicMock(find=MagicMock(return_value=MagicMock(string='Title 2'))), - MagicMock(find=MagicMock(return_value={'href': '/url2'})) - ] - # rest of the stuff - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_results_page = MagicMock() - mocked_results_page.find_all.side_effect = [[mocked_result1, mocked_result2], []] - MockedBeautifulSoup.return_value = mocked_results_page - mock_callback = MagicMock() - importer = SongSelectImport(None) - importer.subscription_level = 'premium' + # GIVEN: A importer object + importer = SongSelectImport(None, None) - # WHEN: The search method is called - results = importer.search('text', 1000, mock_callback) + # WHEN: The home method is called + importer.set_home_page() - # THEN: callback was never called, open was called once, find_all was called once, an empty list returned - assert 2 == mock_callback.call_count, 'callback should have been called twice' - assert 2 == mocked_opener.open.call_count, 'open should have been called twice' - assert 2 == mocked_results_page.find_all.call_count, 'find_all should have been called twice' - mocked_results_page.find_all.assert_called_with('div', 'song-result') - expected_list = [ - {'title': 'Title 1', 'authors': ['James', 'John'], 'link': BASE_URL + '/url1'}, - {'title': 'Title 2', 'authors': ['Philip'], 'link': BASE_URL + '/url2'} - ] - assert expected_list == results, 'The search method should have returned two songs' + # THEN: set_page is called once with the base url + mocked_set_page.assert_called_with(BASE_URL) - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_search_reaches_max_results(self, MockedBeautifulSoup, mocked_build_opener): + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport._run_javascript') + def test_set_page(self, mocked_run_js): """ - Test that when the search finds MAX (2) results, it simply returns a list with those (2) + Test set page runs the correct script """ - # GIVEN: A bunch of mocked out stuff and an importer object - # first search result - mocked_result1 = MagicMock() - mocked_result1.find.side_effect = [ - MagicMock(string='James, John'), - MagicMock(find=MagicMock(return_value=MagicMock(string='Title 1'))), - MagicMock(find=MagicMock(return_value={'href': '/url1'})) - ] - # second search result - mocked_result2 = MagicMock() - mocked_result2.find.side_effect = [ - MagicMock(string='Philip'), - MagicMock(find=MagicMock(return_value=MagicMock(string='Title 2'))), - MagicMock(find=MagicMock(return_value={'href': '/url2'})) - ] - # third search result - mocked_result3 = MagicMock() - mocked_result3.find.side_effect = [ - MagicMock(string='Luke, Matthew'), - MagicMock(find=MagicMock(return_value=MagicMock(string='Title 3'))), - MagicMock(find=MagicMock(return_value={'href': '/url3'})) - ] - # rest of the stuff - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_results_page = MagicMock() - mocked_results_page.find_all.side_effect = [[mocked_result1, mocked_result2, mocked_result3], []] - MockedBeautifulSoup.return_value = mocked_results_page - mock_callback = MagicMock() - importer = SongSelectImport(None) - importer.subscription_level = 'premium' + # GIVEN: A importer object + importer = SongSelectImport(None, None) - # WHEN: The search method is called - results = importer.search('text', 2, mock_callback) + # WHEN: The set login field method is called + importer.set_page('my_new_page') - # THEN: callback was called twice, open was called once, find_all was called once, max results returned - assert 2 == mock_callback.call_count, 'callback should have been called twice' - assert 1 == mocked_opener.open.call_count, 'open should have been called once' - assert 1 == mocked_results_page.find_all.call_count, 'find_all should have been called once' - mocked_results_page.find_all.assert_called_with('div', 'song-result') - expected_list = [{'title': 'Title 1', 'authors': ['James', 'John'], 'link': BASE_URL + '/url1'}, - {'title': 'Title 2', 'authors': ['Philip'], 'link': BASE_URL + '/url2'}] - assert expected_list == results, 'The search method should have returned two songs' + # THEN: The javascript called should contain the correct values + mocked_run_js.assert_called_with('document.location = "my_new_page"') - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_stop_called(self, MockedBeautifulSoup, mocked_build_opener): + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport._run_javascript') + def test_set_login_fields(self, mocked_run_js): """ - Test that the search is stopped with stop() is called + Test correct js is sent to set login fields """ - # GIVEN: An importer object that is currently "searching" - importer = SongSelectImport(None) - importer.run_search = True + # GIVEN: A importer object + importer = SongSelectImport(None, None) - # WHEN: The stop method is called - importer.stop() + # WHEN: The set login field method is called + importer.set_login_fields('my_username', 'my_password') - # THEN: Searching should have stopped - assert importer.run_search is False, 'Searching should have been stopped' + # THEN: The javascript called should contain the correct values + mocked_run_js.assert_called_with(('document.getElementById("EmailAddress").value = "my_username";' + 'document.getElementById("Password").value = "my_password";' + )) - @patch('openlp.plugins.songs.lib.songselect.build_opener') - def test_get_song_page_raises_exception(self, mocked_build_opener): + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport._run_javascript') + @patch('openlp.plugins.songs.lib.songselect.wait_for') + def test_get_page(self, mocked_wait_for, mocked_run_js): + """ + Test get page sends js requests + """ + # GIVEN: A importer object + importer = SongSelectImport(None, None) + mocked_run_js.return_value = True + + # WHEN: The get page method is called + importer.get_page("https://example.com") + + # THEN: The javascript should be run + assert mocked_run_js.call_count == 2, 'Should be called once for request and once for fetch' + mocked_wait_for.assert_called_once() + + def test_get_song_number_from_url(self): + """ + Test the ccli number can be correctly obtained from a url + """ + # GIVEN: A importer object + importer = SongSelectImport(None, None) + + # WHEN: The function is called with a valid url + result = importer.get_song_number_from_url('https://songselect.ccli.com/Songs/7115744/way-maker') + + # THEN: The ccli number is returned + assert result == '7115744', 'Should have found the ccli number from the url' + + def test_get_song_number_from_url_nonumber(self): + """ + Test the ccli number function returns None when no number is found + """ + # GIVEN: A importer object + importer = SongSelectImport(None, None) + + # WHEN: The function is called with a valid url + result = importer.get_song_number_from_url('https://songselect.ccli.com/search/results?SearchText=song+7115744') + + # THEN: The returned value should be None + assert result is None + + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url') + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page') + def test_get_song_page_raises_exception(self, mocked_get_page, mock_get_num): """ Test that when BeautifulSoup gets a bad song page the get_song() method returns None """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_opener = MagicMock() - mocked_build_opener.return_value = mocked_opener - mocked_opener.open.read.side_effect = URLError('[Errno -2] Name or service not known') + # GIVEN: A mocked callback and an importer object + mocked_get_page.side_effect = None mocked_callback = MagicMock() - importer = SongSelectImport(None) + importer = SongSelectImport(None, MagicMock()) # WHEN: get_song is called - result = importer.get_song({'link': 'link'}, callback=mocked_callback) + result = importer.get_song(callback=mocked_callback) # THEN: The callback should have been called once and None should be returned mocked_callback.assert_called_with() assert result is None, 'The get_song() method should have returned None' - @patch('openlp.plugins.songs.lib.songselect.build_opener') + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url') + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page') @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_get_song_lyrics_raise_exception(self, MockedBeautifulSoup, mocked_build_opener): + def test_get_song_lyrics_raise_exception(self, MockedBeautifulSoup, mocked_get_page, mock_get_num): """ Test that when BeautifulSoup gets a bad lyrics page the get_song() method returns None """ @@ -376,61 +290,65 @@ class TestSongSelectImport(TestCase, TestMixin): song_page = MagicMock(return_value={'href': '/lyricpage'}) MockedBeautifulSoup.side_effect = [song_page, TypeError('Test Error')] mocked_callback = MagicMock() - importer = SongSelectImport(None) + importer = SongSelectImport(None, MagicMock()) # WHEN: get_song is called - result = importer.get_song({'link': 'link'}, callback=mocked_callback) + result = importer.get_song(callback=mocked_callback) # THEN: The callback should have been called twice and None should be returned assert 2 == mocked_callback.call_count, 'The callback should have been called twice' assert result is None, 'The get_song() method should have returned None' - @patch('openlp.plugins.songs.lib.songselect.build_opener') - @patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') - def test_get_song(self, MockedBeautifulSoup, mocked_build_opener): + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_song_number_from_url') + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport.get_page') + def test_get_song(self, mocked_get_page, mock_get_num): """ Test that the get_song() method returns the correct song details """ - # GIVEN: A bunch of mocked out stuff and an importer object - mocked_song_page = MagicMock() - mocked_copyright = MagicMock() - mocked_copyright.find_all.return_value = [MagicMock(string='Copyright 1'), MagicMock(string='Copyright 2')] - mocked_song_page.find.side_effect = [ - mocked_copyright, - MagicMock(find=MagicMock(string='CCLI: 123456')) - ] - mocked_lyrics_page = MagicMock() - mocked_find_all = MagicMock() - mocked_find_all.side_effect = [ - [ - MagicMock(contents='The Lord told Noah: there\'s gonna be a floody, floody'), - MagicMock(contents='So, rise and shine, and give God the glory, glory'), - MagicMock(contents='The Lord told Noah to build him an arky, arky') - ], - [MagicMock(string='Verse 1'), MagicMock(string='Chorus'), MagicMock(string='Verse 2')] - ] - mocked_lyrics_page.find.return_value = MagicMock(find_all=mocked_find_all) - MockedBeautifulSoup.side_effect = [mocked_song_page, mocked_lyrics_page] + fake_song_page = ''' +
+

Song Title

+ +
+
  • 1234_cclinumber_5678
+ + +
    +
  • Copyrights
  • Copy thing
  • Copy thing 2
  • +
+ + ''' + fake_lyrics_page = ''' +
+

Verse 1

+

verse thing 1
line 2

+

Verse 2

+

verse thing 2

+
+ + ''' + mocked_get_page.side_effect = [fake_song_page, fake_lyrics_page] mocked_callback = MagicMock() - importer = SongSelectImport(None) - fake_song = {'title': 'Title', 'authors': ['Author 1', 'Author 2'], 'link': 'url'} + importer = SongSelectImport(None, MagicMock()) # WHEN: get_song is called - result = importer.get_song(fake_song, callback=mocked_callback) + result = importer.get_song(callback=mocked_callback) # THEN: The callback should have been called three times and the song should be returned assert 3 == mocked_callback.call_count, 'The callback should have been called twice' assert result is not None, 'The get_song() method should have returned a song dictionary' - assert 2 == mocked_lyrics_page.find.call_count, 'The find() method should have been called twice' - assert 2 == mocked_find_all.call_count, 'The find_all() method should have been called twice' - assert [call('div', 'song-viewer lyrics'), call('div', 'song-viewer lyrics')] == \ - mocked_lyrics_page.find.call_args_list, 'The find() method should have been called with the right arguments' - assert [call('p'), call('h3')] == mocked_find_all.call_args_list, \ - 'The find_all() method should have been called with the right arguments' - assert 'copyright' in result, 'The returned song should have a copyright' - assert 'ccli_number' in result, 'The returned song should have a CCLI number' - assert 'verses' in result, 'The returned song should have verses' - assert 3 == len(result['verses']), 'Three verses should have been returned' + assert result['title'] == 'Song Title' + assert result['authors'] == ['Author 1', 'Author 2'] + assert result['copyright'] == 'Copy thing/Copy thing 2' + assert result['topics'] == ['theme1', 'theme2'] + assert result['ccli_number'] == '1234_cclinumber_5678' + assert result['verses'] == [{'label': 'Verse 1', 'lyrics': 'verse thing 1\nline 2'}, + {'label': 'Verse 2', 'lyrics': 'verse thing 2'}] @patch('openlp.plugins.songs.lib.songselect.clean_song') @patch('openlp.plugins.songs.lib.songselect.Topic') @@ -455,7 +373,7 @@ class TestSongSelectImport(TestCase, TestMixin): MockedTopic.name.__eq__.return_value = False mocked_db_manager = MagicMock() mocked_db_manager.get_object_filtered.return_value = None - importer = SongSelectImport(mocked_db_manager) + importer = SongSelectImport(mocked_db_manager, MagicMock()) # WHEN: The song is saved to the database result = importer.save_song(song_dict) @@ -490,7 +408,7 @@ class TestSongSelectImport(TestCase, TestMixin): MockedAuthor.display_name.__eq__.return_value = False mocked_db_manager = MagicMock() mocked_db_manager.get_object_filtered.return_value = MagicMock() - importer = SongSelectImport(mocked_db_manager) + importer = SongSelectImport(mocked_db_manager, MagicMock()) # WHEN: The song is saved to the database result = importer.save_song(song_dict) @@ -525,7 +443,7 @@ class TestSongSelectImport(TestCase, TestMixin): MockedAuthor.display_name.__eq__.return_value = False mocked_db_manager = MagicMock() mocked_db_manager.get_object_filtered.return_value = None - importer = SongSelectImport(mocked_db_manager) + importer = SongSelectImport(mocked_db_manager, MagicMock()) # WHEN: The song is saved to the database result = importer.save_song(song_dict) @@ -539,6 +457,47 @@ class TestSongSelectImport(TestCase, TestMixin): MockedAuthor.populate.assert_called_with(first_name='Unknown', last_name='', display_name='Unknown') assert 1 == len(result.authors_songs), 'There should only be one author' + @patch('openlp.plugins.songs.lib.songselect.clean_song') + @patch('openlp.plugins.songs.lib.songselect.Topic') + @patch('openlp.plugins.songs.lib.songselect.Author') + def test_save_song_topics(self, MockedAuthor, MockedTopic, mocked_clean_song): + """ + Test that saving a song with topics performs the correct actions + Also check that a verse with no number is retitled to 1 + """ + # GIVEN: A song to save, and some mocked out objects + song_dict = { + 'title': 'Arky Arky', + 'authors': ['Public Domain'], + 'verses': [ + {'label': 'Verse', 'lyrics': 'The Lord told Noah: there\'s gonna be a floody, floody'}, + {'label': 'Chorus 1', 'lyrics': 'So, rise and shine, and give God the glory, glory'}, + {'label': 'Verse 2', 'lyrics': 'The Lord told Noah to build him an arky, arky'} + ], + 'copyright': 'Public Domain', + 'ccli_number': '123456', + 'topics': ['Old Testement', 'Flood'] + } + + def save_object(b): + b.topics = [] + MockedAuthor.display_name.__eq__.return_value = False + MockedTopic.name.__eq__.return_value = False + mocked_db_manager = MagicMock() + mocked_db_manager.get_object_filtered.return_value = None + mocked_db_manager.save_object = save_object + importer = SongSelectImport(mocked_db_manager, MagicMock()) + + # WHEN: The song is saved to the database + result = importer.save_song(song_dict) + + # THEN: The return value should be a Song class and the topics should have been added + assert isinstance(result, Song), 'The returned value should be a Song object' + mocked_clean_song.assert_called_with(mocked_db_manager, result) + assert MockedTopic.populate.call_count == 2, 'Should have created 2 new topics' + MockedTopic.populate.assert_called_with(name='Flood') + assert 1 == len(result.authors_songs), 'There should only be one author' + class TestSongSelectForm(TestCase, TestMixin): """ @@ -554,6 +513,17 @@ class TestSongSelectForm(TestCase, TestMixin): Registry.create() Registry().register('application', self.app) Registry().register('settings', MagicMock()) + self.grid_patcher = patch('openlp.plugins.songs.forms.songselectdialog.QtWidgets.QGridLayout') + self.web_patcher = patch('openlp.plugins.songs.forms.songselectdialog.WebEngineView') + self.vbox_patcher = patch('openlp.plugins.songs.forms.songselectdialog.QtWidgets.QVBoxLayout') + self.grid_patcher.start() + self.web_patcher.start() + self.vbox_patcher.start() + + def tearDown(self): + self.grid_patcher.stop() + self.web_patcher.stop() + self.vbox_patcher.stop() def test_create_form(self): """ @@ -570,156 +540,357 @@ class TestSongSelectForm(TestCase, TestMixin): assert mocked_plugin == ssform.plugin, 'The correct plugin should have been assigned' assert mocked_db_manager == ssform.db_manager, 'The correct db_manager should have been assigned' - @patch('openlp.plugins.songs.forms.songselectform.SongSelectImport') - @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.critical') - @patch('openlp.plugins.songs.forms.songselectform.translate') - def test_login_fails(self, mocked_translate, mocked_critical, MockedSongSelectImport): + @patch('openlp.plugins.songs.lib.songselect.SongSelectImport') + def test_initialise(self, mocked_ss_import): """ - Test that when the login fails, the form returns to the correct state + Test the initialise method """ - # GIVEN: A valid SongSelectForm with a mocked out SongSelectImport, and a bunch of mocked out controls - mocked_song_select_import = MagicMock() - mocked_song_select_import.login.return_value = False - MockedSongSelectImport.return_value = mocked_song_select_import - mocked_translate.side_effect = lambda *args: args[1] + # GIVEN: The SongSelectForm ssform = SongSelectForm(None, MagicMock(), MagicMock()) + + # WHEN: The initialise method is run ssform.initialise() - with patch.object(ssform, 'username_edit') as mocked_username_edit, \ - patch.object(ssform, 'password_edit') as mocked_password_edit, \ - patch.object(ssform, 'save_password_checkbox') as mocked_save_password_checkbox, \ - patch.object(ssform, 'login_button') as mocked_login_button, \ - patch.object(ssform, 'login_spacer') as mocked_login_spacer, \ - patch.object(ssform, 'login_progress_bar') as mocked_login_progress_bar, \ - patch.object(ssform.application, 'process_events') as mocked_process_events: - # WHEN: The login button is clicked, and the login is rigged to fail - ssform.on_login_button_clicked() + # THEN: The import object should exist, song var should be None, and the page hooked up + assert ssform.song is None + assert isinstance(ssform.song_select_importer, SongSelectImport), 'SongSelectImport object should be created' + assert ssform.webview.page.call_count == 2, 'Page should be called twice, once for each load handler' - # THEN: The right things should have happened in the right order - expected_username_calls = [call(False), call(True)] - expected_password_calls = [call(False), call(True)] - expected_save_password_calls = [call(False), call(True)] - expected_login_btn_calls = [call(False), call(True)] - expected_login_spacer_calls = [call(False), call(True)] - expected_login_progress_visible_calls = [call(True), call(False)] - expected_login_progress_value_calls = [call(0), call(0)] - assert expected_username_calls == mocked_username_edit.setEnabled.call_args_list, \ - 'The username edit should be disabled then enabled' - assert expected_password_calls == mocked_password_edit.setEnabled.call_args_list, \ - 'The password edit should be disabled then enabled' - assert expected_save_password_calls == mocked_save_password_checkbox.setEnabled.call_args_list, \ - 'The save password checkbox should be disabled then enabled' - assert expected_login_btn_calls == mocked_login_button.setEnabled.call_args_list, \ - 'The login button should be disabled then enabled' - assert expected_login_spacer_calls == mocked_login_spacer.setVisible.call_args_list, \ - 'Thee login spacer should be make invisible, then visible' - assert expected_login_progress_visible_calls == mocked_login_progress_bar.setVisible.call_args_list, \ - 'Thee login progress bar should be make visible, then invisible' - assert expected_login_progress_value_calls == mocked_login_progress_bar.setValue.call_args_list, \ - 'Thee login progress bar should have the right values set' - assert 2 == mocked_process_events.call_count, 'The process_events() method should be called twice' - mocked_critical.assert_called_with(ssform, 'Error Logging In', 'There was a problem logging in, ' - 'perhaps your username or password is ' - 'incorrect?') + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QDialog.exec') + def test_exec(self, mocked_exec): + """ + Test the exec method + """ + # GIVEN: The SongSelectForm + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_select_importer = MagicMock() + ssform.stacked_widget = MagicMock() + # WHEN: The initialise method is run + ssform.exec() + + # THEN: Should have reset webview, set stack to 0 and pass on the event + ssform.song_select_importer.reset_webview.assert_called_once() + ssform.stacked_widget.setCurrentIndex.assert_called_with(0) + mocked_exec.assert_called_once() + + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QDialog.done') + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QProgressDialog') + def test_done(self, mocked_prog_dialog, mocked_done): + """ + Test the done method closes th dialog + """ + # GIVEN: The SongSelectForm + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_select_importer = MagicMock() + + # WHEN: The initialise method is run + ssform.done('result') + + # THEN: Should have passed on the event + mocked_done.assert_called_once() + + def test_page_load_started(self): + """ + Test the page_load_started method + """ + # GIVEN: The SongSelectForm + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_progress_bar = MagicMock() + ssform.import_button = MagicMock() + ssform.view_button = MagicMock() + ssform.back_button = MagicMock() + ssform.url_bar = MagicMock() + + # WHEN: The method is run + ssform.page_load_started() + + # THEN: The UI should be set up accordingly (working bar and disabled buttons) + ssform.song_progress_bar.setMaximum.assert_called_with(0) + ssform.song_progress_bar.setVisible.assert_called_with(True) + ssform.import_button.setEnabled.assert_called_with(False) + ssform.view_button.setEnabled.assert_called_with(False) + ssform.back_button.setEnabled.assert_called_with(False) + + def test_page_loaded_login(self): + """ + Test the page_loaded method for a "Login" page + """ + # GIVEN: The SongSelectForm and mocked login page + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_select_importer = MagicMock() + ssform.song_select_importer.get_page_type.return_value = Pages.Login + ssform.signin_page_loaded = MagicMock() + ssform.url_bar = MagicMock() + + # WHEN: The method is run + ssform.page_loaded(True) + + # THEN: The signin page method should be called + ssform.signin_page_loaded.assert_called_once() + + def test_page_loaded_song(self): + """ + Test the page_loaded method for a "Song" page + """ + # GIVEN: The SongSelectForm and mocked song page + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_select_importer = MagicMock() + ssform.song_select_importer.get_page_type.return_value = Pages.Song + ssform.song_progress_bar = MagicMock() + ssform.url_bar = MagicMock() + + # WHEN: The method is run + ssform.page_loaded(True) + + # THEN: Progress bar should have been set max 3 (for loading song) + ssform.song_progress_bar.setMaximum.assert_called_with(3) + ssform.song_progress_bar.setVisible.call_count == 2 + + def test_page_loaded_other(self): + """ + Test the page_loaded method for an "Other" page + """ + # GIVEN: The SongSelectForm and mocked other page + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_select_importer = MagicMock() + ssform.song_select_importer.get_page_type.return_value = Pages.Other + ssform.song_progress_bar = MagicMock() + ssform.back_button = MagicMock() + ssform.url_bar = MagicMock() + + # WHEN: The method is run + ssform.page_loaded(True) + + # THEN: Back button should be available + ssform.back_button.setEnabled.assert_called_with(True) + + def test_signin_page_loaded(self): + """ + Test that the signin_page_loaded method calls the appropriate method + """ + # GIVEN: The SongSelectForm and mocked settings + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.song_select_importer = MagicMock() + ssform.settings.value = MagicMock(side_effect=['user', 'pass']) + + # WHEN: The method is run + ssform.signin_page_loaded() + + # THEN: Correct values should have been sent from the settings + ssform.song_select_importer.set_login_fields.assert_called_with('user', 'pass') + + @patch('openlp.plugins.songs.forms.songselectdialog.QtWidgets.QListWidgetItem') + def test_view_song(self, mock_qtlist): + """ + Test that the _view_song method does the important stuff + """ + # GIVEN: The SongSelectForm, mocks and a song + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.stacked_widget = MagicMock() + ssform.title_edit = MagicMock() + ssform.copyright_edit = MagicMock() + ssform.ccli_edit = MagicMock() + ssform.lyrics_table_widget = MagicMock() + ssform.author_list_widget = MagicMock() + ssform.song = { + 'title': 'Song Title', + 'copyright': 'copy thing', + 'ccli_number': '1234', + 'authors': ['Bob', 'Jo'], + 'verses': [{'lyrics': 'hello', 'label': 'Verse 1'}] + } + + # WHEN: The method is run + ssform._view_song() + + # THEN: Page should have changed in the stacked widget and ui should have been updated + ssform.stacked_widget.setCurrentIndex.assert_called_with(1) + ssform.title_edit.setText.assert_called_with('Song Title') + ssform.copyright_edit.setText.assert_called_with('copy thing') + ssform.ccli_edit.setText.assert_called_with('1234') + assert ssform.lyrics_table_widget.setItem.call_count > 0 + assert ssform.author_list_widget.addItem.call_count > 0 + + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.critical') + def test_view_song_invalid(self, mock_message): + """ + Test that the _view_song doesn't mess up when the song doesn't exist + """ + # GIVEN: The SongSelectForm, mocks and a song + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.stacked_widget = MagicMock() + ssform.song = None + + # WHEN: The method is run + ssform._view_song() + + # THEN: Page should not have changed and a warning should show + assert ssform.stacked_widget.setCurrentIndex.call_count == 0 + mock_message.assert_called_once() + + def test_on_url_bar_return_pressed(self): + """ + Test that the on_url_bar_return_pressed method changes the page + """ + # GIVEN: The SongSelectForm, mocks and a song + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssform.url_bar = MagicMock() + ssform.url_bar.text.return_value = "test" + ssform.song_select_importer = MagicMock() + + # WHEN: The method is run + ssform.on_url_bar_return_pressed() + + # THEN: Page should not have changed and a warning should show + ssform.song_select_importer.set_page.assert_called_with("test") + + @patch('openlp.plugins.songs.forms.songselectform.and_') + @patch('openlp.plugins.songs.forms.songselectform.Song') + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information') @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.question') @patch('openlp.plugins.songs.forms.songselectform.translate') - def test_on_import_yes_clicked(self, mocked_translate, mocked_question): + def test_on_import(self, mocked_trans, mocked_quest, mocked_info, mocked_song, mocked_and): """ Test that when a song is imported and the user clicks the "yes" button, the UI goes back to the previous page """ # GIVEN: A valid SongSelectForm with a mocked out QMessageBox.question() method - mocked_translate.side_effect = lambda *args: args[1] - mocked_question.return_value = QtWidgets.QMessageBox.Yes + mocked_trans.side_effect = lambda *args: args[1] + mocked_quest.return_value = QtWidgets.QMessageBox.Yes ssform = SongSelectForm(None, MagicMock(), MagicMock()) mocked_song_select_importer = MagicMock() ssform.song_select_importer = mocked_song_select_importer - ssform.song = None + ssform.song = {'ccli_number': '1234'} # WHEN: The import button is clicked, and the user clicks Yes with patch.object(ssform, 'on_back_button_clicked') as mocked_on_back_button_clicked: ssform.on_import_button_clicked() # THEN: The on_back_button_clicked() method should have been called - mocked_song_select_importer.save_song.assert_called_with(None) - mocked_question.assert_called_with(ssform, 'Song Imported', - 'Your song has been imported, would you like to import more songs?', - defaultButton=QtWidgets.QMessageBox.Yes) - mocked_on_back_button_clicked.assert_called_with() + mocked_song_select_importer.save_song.assert_called_with({'ccli_number': '1234'}) + mocked_quest.assert_not_called() + mocked_info.assert_called_once() + mocked_on_back_button_clicked.assert_called_with(True) assert ssform.song is None + @patch('openlp.plugins.songs.forms.songselectform.len') + @patch('openlp.plugins.songs.forms.songselectform.and_') + @patch('openlp.plugins.songs.forms.songselectform.Song') + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information') @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.question') @patch('openlp.plugins.songs.forms.songselectform.translate') - def test_on_import_no_clicked(self, mocked_translate, mocked_question): + def test_on_import_duplicate_yes_clicked(self, mock_trans, mock_q, mocked_info, mock_song, mock_and, mock_len): """ - Test that when a song is imported and the user clicks the "no" button, the UI exits + Test that when a duplicate song is imported and the user clicks the "yes" button, the song is imported """ # GIVEN: A valid SongSelectForm with a mocked out QMessageBox.question() method - mocked_translate.side_effect = lambda *args: args[1] - mocked_question.return_value = QtWidgets.QMessageBox.No + mock_len.return_value = 1 + mock_trans.side_effect = lambda *args: args[1] + mock_q.return_value = QtWidgets.QMessageBox.Yes ssform = SongSelectForm(None, MagicMock(), MagicMock()) mocked_song_select_importer = MagicMock() ssform.song_select_importer = mocked_song_select_importer - ssform.song = None + ssform.song = {'ccli_number': '1234'} # WHEN: The import button is clicked, and the user clicks Yes - with patch.object(ssform, 'done') as mocked_done: + with patch.object(ssform, 'on_back_button_clicked') as mocked_on_back_button_clicked: ssform.on_import_button_clicked() - # THEN: The on_back_button_clicked() method should have been called - mocked_song_select_importer.save_song.assert_called_with(None) - mocked_question.assert_called_with(ssform, 'Song Imported', - 'Your song has been imported, would you like to import more songs?', - defaultButton=QtWidgets.QMessageBox.Yes) - mocked_done.assert_called_with(QtWidgets.QDialog.Accepted) - assert ssform.song is None + # THEN: Should have been saved and the on_back_button_clicked() method should have been called + mocked_song_select_importer.save_song.assert_called_with({'ccli_number': '1234'}) + mock_q.assert_called_once() + mocked_info.assert_called_once() + mocked_on_back_button_clicked.assert_called_once() + assert ssform.song is None + + @patch('openlp.plugins.songs.forms.songselectform.len') + @patch('openlp.plugins.songs.forms.songselectform.and_') + @patch('openlp.plugins.songs.forms.songselectform.Song') + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information') + @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.question') + @patch('openlp.plugins.songs.forms.songselectform.translate') + def test_on_import_duplicate_no_clicked(self, mock_trans, mock_q, mocked_info, mock_song, mock_and, mock_len): + """ + Test that when a duplicate song is imported and the user clicks the "no" button, the UI exits + """ + # GIVEN: A valid SongSelectForm with a mocked out QMessageBox.question() method + mock_len.return_value = 1 + mock_trans.side_effect = lambda *args: args[1] + mock_q.return_value = QtWidgets.QMessageBox.No + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + mocked_song_select_importer = MagicMock() + ssform.song_select_importer = mocked_song_select_importer + ssform.song = {'ccli_number': '1234'} + + # WHEN: The import button is clicked, and the user clicks No + with patch.object(ssform, 'on_back_button_clicked') as mocked_on_back_button_clicked: + ssform.on_import_button_clicked() + + # THEN: Should have not been saved + assert mocked_song_select_importer.save_song.call_count == 0 + mock_q.assert_called_once() + mocked_info.assert_not_called() + mocked_on_back_button_clicked.assert_not_called() + assert ssform.song is not None + + def test_on_back_button_clicked_preview(self): + """ + Test that when the back button is clicked on preview screen, the stacked widget is set back one page + """ + # GIVEN: A SongSelect form, stacked widget on page 1 + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssimporter = MagicMock() + ssform.song_select_importer = MagicMock() + ssform.song_select_importer.set_home_page = ssimporter + with patch.object(ssform, 'stacked_widget') as mocked_stacked_widget: + mocked_stacked_widget.currentIndex.return_value = 1 + + # WHEN: The preview back button is clicked + ssform.on_back_button_clicked() + + # THEN: The stacked widget should be set back one page and webpage is NOT put back to the home page + mocked_stacked_widget.setCurrentIndex.assert_called_with(0) + ssimporter.assert_not_called() + + def test_on_back_button_clicked_force(self): + """ + Test that when the back button method is triggered with the force param set, + the page should be changed + """ + # GIVEN: A SongSelect form, stacked widget on page 1 + ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssimporter = MagicMock() + ssform.song_select_importer = MagicMock() + ssform.song_select_importer.set_home_page = ssimporter + with patch.object(ssform, 'stacked_widget') as mocked_stacked_widget: + mocked_stacked_widget.currentIndex.return_value = 1 + + # WHEN: The preview back button is clicked with force param + ssform.on_back_button_clicked(True) + + # THEN: The stacked widget should be set back one page and webpage is NOT put back to the home page + mocked_stacked_widget.setCurrentIndex.assert_called_with(0) + ssimporter.assert_called_once() def test_on_back_button_clicked(self): """ - Test that when the back button is clicked, the stacked widget is set back one page + Test that when the back button is clicked, the stacked widget is set to page 0 + and set to home page """ - # GIVEN: A SongSelect form + # GIVEN: A SongSelect form, stacked widget on page 0 ssform = SongSelectForm(None, MagicMock(), MagicMock()) + ssimporter = MagicMock() + ssform.song_select_importer = MagicMock() + ssform.song_select_importer.set_home_page = ssimporter + with patch.object(ssform, 'stacked_widget') as mocked_stacked_widget: + mocked_stacked_widget.currentIndex.return_value = 0 # WHEN: The back button is clicked - with patch.object(ssform, 'stacked_widget') as mocked_stacked_widget, \ - patch.object(ssform, 'search_combobox') as mocked_search_combobox: ssform.on_back_button_clicked() # THEN: The stacked widget should be set back one page - mocked_stacked_widget.setCurrentIndex.assert_called_with(1) - mocked_search_combobox.setFocus.assert_called_with() - - @patch('openlp.plugins.songs.forms.songselectform.QtWidgets.QMessageBox.information') - def test_on_search_show_info(self, mocked_information): - """ - Test that when the search_show_info signal is emitted, the on_search_show_info() method shows a dialog - """ - # GIVEN: A SongSelect form - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - expected_title = 'Test Title' - expected_text = 'This is a test' - - # WHEN: on_search_show_info is called - ssform.on_search_show_info(expected_title, expected_text) - - # THEN: An information dialog should be shown - mocked_information.assert_called_with(ssform, expected_title, expected_text) - - def test_update_login_progress(self): - """ - Test the _update_login_progress() method - """ - # GIVEN: A SongSelect form - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - - # WHEN: _update_login_progress() is called - with patch.object(ssform, 'login_progress_bar') as mocked_login_progress_bar: - mocked_login_progress_bar.value.return_value = 3 - ssform._update_login_progress() - - # THEN: The login progress bar should be updated - mocked_login_progress_bar.setValue.assert_called_with(4) + mocked_stacked_widget.setCurrentIndex.assert_called_with(0) + ssimporter.assert_called_with() def test_update_song_progress(self): """ @@ -734,106 +905,21 @@ class TestSongSelectForm(TestCase, TestMixin): ssform._update_song_progress() # THEN: The song progress bar should be updated - mocked_song_progress_bar.setValue.assert_called_with(3) - - def test_on_search_results_widget_double_clicked(self): - """ - Test that a song is retrieved when a song in the results list is double-clicked - """ - # GIVEN: A SongSelect form - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - expected_song = {'title': 'Amazing Grace'} - - # WHEN: A song result is double-clicked - with patch.object(ssform, '_view_song') as mocked_view_song: - ssform.on_search_results_widget_double_clicked(expected_song) - - # THEN: The song is fetched and shown to the user - mocked_view_song.assert_called_with(expected_song) + mocked_song_progress_bar.setValue.assert_called_with(3) def test_on_view_button_clicked(self): """ - Test that a song is retrieved when the view button is clicked + Test that view song function is run when the view button is clicked """ # GIVEN: A SongSelect form ssform = SongSelectForm(None, MagicMock(), MagicMock()) - expected_song = {'title': 'Amazing Grace'} # WHEN: A song result is double-clicked - with patch.object(ssform, '_view_song') as mocked_view_song, \ - patch.object(ssform, 'search_results_widget') as mocked_search_results_widget: - mocked_search_results_widget.currentItem.return_value = expected_song + with patch.object(ssform, '_view_song') as mocked_view_song: ssform.on_view_button_clicked() # THEN: The song is fetched and shown to the user - mocked_view_song.assert_called_with(expected_song) - - def test_on_search_results_widget_selection_changed(self): - """ - Test that the view button is updated when the search results list is changed - """ - # GIVEN: A SongSelect form - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - - # WHEN: There is at least 1 item selected - with patch.object(ssform, 'search_results_widget') as mocked_search_results_widget, \ - patch.object(ssform, 'view_button') as mocked_view_button: - mocked_search_results_widget.selectedItems.return_value = [1] - ssform.on_search_results_widget_selection_changed() - - # THEN: The view button should be enabled - mocked_view_button.setEnabled.assert_called_with(True) - - @patch('openlp.plugins.songs.forms.songselectform.SongSelectImport') - def test_on_stop_button_clicked(self, MockedSongSelectImport): - """ - Test that the search is stopped when the stop button is clicked - """ - # GIVEN: A mocked SongSelectImporter and a SongSelect form - mocked_song_select_importer = MagicMock() - MockedSongSelectImport.return_value = mocked_song_select_importer - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - ssform.initialise() - - # WHEN: The stop button is clicked - ssform.on_stop_button_clicked() - - # THEN: The view button, search box and search button should be enabled - mocked_song_select_importer.stop.assert_called_with() - assert ssform.search_button.isEnabled() is True - assert ssform.search_combobox.isEnabled() is True - - @patch('openlp.plugins.songs.forms.songselectform.run_thread') - @patch('openlp.plugins.songs.forms.songselectform.SearchWorker') - def test_on_search_button_clicked(self, MockedSearchWorker, mocked_run_thread): - """ - Test that search fields are disabled when search button is clicked. - """ - # GIVEN: A mocked SongSelect form - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - ssform.initialise() - - # WHEN: The search button is clicked - ssform.on_search_button_clicked() - - # THEN: The search box and search button should be disabled - assert ssform.search_button.isEnabled() is False - assert ssform.search_combobox.isEnabled() is False - - def test_on_search_finished(self): - """ - Test that search fields are enabled when search is finished. - """ - # GIVEN: A mocked SongSelect form - ssform = SongSelectForm(None, MagicMock(), MagicMock()) - ssform.initialise() - - # WHEN: The search is finished - ssform.on_search_finished() - - # THEN: The search box and search button should be enabled - assert ssform.search_button.isEnabled() is True - assert ssform.search_combobox.isEnabled() is True + mocked_view_song.assert_called_with() class TestSongSelectFileImport(SongImportTestHelper): @@ -849,85 +935,3 @@ class TestSongSelectFileImport(SongImportTestHelper): """ self.file_import([TEST_PATH / 'TestSong.bin'], self.load_external_result_data(TEST_PATH / 'TestSong-bin.json')) self.file_import([TEST_PATH / 'TestSong.txt'], self.load_external_result_data(TEST_PATH / 'TestSong-txt.json')) - - -class TestSearchWorker(TestCase, TestMixin): - """ - Test the SearchWorker class - """ - def test_constructor(self): - """ - Test the SearchWorker constructor - """ - # GIVEN: An importer mock object and some search text - importer = MagicMock() - search_text = 'Jesus' - - # WHEN: The search worker is created - worker = SearchWorker(importer, search_text) - - # THEN: The correct values should be set - assert importer is worker.importer, 'The importer should be the right object' - assert search_text == worker.search_text, 'The search text should be correct' - - def test_start(self): - """ - Test the start() method of the SearchWorker class - """ - # GIVEN: An importer mock object, some search text and an initialised SearchWorker - importer = MagicMock() - importer.search.return_value = ['song1', 'song2'] - search_text = 'Jesus' - worker = SearchWorker(importer, search_text) - - # WHEN: The start() method is called - with patch.object(worker, 'finished') as mocked_finished, patch.object(worker, 'quit') as mocked_quit: - worker.start() - - # THEN: The "finished" and "quit" signals should be emitted - importer.search.assert_called_with(search_text, 1000, worker._found_song_callback) - mocked_finished.emit.assert_called_with() - mocked_quit.emit.assert_called_with() - - @patch('openlp.plugins.songs.forms.songselectform.translate') - def test_start_over_1000_songs(self, mocked_translate): - """ - Test the start() method of the SearchWorker class when it finds over 1000 songs - """ - # GIVEN: An importer mock object, some search text and an initialised SearchWorker - mocked_translate.side_effect = lambda x, y: y - importer = MagicMock() - importer.search.return_value = ['song%s' % num for num in range(1050)] - search_text = 'Jesus' - worker = SearchWorker(importer, search_text) - - # WHEN: The start() method is called - with patch.object(worker, 'finished') as mocked_finished, patch.object(worker, 'quit') as mocked_quit, \ - patch.object(worker, 'show_info') as mocked_show_info: - worker.start() - - # THEN: The "finished" and "quit" signals should be emitted - importer.search.assert_called_with(search_text, 1000, worker._found_song_callback) - mocked_show_info.emit.assert_called_with('More than 1000 results', 'Your search has returned more than 1000 ' - 'results, it has been stopped. Please ' - 'refine your search to fetch better ' - 'results.') - mocked_finished.emit.assert_called_with() - mocked_quit.emit.assert_called_with() - - def test_found_song_callback(self): - """ - Test that when the _found_song_callback() function is called, the "found_song" signal is emitted - """ - # GIVEN: An importer mock object, some search text and an initialised SearchWorker - importer = MagicMock() - search_text = 'Jesus' - song = {'title': 'Amazing Grace'} - worker = SearchWorker(importer, search_text) - - # WHEN: The start() method is called - with patch.object(worker, 'found_song') as mocked_found_song: - worker._found_song_callback(song) - - # THEN: The "found_song" signal should have been emitted - mocked_found_song.emit.assert_called_with(song) diff --git a/tests/functional/openlp_plugins/songs/test_songstab.py b/tests/functional/openlp_plugins/songs/test_songstab.py index 56ac335ce..d9d5c7220 100644 --- a/tests/functional/openlp_plugins/songs/test_songstab.py +++ b/tests/functional/openlp_plugins/songs/test_songstab.py @@ -24,7 +24,7 @@ This module contains tests for the lib submodule of the Images plugin. import pytest from unittest.mock import MagicMock, patch -from PyQt5 import QtCore +from PyQt5 import QtCore, QtWidgets from openlp.core.common.registry import Registry from openlp.plugins.songs.lib.songstab import SongsTab @@ -135,6 +135,38 @@ def test_neolatin_notation_button(form): assert form.chord_notation == 'neo-latin' +@patch('openlp.plugins.songs.lib.songstab.QtWidgets.QMessageBox.question') +@patch('openlp.core.common.settings.Settings.setValue') +def test_password_change(mocked_settings_set_val, mocked_question, form): + """ + Test the ccli password sends a warning when changed, and saves when accepted + """ + # GIVEN: Warning is accepted and new password set + form.ccli_password.setText('new_password') + mocked_question.return_value = QtWidgets.QMessageBox.Yes + # WHEN: save is invoked + form.save() + # THEN: footer should not have been saved (one less call than the change test below) + mocked_question.assert_called_once() + assert mocked_settings_set_val.call_count == 9 + + +@patch('openlp.plugins.songs.lib.songstab.QtWidgets.QMessageBox.question') +@patch('openlp.core.common.settings.Settings.setValue') +def test_password_change_cancelled(mocked_settings_set_val, mocked_question, form): + """ + Test the ccli password sends a warning when changed and does not save when cancelled + """ + # GIVEN: Warning is not accepted and new password set + form.ccli_password.setText('new_password') + mocked_question.return_value = QtWidgets.QMessageBox.No + # WHEN: save is invoked + form.save() + # THEN: footer should not have been saved (one less call than the change test below) + mocked_question.assert_called_once() + assert mocked_settings_set_val.call_count == 8 + + @patch('openlp.core.common.settings.Settings.setValue') def test_footer_nochange(mocked_settings_set_val, form): """ @@ -144,7 +176,7 @@ def test_footer_nochange(mocked_settings_set_val, form): # WHEN: save is invoked form.save() # THEN: footer should not have been saved (one less call than the change test below) - assert mocked_settings_set_val.call_count == 7 + assert mocked_settings_set_val.call_count == 9 @patch('openlp.core.common.settings.Settings.setValue') @@ -157,7 +189,7 @@ def test_footer_change(mocked_settings_set_val, form): # WHEN: save is invoked form.save() # THEN: footer should have been saved (one more call to setValue than the nochange test) - assert mocked_settings_set_val.call_count == 8 + assert mocked_settings_set_val.call_count == 10 assert form.footer_edit_box.toPlainText() == 'A new footer'