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 = '''
+
+
+
+
+
+
+ '''
+ 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'