diff --git a/openlp/core/ui/icons.py b/openlp/core/ui/icons.py
index 6f8b3e4bd..8a565b4bc 100644
--- a/openlp/core/ui/icons.py
+++ b/openlp/core/ui/icons.py
@@ -100,6 +100,7 @@ class UiIcons(metaclass=Singleton):
'open': {'icon': 'fa.folder-open'},
'optical': {'icon': 'fa.file-video-o'},
'pause': {'icon': 'fa.pause'},
+ 'planning_center': {'icon': 'fa.cloud-download'},
'play': {'icon': 'fa.play'},
'player': {'icon': 'fa.tablet'},
'plugin_list': {'icon': 'fa.puzzle-piece'},
diff --git a/openlp/core/ui/settingsform.py b/openlp/core/ui/settingsform.py
index 7e132e5b4..c4ee2f3f6 100644
--- a/openlp/core/ui/settingsform.py
+++ b/openlp/core/ui/settingsform.py
@@ -64,7 +64,7 @@ class SettingsForm(QtWidgets.QDialog, Ui_SettingsDialog, RegistryProperties):
self.advanced_tab = None
self.api_tab = None
- def exec(self):
+ def exec(self, starting_tab_name=None):
"""
Execute the form
"""
@@ -84,8 +84,14 @@ class SettingsForm(QtWidgets.QDialog, Ui_SettingsDialog, RegistryProperties):
for plugin in State().list_plugins():
if plugin.settings_tab:
self.insert_tab(plugin.settings_tab, plugin.is_active())
- self.setting_list_widget.setCurrentRow(0)
self.setting_list_widget.blockSignals(False)
+ starting_tab_row = 0
+ for index in range(self.setting_list_widget.count()):
+ item = self.setting_list_widget.item(index)
+ if item.text() == starting_tab_name:
+ starting_tab_row = index
+ break
+ self.setting_list_widget.setCurrentRow(starting_tab_row)
return QtWidgets.QDialog.exec(self)
def insert_tab(self, tab_widget, is_visible=True):
diff --git a/openlp/plugins/custom/lib/mediaitem.py b/openlp/plugins/custom/lib/mediaitem.py
index fe4379072..313d70c97 100644
--- a/openlp/plugins/custom/lib/mediaitem.py
+++ b/openlp/plugins/custom/lib/mediaitem.py
@@ -231,6 +231,7 @@ class CustomMediaItem(MediaManagerItem):
service_item.add_capability(ItemCapabilities.CanLoop)
service_item.add_capability(ItemCapabilities.CanSoftBreak)
service_item.add_capability(ItemCapabilities.OnLoadUpdate)
+ service_item.add_capability(ItemCapabilities.CanWordSplit)
custom_slide = self.plugin.db_manager.get_object(CustomSlide, item_id)
title = custom_slide.title
credit = custom_slide.credits
diff --git a/openlp/plugins/planningcenter/__init__.py b/openlp/plugins/planningcenter/__init__.py
new file mode 100644
index 000000000..c5f2b3d5e
--- /dev/null
+++ b/openlp/plugins/planningcenter/__init__.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`planningcenter` module provides the PlanningCenter plugin.
+The PlanningCenter plugin interfaces with the PlanningCenter v2 API to
+download services into OpenLP. """
diff --git a/openlp/plugins/planningcenter/forms/selectplandialog.py b/openlp/plugins/planningcenter/forms/selectplandialog.py
new file mode 100644
index 000000000..16059776c
--- /dev/null
+++ b/openlp/plugins/planningcenter/forms/selectplandialog.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.forms.selectplandialog` module contains the user interface code for the dialog
+"""
+from PyQt5 import QtWidgets
+
+from openlp.core.common.i18n import translate
+
+
+class Ui_SelectPlanDialog(object):
+ """
+ The actual Qt components that make up the dialog.
+ """
+ def setup_ui(self, planningcenter_dialog):
+ planningcenter_dialog.setObjectName('planningcenter_dialog')
+ planningcenter_dialog.resize(400, 280)
+ self.planningcenter_layout = QtWidgets.QFormLayout(planningcenter_dialog)
+ self.planningcenter_layout.setContentsMargins(50, 50, 50, 50)
+ self.planningcenter_layout.setSpacing(8)
+ self.planningcenter_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow)
+ # Service Type GUI Elements -- service_type combo_box
+ self.service_type_label = QtWidgets.QLabel(planningcenter_dialog)
+ self.service_type_combo_box = QtWidgets.QComboBox(planningcenter_dialog)
+ self.planningcenter_layout.addRow(self.service_type_label, self.service_type_combo_box)
+ # Plan Selection GUI Elements
+ self.plan_selection_label = QtWidgets.QLabel(planningcenter_dialog)
+ self.plan_selection_combo_box = QtWidgets.QComboBox(planningcenter_dialog)
+ self.planningcenter_layout.addRow(self.plan_selection_label, self.plan_selection_combo_box)
+ # Theme List for Songs and Custom Slides
+ self.song_theme_selection_label = QtWidgets.QLabel(planningcenter_dialog)
+ self.song_theme_selection_combo_box = QtWidgets.QComboBox(planningcenter_dialog)
+ self.planningcenter_layout.addRow(self.song_theme_selection_label, self.song_theme_selection_combo_box)
+ self.slide_theme_selection_label = QtWidgets.QLabel(planningcenter_dialog)
+ self.slide_theme_selection_combo_box = QtWidgets.QComboBox(planningcenter_dialog)
+ self.planningcenter_layout.addRow(self.slide_theme_selection_label, self.slide_theme_selection_combo_box)
+ # Import Button
+ self.button_layout = QtWidgets.QDialogButtonBox(planningcenter_dialog)
+ self.import_as_new_button = QtWidgets.QPushButton(planningcenter_dialog)
+ self.button_layout.addButton(self.import_as_new_button, QtWidgets.QDialogButtonBox.AcceptRole)
+ self.update_existing_button = QtWidgets.QPushButton(planningcenter_dialog)
+ self.button_layout.addButton(self.update_existing_button, QtWidgets.QDialogButtonBox.AcceptRole)
+ self.edit_auth_button = QtWidgets.QPushButton(planningcenter_dialog)
+ self.button_layout.addButton(self.edit_auth_button, QtWidgets.QDialogButtonBox.ActionRole)
+ self.planningcenter_layout.addRow(self.button_layout)
+ self.retranslate_ui(planningcenter_dialog)
+
+ def retranslate_ui(self, planningcenter_dialog):
+ """
+ Translate the GUI.
+ """
+ planningcenter_dialog.setWindowTitle(translate('PlanningCenterPlugin.PlanningCenterForm',
+ 'Planning Center Online Service Importer'))
+ self.service_type_label.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Service Type'))
+ self.plan_selection_label.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Select Plan'))
+ self.import_as_new_button.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Import New'))
+ self.import_as_new_button.setToolTip(translate('PlanningCenterPlugin.PlanningCenterForm',
+ 'Import As New Service'))
+ self.update_existing_button.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Refresh Service'))
+ self.update_existing_button.setToolTip(translate('PlanningCenterPlugin.PlanningCenterForm',
+ 'Refresh Existing Service from Planning Center. \
+ This will update song lyrics or item orders that \
+ have changed'))
+ self.edit_auth_button.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Edit Authentication'))
+ self.edit_auth_button.setToolTip(translate('PlanningCenterPlugin.PlanningCenterForm', 'Edit the Application \
+ ID and Secret Code to login to Planning Center Online'))
+ self.song_theme_selection_label.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Song Theme'))
+ self.slide_theme_selection_label.setText(translate('PlanningCenterPlugin.PlanningCenterForm', 'Slide Theme'))
diff --git a/openlp/plugins/planningcenter/forms/selectplanform.py b/openlp/plugins/planningcenter/forms/selectplanform.py
new file mode 100755
index 000000000..9eb3f6a93
--- /dev/null
+++ b/openlp/plugins/planningcenter/forms/selectplanform.py
@@ -0,0 +1,289 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.forms.selectplanform` module contains
+the GUI for the PlanningCenter Service importer
+"""
+import logging
+import re
+from datetime import date, datetime
+
+from PyQt5 import QtCore, QtWidgets
+
+from openlp.core.common.i18n import translate
+from openlp.core.common.registry import Registry
+from openlp.core.common.settings import Settings
+from openlp.plugins.bibles.lib import parse_reference
+from openlp.plugins.planningcenter.forms.selectplandialog import Ui_SelectPlanDialog
+from openlp.plugins.planningcenter.lib.customimport import PlanningCenterCustomImport
+from openlp.plugins.planningcenter.lib.planningcenter_api import PlanningCenterAPI
+from openlp.plugins.planningcenter.lib.songimport import PlanningCenterSongImport
+
+log = logging.getLogger(__name__)
+
+
+class SelectPlanForm(QtWidgets.QDialog, Ui_SelectPlanDialog):
+ """
+ The :class:`PlanningCenterForm` class is the PlanningCenter dialog.
+ """
+
+ def __init__(self, parent=None, plugin=None):
+ QtWidgets.QDialog.__init__(self, parent, QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowTitleHint)
+ self.plugin = plugin
+ # create an Planning Center API Object
+ application_id = Settings().value("planningcenter/application_id")
+ secret = Settings().value("planningcenter/secret")
+ self.planning_center_api = PlanningCenterAPI(application_id, secret)
+ self.setup_ui(self)
+ self.service_type_combo_box.currentIndexChanged.connect(self.on_service_type_combobox_changed)
+ self.plan_selection_combo_box.currentIndexChanged.connect(self.on_plan_selection_combobox_changed)
+ self.import_as_new_button.clicked.connect(self.on_import_as_new_button_clicked)
+ self.update_existing_button.clicked.connect(self.on_update_existing_button_clicked)
+ self.edit_auth_button.clicked.connect(self.on_edit_auth_button_clicked)
+
+ def exec(self):
+ """
+ Execute the dialog. This method sets everything back to its initial
+ values.
+ """
+ self.import_as_new_button.setEnabled(False)
+ self.update_existing_button.setEnabled(False)
+ # check our credentials and connection to the PlanningCenter server
+ organization = self.planning_center_api.check_credentials()
+ if len(organization) == 0:
+ QtWidgets.QMessageBox.warning(self.parent(), "Authentication Failed", "Authentiation Failed. Check your \
+ credentials in OpenLP Settings", QtWidgets.QMessageBox.Ok)
+ return
+ # set the Service Type Dropdown Box from PCO
+ service_types_list = self.planning_center_api.get_service_type_list()
+ self.service_type_combo_box.clear()
+ for service_type in service_types_list:
+ self.service_type_combo_box.addItem(service_type['attributes']['name'], service_type['id'])
+ self.service_type_combo_box.setCurrentIndex(0)
+ self.on_plan_selection_combobox_changed()
+ # Set the 2 lists of themes
+ theme_manager = Registry().get('theme_manager')
+ for theme in theme_manager.get_themes():
+ self.song_theme_selection_combo_box.addItem(theme)
+ self.slide_theme_selection_combo_box.addItem(theme)
+
+ return QtWidgets.QDialog.exec(self)
+
+ def done(self, result_code):
+ """
+ Close dialog.
+
+ :param r: The result of the dialog.
+ """
+ log.debug('Closing PlanningCenterForm')
+ return QtWidgets.QDialog.done(self, result_code)
+
+ def on_edit_auth_button_clicked(self):
+ """
+ Open the edit auth screen
+ """
+ self.done(QtWidgets.QDialog.Accepted)
+ settings_form = Registry().get('settings_form')
+ settings_form.exec(translate('PlanningCenterPlugin', 'PlanningCenter'))
+
+ def on_service_type_combobox_changed(self):
+ """
+ Set the plan_selection_combo_box content based upon the current service_type_combo_box setting.
+ """
+ # set the Plan Dropdown Box from PCO
+ service_type_id = self.service_type_combo_box.itemData(self.service_type_combo_box.currentIndex())
+ if service_type_id:
+ plan_list = self.planning_center_api.get_plan_list(service_type_id)
+ self.plan_selection_combo_box.clear()
+ self.plan_selection_combo_box.addItem('Select Plan Date')
+ self.plan_selection_combo_box.setCurrentIndex(0)
+ # Get Today's date and see if it is listed... if it is, then select it in the combobox
+ today = date.today()
+ for plan in plan_list:
+ self.plan_selection_combo_box.addItem(plan['attributes']['dates'], plan['id'])
+ # sort_date=str: 2018-12-21T19:00:00Z
+ plan_datetime = datetime.strptime(plan['attributes']['sort_date'].rstrip("Z"), '%Y-%m-%dT%H:%M:%S')
+ plan_date = date(plan_datetime.year, plan_datetime.month, plan_datetime.day)
+ # if we have any date that matches today or in the future, select it
+ if plan_date >= today:
+ self.plan_selection_combo_box.setCurrentIndex(self.plan_selection_combo_box.count() - 1)
+ self.import_as_new_button.setEnabled(True)
+ self.update_existing_button.setEnabled(True)
+
+ def on_plan_selection_combobox_changed(self):
+ """
+ Set the Import button enable/disable based upon the current plan_selection_combo_box setting.
+ """
+ current_index = self.plan_selection_combo_box.currentIndex()
+ if current_index == 0 or current_index == -1:
+ self.import_as_new_button.setEnabled(False)
+ self.update_existing_button.setEnabled(False)
+ else:
+ self.import_as_new_button.setEnabled(True)
+ self.update_existing_button.setEnabled(True)
+
+ def on_update_existing_button_clicked(self):
+ """
+ Call the import function but tell it to also do an update so that it can
+ keep changed items
+ """
+ self._do_import(update=True)
+ self.done(QtWidgets.QDialog.Accepted)
+
+ def on_import_as_new_button_clicked(self):
+ """
+ Create a new service and import all of the PCO items into it
+ """
+ self._do_import(update=False)
+ self.done(QtWidgets.QDialog.Accepted)
+
+ def _do_import(self, update=False):
+ """
+ Utility function to perform the import or update as requested
+ """
+ service_manager = Registry().get('service_manager')
+ old_service_items = []
+ if update:
+ old_service_items = service_manager.service_items.copy()
+ service_manager.new_file()
+ else:
+ service_manager.on_new_service_clicked()
+ # we only continue here if the service_manager is now empty
+ if len(service_manager.service_items) == 0:
+ service_manager.application.set_busy_cursor()
+ # get the plan ID for the current plan selection
+ plan_id = self.plan_selection_combo_box.itemData(self.plan_selection_combo_box.currentIndex())
+ # get the items array from Planning Center
+ planning_center_items_dict = self.planning_center_api.get_items_dict(plan_id)
+ service_manager.main_window.display_progress_bar(len(planning_center_items_dict['data']))
+ # convert the planning center dict to Songs and Add them to the ServiceManager
+ planning_center_id_to_openlp_id = {}
+ for item in planning_center_items_dict['data']:
+ item_title = item['attributes']['title']
+ media_type = ''
+ openlp_id = -1
+ if item['attributes']['item_type'] == 'song':
+ arrangement_id = item['relationships']['arrangement']['data']['id']
+ song_id = item['relationships']['song']['data']['id']
+ if song_id not in planning_center_id_to_openlp_id:
+ # get arrangement from "included" resources
+ arrangement_data = {}
+ song_data = {}
+ for included_item in planning_center_items_dict['included']:
+ if included_item['type'] == 'Song' and included_item['id'] == song_id:
+ song_data = included_item
+ elif included_item['type'] == 'Arrangement' and included_item['id'] == arrangement_id:
+ arrangement_data = included_item
+ # if we have both song and arrangement set, stop iterating
+ if len(song_data) and len(arrangement_data):
+ break
+ author = song_data['attributes']['author']
+ lyrics = arrangement_data['attributes']['lyrics']
+ arrangement_updated_at = datetime.strptime(arrangement_data['attributes']['updated_at'].
+ rstrip("Z"), '%Y-%m-%dT%H:%M:%S')
+ # start importing the song
+ planning_center_import = PlanningCenterSongImport()
+ theme_name = self.song_theme_selection_combo_box.currentText()
+ openlp_id = planning_center_import.add_song(item_title, author, lyrics,
+ theme_name, arrangement_updated_at)
+ planning_center_id_to_openlp_id[song_id] = openlp_id
+ openlp_id = planning_center_id_to_openlp_id[song_id]
+ media_type = 'songs'
+ else:
+ # if we have "details" for the item, create slides from those
+ html_details = item['attributes']['html_details']
+ theme_name = self.slide_theme_selection_combo_box.currentText()
+ custom_import = PlanningCenterCustomImport()
+ openlp_id = custom_import.add_slide(item_title, html_details, theme_name)
+ media_type = 'custom'
+ # add the media to the service
+ media_type_plugin = Registry().get(media_type)
+ # the variable suffix names below for "songs" is "song", so change media_type to song
+ media_type_suffix = media_type
+ if media_type == 'songs':
+ media_type_suffix = 'song'
+ # turn on remote song feature to add to service
+ media_type_plugin.remote_triggered = True
+ setattr(media_type_plugin, "remote_{0}".format(media_type_suffix), openlp_id)
+ media_type_plugin.add_to_service(remote=openlp_id)
+ # also add verse references if they are there
+ if media_type == 'custom' and not html_details:
+ # check if the slide title is also a verse reference
+ # get a reference to the bible manager
+ bible_media = Registry().get('bibles')
+ bibles = bible_media.plugin.manager.get_bibles()
+ # get the current bible selected from the bibles plugin screen
+ bible = bible_media.version_combo_box.currentText()
+ if len(bible) == 0 and len(bibles) > 0:
+ # if we have no bible in the version_combo_box, but we have
+ # one or more bibles available, use one of those
+ bible = next(iter(bibles))
+ language_selection = bible_media.plugin.manager.get_language_selection(bible)
+ # replace long dashes with normal dashes -- why do these get inserted in PCO?
+ tmp_item_title = re.sub('–', '-', item_title)
+ ref_list = parse_reference(tmp_item_title, bibles[bible], language_selection)
+ if ref_list:
+ bible_media.search_results = bibles[bible].get_verses(ref_list)
+ bible_media.list_view.clear()
+ bible_media.display_results()
+ bible_media.add_to_service()
+ service_manager.main_window.increment_progress_bar()
+ if update:
+ for old_service_item in old_service_items:
+ # see if this service_item contained within the current set of service items
+ # see if we have this same value in the new service
+ for service_index, service_item in enumerate(service_manager.service_items):
+ # we can compare songs to songs and custom to custom but not between them
+ if old_service_item['service_item'].name == 'songs' and \
+ service_item['service_item'].name == 'songs':
+ if old_service_item['service_item'].audit == service_item['service_item'].audit:
+ # get the timestamp from the xml of both the old and new and compare...
+ # modifiedDate="2018-06-30T18:44:35Z"
+ old_match = re.search('modifiedDate="(.+?)Z*"',
+ old_service_item['service_item'].xml_version)
+ old_datetime = datetime.strptime(old_match.group(1), '%Y-%m-%dT%H:%M:%S')
+ new_match = re.search('modifiedDate="(.+?)Z*"',
+ service_item['service_item'].xml_version)
+ new_datetime = datetime.strptime(new_match.group(1), '%Y-%m-%dT%H:%M:%S')
+ # if old timestamp is more recent than new, then copy old to new
+ if old_datetime > new_datetime:
+ service_manager.service_items[service_index] = old_service_item
+ break
+ elif old_service_item['service_item'].name == 'custom' and \
+ service_item['service_item'].name == 'custom':
+ """ we don't get actual slide content from the V2 PC API, so all we create by default is a
+ single slide with matching title and content. If the content
+ is different between the old serviceitem (previously imported
+ from PC and the new content that we are importing now, then
+ the assumption is that we updated this content and we want to
+ keep the old content after this update. If we actually updated
+ something on the PC site in this slide, it would have a
+ different title because that is all we can get the v2API """
+ if old_service_item['service_item'].title == service_item['service_item'].title:
+ if old_service_item['service_item'].slides != service_item['service_item'].slides:
+ service_manager.service_items[service_index] = old_service_item
+ break
+ service_manager.main_window.finished_progress_bar()
+ # select the first item
+ item = service_manager.service_manager_list.topLevelItem(0)
+ service_manager.service_manager_list.setCurrentItem(item)
+ service_manager.repaint_service_list(-1, -1)
+ service_manager.application.set_normal_cursor()
diff --git a/openlp/plugins/planningcenter/lib/customimport.py b/openlp/plugins/planningcenter/lib/customimport.py
new file mode 100644
index 000000000..b8ceb293c
--- /dev/null
+++ b/openlp/plugins/planningcenter/lib/customimport.py
@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.lib.customimport` module provides
+a function that imports a single custom slide into the database and returns
+the database ID of the slide. This mimics the implementation for SongPlugin
+that was used to import songs from Planning Center.
+"""
+import re
+
+from openlp.core.common.i18n import translate
+from openlp.core.common.registry import Registry
+from openlp.core.lib.formattingtags import FormattingTags
+from openlp.plugins.custom.lib.customxmlhandler import CustomXMLBuilder
+from openlp.plugins.custom.lib.db import CustomSlide
+
+
+class PlanningCenterCustomImport(object):
+ """
+ Creates a custom slide and returns the database ID of that slide
+
+ :param item_title: The text to put on the slide.
+ :param html_details: The "details" element from PCO, with html formatting
+ :param theme_name: The theme_name to use for the slide.
+ """
+ def add_slide(self, item_title, html_details, theme_name):
+ custom_slide = CustomSlide()
+ custom_slide.title = item_title
+ sxml = CustomXMLBuilder()
+ slide_content = ''
+ if html_details is None:
+ slide_content = item_title
+ else:
+ # we need non-html here, but the input is html
+ slide_content = self._process_details(html_details)
+ sxml.add_verse_to_lyrics('custom', str(1), slide_content)
+ custom_slide.text = str(sxml.extract_xml(), 'utf-8')
+ custom_slide.credits = 'pco'
+ custom_slide.theme_name = theme_name
+ custom = Registry().get('custom')
+ custom_db_manager = custom.plugin.db_manager
+ custom_db_manager.save_object(custom_slide)
+ return custom_slide.id
+ """
+ Converts the "details" section of a PCO slide into text suitable for
+ slides in OLP
+
+ :param html_details: The html_details string from the PCO API
+ """
+ def _process_details(self, html_details):
+ # convert
into new lines
+ html_details = re.sub(r'
', '\n', html_details)
+ # covert formatting tags from list to dict to I can do arbitrary lookups
+ FormattingTags.load_tags()
+ openlp_formatting_tags = {}
+ for formatting_tag in FormattingTags.get_html_tags():
+ openlp_formatting_tags[formatting_tag['desc']] = {}
+ openlp_formatting_tags[formatting_tag['desc']]['start tag'] = formatting_tag['start tag']
+ openlp_formatting_tags[formatting_tag['desc']]['end tag'] = formatting_tag['end tag']
+ # convert bold
+ html_details = re.sub(r'(.*?)',
+ r"{start}\1{end}".format(
+ start=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', 'Bold')]['start tag'],
+ end=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', 'Bold')]['end tag']),
+ html_details)
+ # convert underline
+ html_details = re.sub(r'(.*?)',
+ r"{start}\1{end}".format(
+ start=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', 'Underline')]['start tag'],
+ end=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', 'Underline')]['end tag']),
+ html_details)
+ # convert italics
+ html_details = re.sub(r'(.*?)',
+ r"{start}\1{end}".format(
+ start=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', 'Italics')]['start tag'],
+ end=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', 'Italics')]['end tag']),
+ html_details)
+ # convert PlanningCenter colors to OpenLP base tags
+ color_lookup = {
+ 'Red': 'ff0000',
+ 'Black': '000000',
+ 'Blue': '0000ff',
+ 'Yellow': 'ffff00',
+ 'Green': '008000',
+ 'Pink': 'ff99cc',
+ 'Orange': 'ff6600',
+ 'Purple': '800080',
+ 'White': 'ffffff',
+ }
+ for color in color_lookup.keys():
+ html_details = re.sub(r'(.*?)'.format(number=color_lookup[color]),
+ r"{start}\1{end}".format(
+ start=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', color)]['start tag'],
+ end=openlp_formatting_tags[
+ translate('OpenLP.FormattingTags', color)]['end tag']),
+ html_details)
+ # throw away the rest of the html
+ html_details = re.sub(r'(|<[^>]*>)', '', html_details)
+ return html_details
diff --git a/openlp/plugins/planningcenter/lib/planningcenter_api.py b/openlp/plugins/planningcenter/lib/planningcenter_api.py
new file mode 100644
index 000000000..3e17c0db7
--- /dev/null
+++ b/openlp/plugins/planningcenter/lib/planningcenter_api.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.lib.planningcenter_api` module contains
+an API interface for the V2API for Planning Center Online
+"""
+import logging
+import os
+import re
+import ssl
+import urllib.error
+import urllib.request
+from json import loads, load, dump
+
+log = logging.getLogger(__name__)
+
+
+class PlanningCenterAPI:
+ """
+ The :class:`PlanningCenterAPI` class is Planning Center Online API Class for
+ the V2API.
+ """
+ def __init__(self, application_id, secret):
+ """
+ Initialize.
+
+ :param application_id: The Application ID from Planning Center Online
+ for API authentication.
+ :param secret: The secret key from Planning Center Online for API
+ authentication
+ """
+ self.api_url = "https://api.planningcenteronline.com/services/v2/"
+ self.application_id = application_id
+ self.secret = secret
+ """ airplane mode will cache responses from PCO such that if you request
+ the same resource a second time, it will respond with the cached
+ response. This is useful for doing development work on an airplane,
+ and for automated testing.
+ The production default is False """
+ self.airplane_mode = False
+ module_directory = os.path.dirname(__file__)
+ self.airplane_mode_directory = "{0}/airplane_mode".format(module_directory)
+ if os.path.isdir(self.airplane_mode_directory):
+ self.airplane_mode = True
+
+ def get_from_services_api(self, url_suffix):
+ """
+ Gets the response from the API for the provided url_suffix and returns
+ the response as an object.
+
+ :param url_suffix: The query part of the URL for the API. The base is
+ in self.api_url, and this suffix is appended to that to make the query.
+ """
+ airplane_mode_suffix = re.sub(r'\W', '_', url_suffix)
+ if len(airplane_mode_suffix) == 0:
+ airplane_mode_suffix = 'null'
+ airplane_mode_filename = "{0}/{1}.json".format(
+ self.airplane_mode_directory, airplane_mode_suffix)
+ if self.airplane_mode:
+ if os.path.isfile(airplane_mode_filename):
+ with open(airplane_mode_filename) as json_file:
+ api_response_object = load(json_file)
+ return api_response_object
+ else:
+ log.warning("Airplane Mode file not present:{filename}".format(filename=airplane_mode_filename))
+ if (not os.environ.get('PYTHONHTTPSVERIFY', '') and
+ getattr(ssl, '_create_unverified_context', None)):
+ ssl._create_default_https_context = ssl._create_unverified_context
+ # create a password manager
+ password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
+ password_mgr.add_password(realm=None, uri=self.api_url,
+ user=self.application_id, passwd=self.secret)
+ handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
+ # create "opener" (OpenerDirector instance)
+ opener = urllib.request.build_opener(handler)
+ # use the opener to fetch a URL
+ opener.open(self.api_url + url_suffix)
+ # Install the opener.
+ # Now all calls to urllib.request.urlopen use our opener.
+ urllib.request.install_opener(opener)
+ api_response_string = urllib.request.urlopen(self.api_url + url_suffix,
+ timeout=30).read()
+ api_response_object = loads(api_response_string.decode('utf-8'))
+ # write out the responses as json files for airplane mode...
+ if self.airplane_mode:
+ with open(airplane_mode_filename, 'w') as outfile:
+ dump(api_response_object, outfile)
+ return api_response_object
+
+ def check_credentials(self):
+ """
+ Attempts to connect to the base URL (self.api_url) with the credentials
+ provided during initialization.
+ If successful, it returns the name of the organization for the Planning
+ Center Online account. If failure, it returns an empty string.
+ """
+ try:
+ response = self.get_from_services_api('')
+ organization = response['data']['attributes']['name']
+ return organization
+ except urllib.error.HTTPError as error:
+ if error.code == 401:
+ return ''
+ else:
+ raise
+
+ def get_service_type_list(self):
+ """
+ Gets the list of service types (i.e. sunday morning, sunday evening,
+ special events, etc) configured in the Planning Center Online Interface
+ and the IDs for those service types.
+ """
+ # Get ServiceTypes
+ service_types_url_suffix = 'service_types'
+ service_types = self.get_from_services_api(service_types_url_suffix)
+ return service_types['data']
+
+ def get_plan_list(self, service_type_id):
+ """
+ Returns the list of plans available for a given service_type_id.
+
+ :param service_type_id: The ID of the service_type
+ (from self.get_service_type_list) from which to list the available plans.
+ """
+ if service_type_id:
+ self.current_service_type_id = service_type_id
+ # get the 10 next future services (including today)
+ future_plans_url_suffix = \
+ "service_types/{0}/plans?filter=future&per_page=10&order=sort_date".format(service_type_id)
+ future_plans = self.get_from_services_api(future_plans_url_suffix)
+ # get the 10 most recent past services
+ past_plans_url_suffix = \
+ "service_types/{0}/plans?filter=past&per_page=10&order=-sort_date".format(service_type_id)
+ past_plans = self.get_from_services_api(past_plans_url_suffix)
+ plan_list = list(reversed(future_plans['data'])) + past_plans['data']
+ return plan_list
+
+ def get_items_dict(self, plan_id):
+ """
+ Gets all items for a given plan ID (from self.get_plan_list), along with
+ their songs and arrangements
+
+ :param plan_id: The ID of the Plan from which to query all Plan Items.
+ """
+ itemsURLSuffix = "service_types/{0}/plans/{1}/items?include=song,arrangement&per_page=100".\
+ format(self.current_service_type_id, plan_id)
+ items = self.get_from_services_api(itemsURLSuffix)
+ return items
diff --git a/openlp/plugins/planningcenter/lib/planningcentertab.py b/openlp/plugins/planningcenter/lib/planningcentertab.py
new file mode 100644
index 000000000..9a13b4659
--- /dev/null
+++ b/openlp/plugins/planningcenter/lib/planningcentertab.py
@@ -0,0 +1,146 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.lib.planningcentertab` module contains
+the settings tab for the PlanningCenter plugin
+"""
+from PyQt5 import QtCore, QtWidgets
+
+from openlp.core.common.i18n import translate
+from openlp.core.common.settings import Settings
+from openlp.core.lib.settingstab import SettingsTab
+from openlp.plugins.planningcenter.lib.planningcenter_api import PlanningCenterAPI
+
+
+class PlanningCenterTab(SettingsTab):
+ """
+ PlanningCenterTab is the alerts settings tab in the settings dialog.
+ """
+ def setup_ui(self):
+ self.setObjectName('PlanningCenterTab')
+ self.tab_layout = QtWidgets.QVBoxLayout(self)
+ self.tab_layout.setObjectName('tab_layout')
+ self.tab_layout.setAlignment(QtCore.Qt.AlignTop)
+ self.auth_group_box = QtWidgets.QGroupBox(self)
+ self.tab_layout.addWidget(self.auth_group_box)
+ self.auth_layout = QtWidgets.QFormLayout(self.auth_group_box)
+ self.auth_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)
+ # notice
+ self.notice_label = QtWidgets.QLabel(self.auth_group_box)
+ self.notice_label.setWordWrap(True)
+ self.auth_layout.addRow(self.notice_label)
+ # instructions
+ self.instructions_label = QtWidgets.QLabel(self.auth_group_box)
+ self.instructions_label.setWordWrap(True)
+ self.auth_layout.addRow(self.instructions_label)
+ # application_id
+ self.application_id_label = QtWidgets.QLabel(self.auth_group_box)
+ self.application_id_line_edit = QtWidgets.QLineEdit(self.auth_group_box)
+ self.application_id_line_edit.setMaxLength(64)
+ self.auth_layout.addRow(self.application_id_label, self.application_id_line_edit)
+ # secret
+ self.secret_label = QtWidgets.QLabel(self.auth_group_box)
+ self.secret_line_edit = QtWidgets.QLineEdit(self.auth_group_box)
+ self.secret_line_edit.setMaxLength(64)
+ self.auth_layout.addRow(self.secret_label, self.secret_line_edit)
+ # Buttons
+ self.button_layout = QtWidgets.QDialogButtonBox(self.auth_group_box)
+ self.test_credentials_button = QtWidgets.QPushButton(self.auth_group_box)
+ self.button_layout.addButton(self.test_credentials_button, QtWidgets.QDialogButtonBox.AcceptRole)
+ self.auth_layout.addRow(self.button_layout)
+ # signals
+ self.test_credentials_button.clicked.connect(self.on_test_credentials_button_clicked)
+
+ def retranslate_ui(self):
+ self.auth_group_box.setTitle(translate('PlanningCenterPlugin.PlanningCenterTab', 'Authentication Settings'))
+ self.application_id_label.setText(translate('PlanningCenterPlugin.PlanningCenterTab', 'Application ID:'))
+ self.secret_label.setText(translate('PlanningCenterPlugin.PlanningCenterTab', 'Secret:'))
+
+ self.notice_label.setText(
+ translate('PlanningCenterPlugin.PlanningCenterTab', 'Note: '
+ 'An Internet connection and a Planning Center Online Account are required in order to \
+ import plans from Planning Center Online.')
+ )
+ self.instructions_label.setText(
+ translate('PlanningCenterPlugin.PlanningCenterTab',
+ """Enter your Planning Center Online Personal Access Token details in the text boxes \
+below. Personal Access Tokens are created by doing the following:
+
+ - Login to your Planning Center Online account at
+
+ https://api.planningcenteronline.com/oauth/applications
+ - Click the "New Personal Access Token" button at the bottom of the screen.
+ - Enter a description of your use case (eg. "OpenLP Integration")
+ - Copy and paste the provided Application ID and Secret values below.
+
"""))
+
+ self.test_credentials_button.setText(translate('PlanningCenterPlugin.PlanningCenterAuthForm',
+ 'Test Credentials'))
+
+ def resizeEvent(self, event=None):
+ """
+ Don't call SettingsTab resize handler because we are not using left/right columns.
+ """
+ QtWidgets.QWidget.resizeEvent(self, event)
+
+ def load(self):
+ """
+ Load the settings into the UI.
+ """
+ settings = Settings()
+ settings.beginGroup(self.settings_section)
+ self.application_id = settings.value('application_id')
+ self.secret = settings.value('secret')
+ settings.endGroup()
+ self.application_id_line_edit.setText(self.application_id)
+ self.secret_line_edit.setText(self.secret)
+
+ def save(self):
+ """
+ Save the changes on exit of the Settings dialog.
+ """
+ settings = Settings()
+ settings.beginGroup(self.settings_section)
+ settings.setValue('application_id', self.application_id_line_edit.text())
+ settings.setValue('secret', self.secret_line_edit.text())
+ settings.endGroup()
+
+ def on_test_credentials_button_clicked(self):
+ """
+ Tests if the credentials are valid
+ """
+ application_id = self.application_id_line_edit.text()
+ secret = self.secret_line_edit.text()
+ if len(application_id) == 0 or len(secret) == 0:
+ QtWidgets.QMessageBox.warning(self, "Authentication Failed",
+ "Please enter values for both Application ID and Secret",
+ QtWidgets.QMessageBox.Ok)
+ return
+ test_auth = PlanningCenterAPI(application_id, secret)
+ organization = test_auth.check_credentials()
+ if len(organization):
+ QtWidgets.QMessageBox.information(self, 'Planning Center Online Authentication Test',
+ "Authentication successful for organization: {0}".format(organization),
+ QtWidgets.QMessageBox.Ok)
+ else:
+ QtWidgets.QMessageBox.warning(self, "Authentication Failed",
+ "Authentiation Failed",
+ QtWidgets.QMessageBox.Ok)
diff --git a/openlp/plugins/planningcenter/lib/songimport.py b/openlp/plugins/planningcenter/lib/songimport.py
new file mode 100644
index 000000000..ddcd5d1f4
--- /dev/null
+++ b/openlp/plugins/planningcenter/lib/songimport.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.lib.songimport` module provides
+2 classes that are used to import Planning Center Online Service Songs into
+an OpenLP Service.
+"""
+import re
+
+from openlp.core.common.registry import Registry
+from openlp.plugins.songs.lib.db import Song
+from openlp.plugins.songs.lib.importers.songimport import SongImport
+
+
+class PlanningCenterSongImport(SongImport):
+ """
+ The :class:`PlanningCenterSongImport` class subclasses SongImport
+ and provides interfaces for parsing lyrics and adding songs from a
+ Planning Center Service into an OpenLP Service.
+ """
+ def __init__(self):
+ """
+ Initialize
+ """
+ songs = Registry().get('songs')
+ manager = songs.plugin.manager
+ SongImport.__init__(self, manager, file_path=None)
+
+ def add_song(self, item_title, author, lyrics, theme_name, last_modified):
+ """
+ Builds and adds song to the database and returns the Song ID
+ :param item_title: The song title.
+ :param author: Author String from Planning Center
+ :param lyrics: Lyrics String from Planning Center
+ :param theme_name: Theme String to use for this song
+ :param last_modified: DateTime of last modified date for this song
+ """
+ self.set_defaults()
+ self.title = item_title
+ self.theme_name = theme_name
+ if author:
+ self.parse_author(author)
+ # handle edge condition where a song has no lyrics set
+ if lyrics is None:
+ self.add_verse(item_title)
+ else:
+ verses = self._split_lyrics_into_verses(lyrics)
+ for verse in verses:
+ if len(verse['verse_type']):
+ verse_def = verse['verse_type'] + verse['verse_number']
+ self.add_verse(verse_text=verse['verse_text'], verse_def=verse_def)
+ else:
+ self.add_verse(verse_text=verse['verse_text'])
+ openlp_id = self.finish(temporary_flag=True)
+ # set the last_updated date/time based on the PCO date/time so I can look for updates
+ song = self.manager.get_object(Song, openlp_id)
+ song.last_modified = last_modified
+ self.manager.save_object(song)
+ return openlp_id
+
+ def _split_lyrics_into_verses(self, lyrics):
+ """
+ Parses Planning Center Lyrics and returns an list of verses, where each
+ verse is a dictionary that looks like:
+ verse['verse_type']
+ verse['verse_number']
+ verse['verse_text']
+ :param lyrics: Lyrics String from Planning Center
+ """
+ # create a regular expression for potential VERSE,CHORUS tags included inline inside the lyrics...
+ verse_marker_pattern = re.compile(r'^(v|verse|c|chorus|bridge|prechorus|instrumental|intro|outro|vamp|breakdown|ending|interlude|tag|misc)\s*(\d*)$', re.IGNORECASE) # noqa: E501
+ # create regex for an END marker. content after this marker will be ignored
+ end_marker_pattern = re.compile('^{(<.*?>)?END(<.*?>)?}$')
+ verse_type = ''
+ verse_number = ''
+ verse_text = ''
+ is_end_marker = False
+ output_verses = []
+ input_verses = lyrics.split("\n\n")
+ for verse in input_verses:
+ for line in verse.split("\n"):
+ if end_marker_pattern.search(line):
+ # see if this is the end marker (stop parsing verses if it is)
+ is_end_marker = True
+ # strip out curly braces and the content inside {}
+ line = re.sub('{.*?}+', '', line)
+ # strip out any extraneous tags <...>
+ line = re.sub('<.*?>', '', line)
+ # remove beginning/trailing whitespace and line breaks
+ line = line.rstrip()
+ line = line.lstrip()
+ regex_match = verse_marker_pattern.search(line)
+ if regex_match:
+ if len(verse_text):
+ # if we have verse_text from the previous verse, submit it now
+ self._add_verse(output_verses, verse_type, verse_number, verse_text)
+ verse_text = ''
+ verse_type = self._lookup_openlp_verse_type(regex_match.group(1))
+ # if we have a number after the verse marker, capture it here
+ if regex_match.group(2):
+ verse_number = regex_match.group(2)
+ else:
+ # if empty, let openlp auto-create it for us
+ verse_number = ''
+ continue
+ else:
+ if len(verse_text):
+ verse_text += "\n{0}".format(line)
+ else:
+ verse_text = line
+ if len(verse_text):
+ self._add_verse(output_verses, verse_type, verse_number, verse_text)
+ verse_text = ''
+ if is_end_marker:
+ return output_verses
+ return output_verses
+
+ def _lookup_openlp_verse_type(self, pco_verse_type):
+ """
+ Provides a lookup table to map from a Planning Center Verse Type
+ to an OpenLP verse type.
+ :param pco_verse_type: Planning Center Verse Type String
+ """
+ pco_verse_type_to_openlp = {}
+ pco_verse_type_to_openlp['VERSE'] = 'v'
+ pco_verse_type_to_openlp['V'] = 'v'
+ pco_verse_type_to_openlp['C'] = 'c'
+ pco_verse_type_to_openlp['CHORUS'] = 'c'
+ pco_verse_type_to_openlp['PRECHORUS'] = 'p'
+ pco_verse_type_to_openlp['INTRO'] = 'i'
+ pco_verse_type_to_openlp['ENDING'] = 'e'
+ pco_verse_type_to_openlp['BRIDGE'] = 'b'
+ pco_verse_type_to_openlp['OTHER'] = 'o'
+ openlp_verse_type = pco_verse_type_to_openlp['OTHER']
+ if pco_verse_type.upper() in pco_verse_type_to_openlp:
+ openlp_verse_type = pco_verse_type_to_openlp[pco_verse_type.upper()]
+ return openlp_verse_type
+
+ def _add_verse(self, output_verses, verse_type, verse_number, verse_text):
+ """
+ Simple utility function that takes verse attributes and adds the verse
+ to the output_verses list.
+ :param output_verses: The list of verses that will ultimately be returned by
+ the _split_lyrics_into_verses function
+ :param verse_type: The OpenLP Verse Type, like v,c,e, etc...
+ :param verse_number: The verse number, if known, like 1, 2, 3, etc.
+ :param verse_text: The parsed verse text returned from _split_lyrics_into_verses
+ """
+ new_verse = {}
+ new_verse['verse_type'] = verse_type
+ new_verse['verse_number'] = verse_number
+ new_verse['verse_text'] = verse_text
+ output_verses.append(new_verse)
diff --git a/openlp/plugins/planningcenter/planningcenterplugin.py b/openlp/plugins/planningcenter/planningcenterplugin.py
new file mode 100644
index 000000000..5d52ca151
--- /dev/null
+++ b/openlp/plugins/planningcenter/planningcenterplugin.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`~openlp.plugins.planningcenter.PlanningCenterPlugin` module contains
+the Plugin class for the PlanningCenter plugin.
+"""
+import logging
+
+from openlp.core.common.i18n import translate
+from openlp.core.common.registry import Registry
+from openlp.core.common.settings import Settings
+from openlp.core.lib.plugin import Plugin, StringContent
+from openlp.core.lib.ui import create_action
+from openlp.core.state import State
+from openlp.core.ui.icons import UiIcons
+from openlp.plugins.custom.lib.db import CustomSlide
+from openlp.plugins.planningcenter.forms.selectplanform import SelectPlanForm
+from openlp.plugins.planningcenter.lib.planningcentertab import PlanningCenterTab
+from openlp.plugins.songs.lib.db import Song
+
+log = logging.getLogger(__name__)
+
+__default_settings__ = {
+ 'planningcenter/application_id': '',
+ 'planningcenter/secret': ''
+}
+
+
+class PlanningCenterPlugin(Plugin):
+ """
+ This plugin enables the user to import services from Planning Center Online.
+ """
+ log.info('PlanningCenter Plugin loaded')
+
+ def __init__(self):
+ """
+ Create and set up the PlanningCenter plugin.
+ """
+ super(PlanningCenterPlugin, self).__init__('planningcenter', __default_settings__,
+ settings_tab_class=PlanningCenterTab)
+ self.planningcenter_form = None
+ self.icon = UiIcons().planning_center
+ self.icon_path = self.icon
+ self.weight = -1
+ State().add_service('planning_center', self.weight, is_plugin=True)
+ State().update_pre_conditions('planning_center', self.check_pre_conditions())
+
+ def initialise(self):
+ """
+ Initialise the plugin
+ """
+ log.info('PlanningCenter Initialising')
+ super(PlanningCenterPlugin, self).initialise()
+ self.import_planning_center.setVisible(True)
+
+ def add_import_menu_item(self, import_menu):
+ """
+ Add "PlanningCenter Service" to the **Import** menu.
+
+ :param import_menu: The actual **Import** menu item, so that your
+ actions can use it as their parent.
+ """
+ self.import_planning_center = create_action(import_menu, 'import_planning_center',
+ text=translate('PlanningCenterPlugin', 'Planning Center Service'),
+ visible=False,
+ statustip=translate('PlanningCenterPlugin',
+ 'Import Planning Center Service Plan \
+ from Planning Center Online.'),
+ triggers=self.on_import_planning_center_triggered
+ )
+ import_menu.addAction(self.import_planning_center)
+
+ def on_import_planning_center_triggered(self):
+ """
+ Run the PlanningCenter importer.
+ """
+ # Determine which dialog to show based on whether the auth values are set yet
+ self.application_id = Settings().value("planningcenter/application_id")
+ self.secret = Settings().value("planningcenter/secret")
+ if len(self.application_id) == 0 or len(self.secret) == 0:
+ self.planningcenter_form = Registry().get('settings_form')
+ self.planningcenter_form.exec(translate('PlanningCenterPlugin', 'PlanningCenter'))
+ else:
+ self.planningcenter_form = SelectPlanForm(Registry().get('main_window'), self)
+ self.planningcenter_form.exec()
+
+ @staticmethod
+ def about():
+ """
+ Provides information for the plugin manager to display.
+
+ :return: A translatable string with some basic information about the
+ PlanningCenter plugin
+ """
+ return translate('PlanningCenterPlugin', 'PlanningCenter Plugin'
+ '
The planningcenter plugin provides an interface to import \
+ service plans from the Planning Center Online v2 API.')
+
+ def set_plugin_text_strings(self):
+ """
+ Called to define all translatable texts of the plugin
+ """
+ # Name PluginList
+ self.text_strings[StringContent.Name] = {
+ 'singular': translate('PlanningCenterPlugin', 'PlanningCenter',
+ 'name singular'),
+ 'plural': translate('PlanningCenterPlugin', 'PlanningCenter',
+ 'name plural')
+ }
+ # Name for MediaDockManager, SettingsManager
+ self.text_strings[StringContent.VisibleName] = {
+ 'title': translate('PlanningCenterPlugin', 'PlanningCenter',
+ 'container title')
+ }
+ # Middle Header Bar
+ tooltips = {
+ 'load': '',
+ 'import': translate('PlanningCenterPlugin', 'Import All Plan Items \
+ into Current Service'),
+ 'new': '',
+ 'edit': '',
+ 'delete': '',
+ 'preview': '',
+ 'live': '',
+ 'service': '',
+ }
+ self.set_plugin_ui_text_strings(tooltips)
+
+ def finalise(self):
+ """
+ Tidy up on exit
+ """
+ log.info('PlanningCenter Finalising')
+ self.import_planning_center.setVisible(False)
+ self.new_service_created()
+ # call songs plugin manager to delete temporary songs
+ songs_manager = Registry().get('songs').plugin.manager
+ songs = songs_manager.get_all_objects(Song, Song.temporary == True) # noqa: E712
+ for song in songs:
+ songs_manager.delete_object(Song, song.id)
+ # call custom manager to delete pco slides
+ custom_manager = Registry().get('custom').plugin.db_manager
+ pco_slides = custom_manager.get_all_objects(CustomSlide, CustomSlide.credits == 'pco')
+ for slide in pco_slides:
+ custom_manager.delete_object(CustomSlide, slide.id)
+ super(PlanningCenterPlugin, self).finalise()
diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py
index 06dd81a68..4fd6a7203 100644
--- a/openlp/plugins/songs/forms/songselectform.py
+++ b/openlp/plugins/songs/forms/songselectform.py
@@ -128,11 +128,11 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
self.username_edit.setFocus()
return QtWidgets.QDialog.exec(self)
- def done(self, r):
+ def done(self, result_code):
"""
Log out of SongSelect.
- :param r: The result of the dialog.
+ :param result_code: The result of the dialog.
"""
log.debug('Closing SongSelectForm')
if self.stacked_widget.currentIndex() > 0:
@@ -149,7 +149,7 @@ class SongSelectForm(QtWidgets.QDialog, Ui_SongSelectDialog, RegistryProperties)
self.song_select_importer.logout()
self.application.process_events()
progress_dialog.setValue(2)
- return QtWidgets.QDialog.done(self, r)
+ return QtWidgets.QDialog.done(self, result_code)
def _update_login_progress(self):
"""
diff --git a/openlp/plugins/songs/lib/importers/songimport.py b/openlp/plugins/songs/lib/importers/songimport.py
index d9deb8966..d67f795a4 100644
--- a/openlp/plugins/songs/lib/importers/songimport.py
+++ b/openlp/plugins/songs/lib/importers/songimport.py
@@ -319,9 +319,11 @@ class SongImport(QtCore.QObject):
else:
return True
- def finish(self):
+ def finish(self, temporary_flag=False):
"""
All fields have been set to this song. Write the song to disk.
+
+ :param temporary_flag: should this song be marked as temporary in the db (default=False)
"""
if not self.check_complete():
self.set_defaults()
@@ -379,6 +381,7 @@ class SongImport(QtCore.QObject):
if topic is None:
topic = Topic.populate(name=topic_text)
song.topics.append(topic)
+ song.temporary = temporary_flag
# We need to save the song now, before adding the media files, so that
# we know where to save the media files to.
clean_song(self.manager, song)
@@ -393,7 +396,7 @@ class SongImport(QtCore.QObject):
song.media_files.append(MediaFile.populate(file_path=file_path, weight=weight))
self.manager.save_object(song)
self.set_defaults()
- return True
+ return song.id
def copy_media_file(self, song_id, file_path):
"""
diff --git a/tests/interfaces/openlp_plugins/planningcenter/__init__.py b/tests/interfaces/openlp_plugins/planningcenter/__init__.py
new file mode 100644
index 000000000..4496870fb
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/__init__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
diff --git a/tests/interfaces/openlp_plugins/planningcenter/forms/__init__.py b/tests/interfaces/openlp_plugins/planningcenter/forms/__init__.py
new file mode 100644
index 000000000..4496870fb
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/forms/__init__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
diff --git a/tests/interfaces/openlp_plugins/planningcenter/forms/test_selectplanform.py b/tests/interfaces/openlp_plugins/planningcenter/forms/test_selectplanform.py
new file mode 100644
index 000000000..5b3727387
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/forms/test_selectplanform.py
@@ -0,0 +1,389 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
+"""
+Package to test the openlp.plugins.planningcenter.forms.selectplanform package.
+"""
+import os
+import re
+from datetime import date, datetime
+from unittest import TestCase, skip
+from unittest.mock import patch, MagicMock
+
+from PyQt5 import QtWidgets, QtTest, QtCore
+
+from openlp.core.common.registry import Registry
+from openlp.core.common.settings import Settings
+from openlp.core.state import State
+from openlp.core.ui.servicemanager import ServiceManager
+from openlp.core.ui.settingsform import SettingsForm
+from openlp.core.ui.thememanager import ThemeManager
+from openlp.plugins.bibles.bibleplugin import BiblePlugin
+from openlp.plugins.bibles.lib.mediaitem import BibleMediaItem
+from openlp.plugins.custom.customplugin import CustomPlugin
+from openlp.plugins.custom.lib.mediaitem import CustomMediaItem
+from openlp.plugins.planningcenter.forms.selectplanform import SelectPlanForm
+from openlp.plugins.planningcenter.planningcenterplugin import PlanningCenterPlugin
+from openlp.plugins.songs.lib.mediaitem import SongMediaItem
+from openlp.plugins.songs.songsplugin import SongsPlugin
+from tests.helpers.testmixin import TestMixin
+
+TEST_PATH = os.path.abspath(
+ os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', 'resources', 'planningcenter'))
+
+
+class TestSelectPlanForm(TestCase, TestMixin):
+ """
+ Test the SelectPlanForm class
+ """
+
+ def setUp(self):
+ """
+ Create the UI
+ """
+ self.registry = Registry()
+ Registry.create()
+ self.setup_application()
+ self.build_settings()
+ State().load_settings()
+ Registry().register('main_window', MagicMock(service_manager_settings_section='servicemanager'))
+ self.application_id = 'abc'
+ self.secret = '123'
+ Settings().setValue('planningcenter/application_id', self.application_id)
+ Settings().setValue('planningcenter/secret', self.secret)
+ # init the planning center plugin so we have default values defined for Settings()
+ self.planning_center_plugin = PlanningCenterPlugin()
+ # setup our form
+ self.form = SelectPlanForm()
+ self.form.planning_center_api.airplane_mode = True
+ self.form.planning_center_api.airplane_mode_directory = TEST_PATH
+ self.theme_manager = ThemeManager(None)
+ self.theme_manager.get_themes = MagicMock()
+ self.theme_manager.get_themes.return_value = ['themeA', 'themeB']
+
+ def tearDown(self):
+ """
+ Delete all the C++ objects at the end so that we don't have a segfault
+ """
+ del self.form
+ del self.planning_center_plugin
+ del self.theme_manager
+ del self.registry
+ self.destroy_settings()
+
+ def test_initial_defaults(self):
+ """
+ Test that the SelectPlanForm displays with correct defaults
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ # a theme manager with mocked themes, and a fake date = Sunday (7/29/2018)
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.date') as mock_date:
+ # need to always return 9/29/2019 for date.today()
+ mock_date.today.return_value = date(2019, 9, 29)
+ mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+ # WHEN: The form is shown
+ self.form.exec()
+ # THEN: The correct count of service types show up in the combo box
+ combo_box_count = self.form.service_type_combo_box.count()
+ self.assertEqual(combo_box_count, 2,
+ 'The service_type_combo_box contains 2 items')
+ # The first service type is selected
+ self.assertEqual(self.form.service_type_combo_box.currentText(), 'gbf',
+ 'The service_type_combo_box defaults to "gbf"')
+ # the selected plan is today (the mocked date is a Sunday)
+ self.assertEqual(self.form.plan_selection_combo_box.currentText(),
+ date.strftime(mock_date.today.return_value, '%B %d, %Y'),
+ 'Incorrect default date selected for Plan Date')
+ # count the number of themes listed and make sure it matches expected value
+ self.assertEqual(self.form.song_theme_selection_combo_box.count(),
+ 2, 'Count of song themes is incorrect')
+ self.assertEqual(self.form.slide_theme_selection_combo_box.count(),
+ 2, 'Count of custom slide themes is incorrect')
+
+ def test_warning_messagebox_shown_for_bad_credentials(self):
+ """
+ Test that if we don't have good credentials, then it will show a QMessageBox with a warning in it
+ """
+ # GIVEN: A SelectPlanForm instance with airplane mode enabled, resources, available,
+ # mocked check_credentials function to return ''
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch.object(self.form.planning_center_api, 'check_credentials', return_value=''), \
+ patch('PyQt5.QtWidgets.QMessageBox.warning') as mock_warning:
+ # WHEN: form is displayed
+ self.form.exec()
+ # THEN: we should have called a warning messagebox
+ mock_warning.assert_called_once()
+
+ def test_disable_import_buttons(self):
+ """
+ Test that the import buttons are disabled when the "Select Plan Date" element in the
+ Plan Selection List is selected.
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available, and the form
+ with patch('PyQt5.QtWidgets.QDialog.exec'):
+ self.form.exec()
+ # WHEN: The Select Plan combo box is set to "Select Plan Date"
+ index = self.form.plan_selection_combo_box.findText('Select Plan Date')
+ self.form.plan_selection_combo_box.setCurrentIndex(index)
+ # THEN: "Import New" and "Refresh Service" buttons become inactive
+ self.assertEqual(self.form.import_as_new_button.isEnabled(), False,
+ '"Import as New" button should be disabled')
+ self.assertEqual(self.form.update_existing_button.isEnabled(), False,
+ '"Refresh Service" button should be disabled')
+
+ def test_default_plan_date_is_next_sunday(self):
+ """
+ Test that the SelectPlanForm displays Next Sunday's Date by Default
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ # a theme manager with mocked themes, and a fake date = (9/24/2019)
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.date') as mock_date:
+ # need to always return 9/24/2019 for date.today()
+ mock_date.today.return_value = date(2019, 9, 24)
+ mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+ self.form.exec()
+ # WHEN: The second (index=1) service type is selected
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ # THEN: The plan selection date is 9/29 (the following Sunday)
+ self.assertEqual(self.form.plan_selection_combo_box.currentText(), 'September 29, 2019',
+ 'The next Sunday\'s Date is not selected in the plan_selection_combo_box')
+
+ def test_service_type_changed_called_when_service_type_combo_changed(self):
+ """
+ Test that the "on_service_type_combobox_changed" function is executed when the
+ service_type_combobox is changed
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available
+ with patch('PyQt5.QtWidgets.QDialog.exec'):
+ self.form.exec()
+ # WHEN: The Service Type combo is set to index 1
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ # THEN: The on_service_type_combobox_changed function is called
+ assert self.form.plan_selection_combo_box.count() > 0, 'Plan Selection Combo Box is not empty'
+ assert self.form.plan_selection_combo_box.itemText(0) == 'Select Plan Date', 'Plan Combo Box has default text'
+
+ def test_plan_selection_changed_called_when_plan_selection_combo_changed(self):
+ """
+ Test that the "on_plan_selection_combobox_changed" function is executed when the
+ plan_selection_combobox is changed
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ with patch('PyQt5.QtWidgets.QDialog.exec'):
+ self.form.exec()
+ # WHEN: The Service Type combo is set to index 1
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ self.form.plan_selection_combo_box.setCurrentIndex(1)
+ # THEN: The import and update buttons should be enabled
+ assert self.form.import_as_new_button.isEnabled() is True, 'Import button should be enabled'
+ assert self.form.update_existing_button.isEnabled() is True, 'Update button should be enabled'
+
+ def test_settings_tab_displayed_when_edit_auth_button_clicked(self):
+ """
+ Test that the settings dialog is displayed when the edit_auth_button is clicked
+ """
+ # GIVEN: A SelectPlanForm instance with airplane mode enabled and resources available
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.core.ui.settingsform.SettingsForm.exec') as mock_settings_form:
+ SettingsForm()
+ self.form.exec()
+ # WHEN: the edit_auth_button is clicked
+ QtTest.QTest.mouseClick(self.form.edit_auth_button, QtCore.Qt.LeftButton)
+ self.assertEqual(mock_settings_form.called, 1, "Settings Form opened when edit_auth_button clicked")
+
+ def test_import_function_called_when_import_button_clicked(self):
+ """
+ Test that the "on_import_as_new_button_clicked" function is executed when the
+ "Import New" button is clicked
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.SelectPlanForm._do_import') \
+ as mock_do_import:
+ self.form.exec()
+ # WHEN: The Service Type combo is set to index 1 and the Select Plan combo box is set
+ # to index 1 and the "Import New" button is clicked
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ self.form.plan_selection_combo_box.setCurrentIndex(4)
+ QtTest.QTest.mouseClick(self.form.import_as_new_button, QtCore.Qt.LeftButton)
+ # THEN: The on_import_as_new_button_cliced function is called
+ mock_do_import.assert_called_with(update=False)
+
+ def test_service_imported_when_import_button_clicked(self):
+ """
+ Test that a service is imported when the "Import New" button is clicked
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ # mocked out "on_new_service_clicked"
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.core.common.registry.Registry.get'), \
+ patch('openlp.plugins.planningcenter.lib.songimport.PlanningCenterSongImport.finish') \
+ as mock_song_import, \
+ patch('openlp.plugins.planningcenter.lib.customimport.CustomSlide') as mock_custom_slide_import, \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.parse_reference') as mock_bible_import, \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.date') as mock_date:
+ # need to always return 9/29/2019 for date.today()
+ mock_date.today.return_value = date(2019, 9, 29)
+ mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+ self.form.exec()
+ # WHEN: The Service Type combo is set to index 1 and the Select Plan combo box is set to
+ # index 1 and the "Import New" button is clicked
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ QtTest.QTest.mouseClick(self.form.import_as_new_button, QtCore.Qt.LeftButton)
+ # THEN: There should be 5 service items added, 1 song, 3 custom slides (one is a bible
+ # title slide), and 1 bible verse
+ self.assertEqual(mock_song_import.call_count, 1, '1 song added via song_media_item')
+ self.assertEqual(mock_custom_slide_import.call_count, 4, '4 custom slide added via custom_media_item')
+ self.assertEqual(mock_bible_import.call_count, 2, '2 bible verses submitted for parsing')
+
+ def test_service_refreshed_when_refresh_button_clicked(self):
+ """
+ Test that a service is refreshed when the "Refresh Service" button is clicked
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ # mocked out "on_new_service_clicked"
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.core.common.registry.Registry.get'), \
+ patch('openlp.plugins.planningcenter.lib.songimport.PlanningCenterSongImport.finish') \
+ as mock_song_import, \
+ patch('openlp.plugins.planningcenter.lib.customimport.CustomSlide') as mock_custom_slide_import, \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.parse_reference') as mock_bible_import, \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.date') as mock_date:
+ # need to always return 9/29/2019 for date.today()
+ mock_date.today.return_value = date(2019, 9, 29)
+ mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+ self.form.exec()
+ # WHEN: The Service Type combo is set to index 1 and the Select Plan combo box is
+ # set to index 1 and the "Update" button is clicked
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ QtTest.QTest.mouseClick(self.form.update_existing_button, QtCore.Qt.LeftButton)
+ # THEN: There should be 5 service items added, 1 song, 3 custom slides (one is a bible
+ # title slide), and 1 bible verse
+ self.assertEqual(mock_song_import.call_count, 1, '1 song added via song_media_item')
+ self.assertEqual(mock_custom_slide_import.call_count, 4, '4 custom slide added via custom_media_item')
+ self.assertEqual(mock_bible_import.call_count, 2, '2 bible verses submitted for parsing')
+
+ def test_other_bible_is_used_when_bible_gui_form_is_blank(self):
+ """
+ Test that an other bible is used when the GUI has an empty string for current selected bible
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ # mocked out "on_new_service_clicked"
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.core.common.registry.Registry.get') as mock_get, \
+ patch('openlp.plugins.planningcenter.lib.songimport.PlanningCenterSongImport.finish'), \
+ patch('openlp.plugins.planningcenter.lib.customimport.CustomSlide'), \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.parse_reference') as mock_bible_import, \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.date') as mock_date:
+ # need to always return 9/29/2019 for date.today()
+ mock_date.today.return_value = date(2019, 9, 29)
+ mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+ mock_bibles = {}
+ mock_bibles['other_bible'] = MagicMock()
+ mock_get.return_value.plugin.manager.get_bibles.return_value = mock_bibles
+ mock_get.return_value.version_combo_box.currentText.return_value = ''
+ self.form.exec()
+ # WHEN: The Service Type combo is set to index 1 and the Select Plan combo box
+ # is set to index 1 and the "Import New" button is clicked
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ QtTest.QTest.mouseClick(self.form.import_as_new_button, QtCore.Qt.LeftButton)
+ # THEN: There should be 2 bible verse parse attempts
+ self.assertEqual(mock_bible_import.call_count, 2, '2 bible verses submitted for parsing')
+
+ def _create_mock_action(self, name, **kwargs):
+ """
+ Create a fake action with some "real" attributes for Service Manager
+ """
+ action = QtWidgets.QAction(self.service_manager)
+ action.setObjectName(name)
+ if kwargs.get('triggers'):
+ action.triggered.connect(kwargs.pop('triggers'))
+ self.service_manager.toolbar.actions[name] = action
+ return action
+
+ @skip("fails to run when executed with all other openlp tests. awaiting pytest fixtures to enable again")
+ def test_less_mocking_service_refreshed_when_refresh_button_clicked_test(self):
+ """
+ Test that a service is refreshed when the "Refresh Service" button is clicked
+ """
+ # GIVEN: An SelectPlanForm instance with airplane mode enabled, resources available,
+ # mocked out "on_new_service_clicked"
+ with patch('PyQt5.QtWidgets.QDialog.exec'), \
+ patch('openlp.plugins.planningcenter.forms.selectplanform.date') as mock_date:
+ # need to always return 9/29/2019 for date.today()
+ mock_date.today.return_value = date(2019, 9, 29)
+ mock_date.side_effect = lambda *args, **kw: date(*args, **kw)
+ # init ServiceManager
+ Registry().register('plugin_manager', MagicMock())
+ Registry().register('application', MagicMock())
+ Registry().register('renderer', MagicMock())
+ self.service_manager = ServiceManager()
+ self.service_manager.setup_ui(self.service_manager)
+ # init songs plugin
+ with patch('openlp.plugins.songs.lib.mediaitem.EditSongForm'), \
+ patch('openlp.plugins.custom.lib.mediaitem.EditCustomForm'), \
+ patch('openlp.core.lib.mediamanageritem.create_widget_action'), \
+ patch('openlp.core.widgets.toolbar.create_widget_action'):
+ # init songs plugin
+ songs_plugin = SongsPlugin()
+ song_media_item = SongMediaItem(None, songs_plugin)
+ song_media_item.search_text_edit = MagicMock()
+ song_media_item.settings_section = 'songs'
+ song_media_item.initialise()
+ # init custom plugin
+ custom_plugin = CustomPlugin()
+ CustomMediaItem(None, custom_plugin)
+ # init bible plugin
+ bible_plugin = BiblePlugin()
+ bible_media_item = BibleMediaItem(None, bible_plugin)
+ bible_media_item.build_display_results = MagicMock()
+ self.form.exec()
+ # WHEN:
+ # The Service Type combo is set to index 1 and "Import New" button is clicked
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ QtTest.QTest.mouseClick(self.form.import_as_new_button, QtCore.Qt.LeftButton)
+ # make changes to the now imported service items
+ # first, for serviceitem[0] update last_updated in xml_string and change "sweet" to "sublime"
+ old_match = re.search('modifiedDate="(.+?)Z*"',
+ self.service_manager.service_items[0]['service_item'].xml_version)
+ old_string = old_match.group(1)
+ now_string = datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
+ self.service_manager.service_items[0]['service_item'].xml_version = \
+ self.service_manager.service_items[0]['service_item'].xml_version.replace(old_string, now_string)
+ self.service_manager.service_items[0]['service_item'].xml_version = \
+ self.service_manager.service_items[0]['service_item'].xml_version.replace("sweet", "sublime")
+ # second, add the word modified to the slide text for serviceitem[1]
+ self.service_manager.service_items[1]['service_item'].slides[0]['text'] = \
+ self.service_manager.service_items[1]['service_item'].slides[0]['text'].replace("Test", "Modified Test")
+ # third, delete serviceitems[2] and serviceitem[3]
+ del self.service_manager.service_items[3]
+ del self.service_manager.service_items[2]
+ # last, draw the form again and request refresh
+ self.form.exec()
+ self.form.service_type_combo_box.setCurrentIndex(1)
+ QtTest.QTest.mouseClick(self.form.update_existing_button, QtCore.Qt.LeftButton)
+ # THEN:
+ # There should be 4 service items added
+ self.assertEqual(len(self.service_manager.service_items), 5, '5 items should be in the ServiceManager')
+ # Amazon Grace should still include sublime
+ self.assertTrue('sublime' in self.service_manager.service_items[0]['service_item'].xml_version)
+ # Slides in service_item[1] should still contain the word "Modified"
+ self.assertTrue('Modified' in self.service_manager.service_items[1]['service_item'].slides[0]['text'])
diff --git a/tests/interfaces/openlp_plugins/planningcenter/lib/__init__.py b/tests/interfaces/openlp_plugins/planningcenter/lib/__init__.py
new file mode 100644
index 000000000..4496870fb
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/lib/__init__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
diff --git a/tests/interfaces/openlp_plugins/planningcenter/lib/test_planningcenter_api.py b/tests/interfaces/openlp_plugins/planningcenter/lib/test_planningcenter_api.py
new file mode 100644
index 000000000..59f420bec
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/lib/test_planningcenter_api.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
+"""
+Package to test the openlp.plugins.planningcenter.lib.planningcenter_api package.
+"""
+import os
+import urllib.error
+from unittest import TestCase
+from unittest.mock import patch
+
+from openlp.plugins.planningcenter.lib.planningcenter_api import PlanningCenterAPI
+from tests.helpers.testmixin import TestMixin
+
+TEST_PATH = os.path.abspath(
+ os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', 'resources', 'planningcenter'))
+
+
+class TestPlanningCenterAPI(TestCase, TestMixin):
+ """
+ Test the PlanningCenterAPI class
+ """
+ def test_init(self):
+ """
+ Test that the api class can be instantiated with an application_id and secret
+ """
+ # GIVEN: A PlanningCenterAPI Class
+ # WHEN: __init__ is called with an application id and secret
+ api = PlanningCenterAPI('application_id', 'secret')
+ # THEN:
+ # airplane mode should be false
+ self.assertFalse(api.airplane_mode, 'Class init without airplane mode')
+
+ def test_init_with_airplane_mode(self):
+ """
+ Test that the api class can be instantiated with an application_id and secret
+ """
+ # GIVEN: A PlanningCenterAPI Class
+ # WHEN: __init__ is called with an application id and secret and airplane_dir mocked
+ with patch('openlp.plugins.planningcenter.lib.planningcenter_api.os.path.isdir') as airplane_isdir:
+ airplane_isdir.return_value = True
+ api = PlanningCenterAPI('application_id', 'secret')
+ # THEN:
+ # airplane mode should be true
+ self.assertTrue(api.airplane_mode, 'Class init with airplane mode')
+
+ def test_get_from_services_api(self):
+ """
+ Test that the get_from_services_api can be called in airplane mode
+ """
+ # GIVEN: A PlanningCenterAPI Class
+ # WHEN: get_from_services_api is called with empty string as input ('') and airplane mode enabled
+ with patch('openlp.plugins.planningcenter.lib.planningcenter_api.os.path.isdir') as airplane_isdir, \
+ patch('openlp.plugins.planningcenter.lib.planningcenter_api.urllib.request.build_opener') \
+ as mock_opener, \
+ patch('openlp.plugins.planningcenter.lib.planningcenter_api.open'):
+ airplane_isdir.return_value = True
+ api = PlanningCenterAPI('application_id', 'secret')
+ mock_opener().open().read.return_value = "{\"foo\" : \"bar\"}".encode(encoding='UTF-8')
+ return_value = api.get_from_services_api('test')
+ # THEN:
+ # we should get back the return value we mocked
+ self.assertEqual(return_value['foo'], 'bar', "get_from_services_api returns correct value")
+
+ def test_check_credentials_returns_empty_string_for_bad_credentials(self):
+ """
+ Test that check_credentials returns an empty string if authentication fails
+ """
+ # GIVEN: A PlanningCenterAPI Class with mocked out get_from_services_api that returns a 401 (http auth) error
+ api = PlanningCenterAPI('application_id', 'secret')
+ with patch.object(api, 'get_from_services_api') as mock_get_services:
+ mock_get_services.side_effect = urllib.error.HTTPError(None, 401, None, None, None)
+ # WHEN: check_credentials is called
+ return_value = api.check_credentials()
+ # THEN: we have an empty string returns
+ assert return_value == '', "return string is empty for bad authentication"
+
+ def test_check_credentials_raises_other_exceptions(self):
+ # GIVEN: A PlanningCenterAPI Class with mocked out get_from_services_api that returns a 400 error
+ api = PlanningCenterAPI('application_id', 'secret')
+ with patch.object(api, 'get_from_services_api') as mock_get_services:
+ mock_get_services.side_effect = urllib.error.HTTPError(None, 300, None, None, None)
+ # WHEN: check_credentials is called in a try block
+ error_code = 0
+ try:
+ api.check_credentials()
+ except urllib.error.HTTPError as error:
+ error_code = error.code
+ # THEN: we received an exception with code of 300
+ assert error_code == 300, "correct exception is raised from check_credentials"
+
+ def test_check_credentials_pass(self):
+ """
+ Test that check_credentials can be called in airplane mode
+ """
+ # GIVEN: A PlanningCenterAPI Class
+ # WHEN: get_from_services_api is called with empty string as input ('') and airplane mode enabled
+ with patch('openlp.plugins.planningcenter.lib.planningcenter_api.os.path.isdir') as airplane_isdir, \
+ patch('openlp.plugins.planningcenter.lib.planningcenter_api.urllib.request.build_opener') \
+ as mock_opener, \
+ patch('openlp.plugins.planningcenter.lib.planningcenter_api.open'):
+ airplane_isdir.return_value = True
+ api = PlanningCenterAPI('application_id', 'secret')
+ mock_opener().open().read.return_value = "{\"data\": {\"attributes\": {\"name\": \"jpk\"}}}".\
+ encode(encoding='UTF-8')
+ return_value = api.check_credentials()
+ # THEN:
+ # Check credentials returns our mocked value
+ self.assertEqual(return_value, 'jpk', "check_credentials returns correct value for pass")
diff --git a/tests/interfaces/openlp_plugins/planningcenter/lib/test_planningcentertab.py b/tests/interfaces/openlp_plugins/planningcenter/lib/test_planningcentertab.py
new file mode 100644
index 000000000..473b6a404
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/lib/test_planningcentertab.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
+"""
+Package to test the openlp.plugins.planningcenter.lib.planningcentertab package.
+"""
+from unittest import TestCase
+from unittest.mock import patch
+
+from PyQt5 import QtCore, QtTest, QtWidgets
+
+from openlp.core.common.registry import Registry
+from openlp.core.common.settings import Settings
+from openlp.core.state import State
+from openlp.plugins.planningcenter.lib.planningcentertab import PlanningCenterTab
+from openlp.plugins.planningcenter.planningcenterplugin import PlanningCenterPlugin
+from tests.helpers.testmixin import TestMixin
+
+
+class TestPlanningCenterTab(TestCase, TestMixin):
+ """
+ Test the PlanningCenterTab class
+ """
+ def setUp(self):
+ """
+ Create the UI
+ """
+ self.setup_application()
+ self.registry = Registry()
+ Registry.create()
+ State().load_settings()
+ self.plugin = PlanningCenterPlugin()
+ Settings().setValue('planningcenter/application_id', 'abc')
+ Settings().setValue('planningcenter/secret', '123')
+ self.dialog = QtWidgets.QDialog()
+ self.tab = PlanningCenterTab(self.dialog, 'PlanningCenter')
+ self.tab.setup_ui()
+ self.tab.retranslate_ui()
+ self.tab.load()
+ self.tab.resizeEvent()
+
+ def tearDown(self):
+ """
+ Delete all the C++ objects at the end so that we don't have a segfault
+ """
+ del self.tab
+ del self.dialog
+ del self.registry
+
+ def test_bad_authentication_credentials(self):
+ """
+ Test that the tab can be created and Test authentication clicked for bad application id and secret
+ """
+ # GIVEN: A PlanningCenterTab Class
+ # WHEN: tab is drawn and application_id/secret values are entered in the GUI and the test buttin is clicked
+ with patch('openlp.plugins.planningcenter.lib.planningcentertab.PlanningCenterAPI.check_credentials') \
+ as mock_check_credentials, \
+ patch('openlp.plugins.planningcenter.lib.planningcentertab.QtWidgets.QMessageBox.warning') \
+ as mock_qmessagebox:
+ mock_check_credentials.return_value = ''
+ QtTest.QTest.mouseClick(self.tab.test_credentials_button, QtCore.Qt.LeftButton)
+ # THEN:
+ # The warning messagebox should be called
+ self.assertEqual(mock_qmessagebox.call_count, 1, 'Warning dialog used for bad credentials')
+
+ def test_empty_authentication_credentials(self):
+ """
+ Test that the tab can be created and Test authentication clicked for missing application id and secret
+ """
+ # GIVEN: A PlanningCenterTab Class
+ # WHEN: tab is drawn and application_id/secret values are entered in the GUI and the test buttin is clicked
+ with patch('openlp.plugins.planningcenter.lib.planningcentertab.QtWidgets.QMessageBox.warning') \
+ as mock_qmessagebox:
+ self.tab.application_id_line_edit.setText('')
+ self.tab.secret_line_edit.setText('')
+ QtTest.QTest.mouseClick(self.tab.test_credentials_button, QtCore.Qt.LeftButton)
+ # THEN:
+ # The warning messagebox should be called
+ self.assertEqual(mock_qmessagebox.call_count, 1, 'Warning dialog used for missing credentials')
+
+ def test_good_authentication_credentials(self):
+ """
+ Test that the tab can be created and Test authentication clicked for good application id and secret
+ """
+ # GIVEN: A PlanningCenterTab Class
+ # WHEN: tab is drawn and application_id/secret values are entered in the GUI and the test buttin is clicked
+ with patch('openlp.plugins.planningcenter.lib.planningcentertab.PlanningCenterAPI.check_credentials') \
+ as mock_check_credentials, \
+ patch('openlp.plugins.planningcenter.lib.planningcentertab.QtWidgets.QMessageBox.information') \
+ as mock_qmessagebox:
+ mock_check_credentials.return_value = 'good'
+ QtTest.QTest.mouseClick(self.tab.test_credentials_button, QtCore.Qt.LeftButton)
+ # THEN:
+ # The information messagebox should be called
+ self.assertEqual(mock_qmessagebox.call_count, 1, 'Information dialog used for good credentials')
+
+ def test_save_credentials(self):
+ """
+ Test that credentials are saved in settings when the save function is called
+ """
+ # GIVEN: A PlanningCenterTab Class
+ # WHEN: application id and secret values are set to something and the save function is called
+ new_application_id = 'planningcenter'
+ new_secret = 'woohoo'
+ self.tab.application_id_line_edit.setText(new_application_id)
+ self.tab.secret_line_edit.setText(new_secret)
+ self.tab.save()
+ # THEN: The settings version of application_id and secret should reflect the new values
+ settings = Settings()
+ settings.beginGroup(self.tab.settings_section)
+ application_id = settings.value('application_id')
+ secret = settings.value('secret')
+ self.assertEqual(application_id, new_application_id)
+ self.assertEqual(secret, new_secret)
+ settings.endGroup()
diff --git a/tests/interfaces/openlp_plugins/planningcenter/lib/test_songimport.py b/tests/interfaces/openlp_plugins/planningcenter/lib/test_songimport.py
new file mode 100644
index 000000000..34d598321
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/lib/test_songimport.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
+"""
+Package to test the openlp.plugins.planningcenter.lib.songimport package.
+"""
+import datetime
+from unittest import TestCase
+from unittest.mock import patch
+
+from openlp.core.common.registry import Registry
+from openlp.plugins.planningcenter.lib.songimport import PlanningCenterSongImport
+from tests.helpers.testmixin import TestMixin
+
+
+class TestSongImport(TestCase, TestMixin):
+ """
+ Test the PlanningcenterPlugin class
+ """
+ def setUp(self):
+ """
+ Create the class
+ """
+ self.registry = Registry()
+ Registry.create()
+ with patch('openlp.core.common.registry.Registry.get'):
+ self.song_import = PlanningCenterSongImport()
+
+ def tearDown(self):
+ """
+ Delete all the C++ objects at the end so that we don't have a segfault
+ """
+ del self.registry
+ del self.song_import
+
+ def test_add_song_without_lyrics(self):
+ """
+ Test that a song can be added with None lyrics
+ """
+ # GIVEN: A PlanningCenterSongImport Class
+ # WHEN: A song is added without lyrics
+ item_title = 'Title'
+ author = 'Author'
+ lyrics = None
+ theme_name = 'Theme Name'
+ last_modified = datetime.datetime.now()
+ with patch('openlp.plugins.songs.lib.importers.songimport.Song') as mock_song, \
+ patch('openlp.plugins.songs.lib.importers.songimport.Author'):
+ self.song_import.add_song(item_title, author, lyrics, theme_name, last_modified)
+ # THEN: A mock song has valid title, lyrics, and theme_name values
+ self.assertEqual(mock_song.return_value.title, item_title, "Mock Song Title matches input title")
+ self.assertTrue(item_title in mock_song.return_value.lyrics, "Mock Song Lyrics contain input title")
+ self.assertEqual(mock_song.return_value.theme_name, theme_name, "Mock Song Theme matches input theme")
+
+ def test_add_song_with_lyrics(self):
+ """
+ Test that a song can be added with lyrics
+ """
+ # GIVEN: A PlanningCenterSongImport Class
+ # WHEN: A song is added without lyrics
+ item_title = 'Title'
+ author = 'Author'
+ lyrics = 'This is my song!'
+ theme_name = 'Theme Name'
+ last_modified = datetime.datetime.now()
+ with patch('openlp.plugins.songs.lib.importers.songimport.Song') as mock_song, \
+ patch('openlp.plugins.songs.lib.importers.songimport.Author'):
+ self.song_import.add_song(item_title, author, lyrics, theme_name, last_modified)
+ # THEN: A mock song has valid title, lyrics, and theme_name values
+ self.assertEqual(mock_song.return_value.title, item_title, "Mock Song Title matches input title")
+ self.assertTrue(lyrics in mock_song.return_value.lyrics, "Mock Song Lyrics contain input lyrics")
+ self.assertEqual(mock_song.return_value.theme_name, theme_name, "Mock Song Theme matches input theme")
+
+ def test_add_song_with_verse(self):
+ """
+ Test that a song can be added with lyrics that contain a verse header
+ """
+ # GIVEN: A PlanningCenterSongImport Class
+ # WHEN: A song is added with lyrics that contain a verse tag
+ item_title = 'Title'
+ author = 'Author'
+ lyrics = 'V1\nThis is my song!'
+ theme_name = 'Theme Name'
+ last_modified = datetime.datetime.now()
+ with patch('openlp.plugins.songs.lib.importers.songimport.Song') as mock_song, \
+ patch('openlp.plugins.songs.lib.importers.songimport.Author'):
+ self.song_import.add_song(item_title, author, lyrics, theme_name, last_modified)
+ # THEN: A mock song has valid title, lyrics, and theme_name values
+ self.assertEqual(mock_song.return_value.title, item_title,
+ "Mock Song Title matches input title")
+ self.assertTrue("This is my song!" in mock_song.return_value.lyrics,
+ "Mock Song Lyrics contain input lyrics")
+ self.assertTrue("type=\"v\"" in mock_song.return_value.lyrics,
+ "Mock Song Lyrics contain input verse")
+ self.assertEqual(mock_song.return_value.theme_name, theme_name,
+ "Mock Song Theme matches input theme")
+
+ def test_parse_lyrics_with_end_marker(self):
+ """
+ Test that a lyrics after an END marker are skipped
+ """
+ # GIVEN: A PlanningCenterSongImport Class
+ # WHEN: _split_lyrics_into_verses is called with lyrics that contain an END marker
+ lyrics = 'This is my song!\n\n{END}\n\nSkip this part of the song'
+ output_verses = self.song_import._split_lyrics_into_verses(lyrics)
+ # THEN: A mock song has valid title, lyrics, and theme_name values
+ self.assertEqual(len(output_verses), 1, "A single verse is returned")
+ self.assertTrue("This is my song!" in output_verses[0]['verse_text'], "Output lyrics contain input lyrics")
+ self.assertFalse("END" in output_verses[0]['verse_text'], "Output Lyrics do not contain END")
+ self.assertFalse("Skip this part of the song" in output_verses[0]['verse_text'], "Output Lyrics stop at end")
+
+ def test_parse_lyrics_with_multiple_verses(self):
+ """
+ Test lyrics with verse marker inside
+ """
+ # GIVEN: A PlanningCenterSongImport Class
+ # WHEN: _split_lyrics_into_verses is called with lyrics that contain a verse marker inside (chorus)
+ lyrics = 'First Verse\n\nChorus\nThis is my Chorus\n'
+ output_verses = self.song_import._split_lyrics_into_verses(lyrics)
+ # THEN: A mock song has valid title, lyrics, and theme_name values
+ self.assertEqual(len(output_verses), 2, "Two output verses are returned")
+ self.assertEqual(output_verses[1]['verse_type'], 'c', "Second verse is a chorus")
+
+ def test_parse_lyrics_with_single_spaced_verse_tags(self):
+ """
+ Test lyrics with verse marker inside
+ """
+ # GIVEN: A PlanningCenterSongImport Class
+ # WHEN: _split_lyrics_into_verses is called with lyrics that contain a verse marker inside (chorus)
+ lyrics = 'V1\nFirst Verse\nV2\nSecondVerse\n'
+ output_verses = self.song_import._split_lyrics_into_verses(lyrics)
+ # THEN: A mock song has valid title, lyrics, and theme_name values
+ self.assertEqual(len(output_verses), 2, "Two output verses are returned")
diff --git a/tests/interfaces/openlp_plugins/planningcenter/test_planningcenterplugin.py b/tests/interfaces/openlp_plugins/planningcenter/test_planningcenterplugin.py
new file mode 100644
index 000000000..5d08084e5
--- /dev/null
+++ b/tests/interfaces/openlp_plugins/planningcenter/test_planningcenterplugin.py
@@ -0,0 +1,172 @@
+# -*- coding: utf-8 -*-
+
+##########################################################################
+# OpenLP - Open Source Lyrics Projection #
+# ---------------------------------------------------------------------- #
+# Copyright (c) 2008-2019 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 . #
+##########################################################################
+"""
+Package to test the openlp.plugins.planningcenter.planningcenterplugin package.
+"""
+from unittest import TestCase
+from unittest.mock import patch, MagicMock
+
+from PyQt5 import QtWidgets
+
+from openlp.core.common.registry import Registry
+from openlp.core.common.settings import Settings
+from openlp.core.state import State
+from openlp.core.ui.icons import UiIcons
+from openlp.core.ui.settingsform import SettingsForm
+from openlp.plugins.planningcenter.planningcenterplugin import PlanningCenterPlugin
+from tests.helpers.testmixin import TestMixin
+
+
+class TestPlanningCenterPlugin(TestCase, TestMixin):
+ """
+ Test the PlanningcenterPlugin class
+ """
+ def setUp(self):
+ """
+ Create the UI
+ """
+ self.setup_application()
+ self.registry = Registry()
+ Registry.create()
+ State().load_settings()
+ self.plugin = PlanningCenterPlugin()
+ self.settings_form = SettingsForm()
+
+ def tearDown(self):
+ """
+ Delete all the C++ objects at the end so that we don't have a segfault
+ """
+ del self.registry
+ del self.plugin
+ del self.settings_form
+
+ def test_class_init_defaults(self):
+ """
+ Test that the plugin class is instantiated with the correct defaults
+ """
+ # GIVEN: A PlanningcenterPlugin Class
+ # WHEN: the class has been through __init__
+ # THEN:
+ # planningcenter form is set to None
+ self.assertEqual(self.plugin.planningcenter_form, None, "Init plugin set to None")
+ # icon is set correctly
+ self.assertEqual(self.plugin.icon, UiIcons().planning_center, "Init icon set to planning_center icon")
+ # weight is -1
+ self.assertEqual(self.plugin.weight, -1, "Init weight set to -1")
+ # the planning_center module is registered active
+ self.assertEqual(State().is_module_active('planning_center'), True, "Init State() is active")
+
+ def test_initialise(self):
+ """
+ Test that the initialise function can be called and it passes a call along
+ to its parent class
+ """
+ # GIVEN: A PlanningcenterPlugin Class
+ # WHEN: initialise has been called on the class
+ with patch('openlp.plugins.planningcenter.planningcenterplugin.PlanningCenterPlugin.import_planning_center',
+ create=True):
+ return_value = self.plugin.initialise()
+ # THEN:
+ # the function returns and does not fail... it doesn't do much at this point, so this
+ # is mainly to improve test coverage
+ self.assertEqual(return_value, None, "Initialise was called on the class and it didn't crash")
+
+ def test_import_menu_item_added(self):
+ """
+ Test that the add_import_menu_item function adds the menu item
+ """
+ # GIVEN: A PlanningcenterPlugin Class
+ # WHEN: add_import_menu_item is called
+ import_menu = QtWidgets.QMenu()
+ self.plugin.add_import_menu_item(import_menu)
+ self.plugin.import_planning_center.setVisible(True)
+ # THEN:
+ # the menu should not be empty
+ self.assertEqual(import_menu.isEmpty(), False, "Menu Item is populated")
+
+ @patch('openlp.plugins.planningcenter.forms.selectplanform.SelectPlanForm.exec')
+ @patch('openlp.core.ui.settingsform.SettingsForm.exec')
+ def test_on_import_planning_center_triggered_with_auth_settings(self, mock_editauth_exec, mock_selectplan_exec):
+ """
+ Test that the on_import_planning_center_triggered function correctly returns
+ the correct form to display.
+ """
+ # GIVEN: A PlanningCenterPlugin Class with mocked exec calls on both
+ # PlanningCenter forms and settings set
+ application_id = 'abc'
+ secret = '123'
+ Settings().setValue('planningcenter/application_id', application_id)
+ Settings().setValue('planningcenter/secret', secret)
+ # init the planning center plugin so we have default values defined for Settings()
+ # WHEN: on_import_planning_center_triggered is called
+ self.plugin.on_import_planning_center_triggered()
+ # THEN:
+ self.assertEqual(mock_selectplan_exec.call_count, 1, "Select Plan Form was shown")
+ self.assertEqual(mock_editauth_exec.call_count, 0, "Edit Auth Form was not shown")
+
+ @patch('openlp.plugins.planningcenter.forms.selectplanform.SelectPlanForm.exec')
+ @patch('openlp.core.ui.settingsform.SettingsForm.exec')
+ def test_on_import_planning_center_triggered_without_auth_settings(self, mock_editauth_exec, mock_selectplan_exec):
+ """
+ Test that the on_import_planning_center_triggered function correctly returns
+ the correct form to display.
+ """
+ # GIVEN: A PlanningCenterPlugin Class with mocked exec calls on both
+ # PlanningCenter forms and settings set
+ application_id = ''
+ secret = ''
+ Settings().setValue('planningcenter/application_id', application_id)
+ Settings().setValue('planningcenter/secret', secret)
+ # init the planning center plugin so we have default values defined for Settings()
+ # WHEN: on_import_planning_center_triggered is called
+ self.plugin.on_import_planning_center_triggered()
+ # THEN:
+ self.assertEqual(mock_selectplan_exec.call_count, 0, "Select Plan Form was not shown")
+ self.assertEqual(mock_editauth_exec.call_count, 1, "Edit Auth Form was shown")
+
+ def test_about(self):
+ """
+ Test that the about function returns text.
+ """
+ # GIVEN: A PlanningCenterPlugin Class
+ # WHEN: about() is called
+ return_value = self.plugin.about()
+ # THEN:
+ self.assertGreater(len(return_value), 0, "About function returned some text")
+
+ def test_finalise(self):
+ """
+ Test finalise function
+ """
+ # GIVEN: A PlanningCenterPlugin Class
+ # WHEN: finalise() is called
+ with patch('openlp.core.common.registry.Registry.get') as mock_get, \
+ patch('openlp.plugins.planningcenter.planningcenterplugin.Song'), \
+ patch('openlp.plugins.planningcenter.planningcenterplugin.CustomSlide'), \
+ patch('openlp.plugins.planningcenter.planningcenterplugin.PlanningCenterPlugin.import_planning_center',
+ create=True):
+ mock_get.return_value.plugin.manager.get_all_objects.return_value = [MagicMock()]
+ mock_get.return_value.plugin.db_manager.get_all_objects.return_value = [MagicMock()]
+ self.plugin.finalise()
+ self.assertEqual(mock_get.return_value.plugin.manager.get_all_objects.call_count, 1, 'Get All Object Counts')
+ self.assertEqual(mock_get.return_value.plugin.manager.delete_object.call_count, 1, 'Get All Object Counts')
+ self.assertEqual(mock_get.return_value.plugin.db_manager.get_all_objects.call_count, 1, 'Get All Object Counts')
+ self.assertEqual(mock_get.return_value.plugin.db_manager.delete_object.call_count, 1, 'Delete Object Counts')
diff --git a/tests/resources/planningcenter/null.json b/tests/resources/planningcenter/null.json
new file mode 100644
index 000000000..1b4a82e34
--- /dev/null
+++ b/tests/resources/planningcenter/null.json
@@ -0,0 +1 @@
+{"data": {"type": "Organization", "id": "123", "attributes": {"ccli": "123", "ccli_connected": true, "ccli_reporting_enabled": true, "created_at": "2019-01-01T11:11:11Z", "date_format": "US", "file_storage_exceeded": false, "legacy_id": "123", "music_stand_enabled": true, "name": "Church Name", "owner_name": "This Person", "people_allowed": 150, "people_remaining": 1, "projector_enabled": false, "secret": "123", "time_zone": "America/Chicago", "twenty_four_hour_time": true, "updated_at": "2019-10-30T20:35:07Z"}, "links": {"attachment_types": "https://api.planningcenteronline.com/services/v2/attachment_types", "email_templates": "https://api.planningcenteronline.com/services/v2/email_templates", "folders": "https://api.planningcenteronline.com/services/v2/folders", "media": "https://api.planningcenteronline.com/services/v2/media", "people": "https://api.planningcenteronline.com/services/v2/people", "plans": null, "series": "https://api.planningcenteronline.com/services/v2/series", "service_types": "https://api.planningcenteronline.com/services/v2/service_types", "songs": "https://api.planningcenteronline.com/services/v2/songs", "tag_groups": "https://api.planningcenteronline.com/services/v2/tag_groups", "teams": "https://api.planningcenteronline.com/services/v2/teams", "self": "https://api.planningcenteronline.com/services/v2"}}, "included": [], "meta": {}}
\ No newline at end of file
diff --git a/tests/resources/planningcenter/service_types.json b/tests/resources/planningcenter/service_types.json
new file mode 100644
index 000000000..a3d00e44f
--- /dev/null
+++ b/tests/resources/planningcenter/service_types.json
@@ -0,0 +1 @@
+{"links": {"self": "https://api.planningcenteronline.com/services/v2/service_types"}, "data": [{"type": "ServiceType", "id": "122061", "attributes": {"archived_at": null, "attachment_types_enabled": true, "background_check_permissions": "default", "comment_permissions": "Scheduled Viewer", "created_at": "2011-08-04T01:31:57Z", "deleted_at": null, "frequency": "Weekly", "last_plan_from": "position", "name": "gbf", "permissions": "Editor", "sequence": 0, "updated_at": "2019-08-03T23:31:21Z"}, "relationships": {"parent": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061"}}, {"type": "ServiceType", "id": "561347", "attributes": {"archived_at": null, "attachment_types_enabled": false, "background_check_permissions": "default", "comment_permissions": "Viewer", "created_at": "2016-02-04T20:18:28Z", "deleted_at": null, "frequency": "Weekly", "last_plan_from": "position", "name": "OpenLP Testing", "permissions": "Editor", "sequence": 0, "updated_at": "2019-02-22T17:44:13Z"}, "relationships": {"parent": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347"}}], "included": [], "meta": {"total_count": 2, "count": 2, "can_order_by": ["sequence", "name"], "can_query_by": ["id", "parent_id"], "can_include": ["time_preference_options"], "parent": {"id": "59443", "type": "Organization"}}}
\ No newline at end of file
diff --git a/tests/resources/planningcenter/service_types_122061_plans_filter_future_per_page_10_order_sort_date.json b/tests/resources/planningcenter/service_types_122061_plans_filter_future_per_page_10_order_sort_date.json
new file mode 100644
index 000000000..10eab4ed3
--- /dev/null
+++ b/tests/resources/planningcenter/service_types_122061_plans_filter_future_per_page_10_order_sort_date.json
@@ -0,0 +1 @@
+{"links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans?filter=future&order=sort_date&per_page=10", "next": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans?filter=future&offset=10&order=sort_date&per_page=10"}, "data": [{"type": "Plan", "id": "39496412", "attributes": {"created_at": "2018-12-04T22:43:25Z", "dates": "October 27, 2019", "files_expire_at": "2019-11-11T12:00:00Z", "items_count": 20, "last_time_at": "2019-10-27T10:30:00Z", "multi_day": false, "needed_positions_count": 3, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 21, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Oct 27", "sort_date": "2019-10-27T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-22T20:14:14Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "44038360"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496208"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496412"}}, {"type": "Plan", "id": "44038360", "attributes": {"created_at": "2019-09-03T22:12:54Z", "dates": "October 30, 2019", "files_expire_at": "2019-11-14T20:00:00Z", "items_count": 0, "last_time_at": "2019-10-30T18:00:00Z", "multi_day": false, "needed_positions_count": 6, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 11, "public": false, "rehearsal_time_count": 0, "series_title": "Rehearsal", "service_time_count": 1, "short_dates": "Oct 30", "sort_date": "2019-10-30T18:00:00Z", "title": null, "total_length": 0, "updated_at": "2019-10-15T04:29:32Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496415"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496412"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1262528"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/44038360"}}, {"type": "Plan", "id": "39496415", "attributes": {"created_at": "2018-12-04T22:43:35Z", "dates": "November 3, 2019", "files_expire_at": "2019-11-18T12:00:00Z", "items_count": 23, "last_time_at": "2019-11-03T10:30:00Z", "multi_day": false, "needed_positions_count": 15, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 23, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Nov 3", "sort_date": "2019-11-03T10:30:00Z", "title": "Communion Sunday", "total_length": 0, "updated_at": "2019-10-15T19:33:32Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496417"}}, "previous_plan": {"data": {"type": "Plan", "id": "44038360"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496415"}}, {"type": "Plan", "id": "39496417", "attributes": {"created_at": "2018-12-04T22:43:46Z", "dates": "November 10, 2019", "files_expire_at": "2019-11-25T12:00:00Z", "items_count": 20, "last_time_at": "2019-11-10T10:30:00Z", "multi_day": false, "needed_positions_count": 13, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 19, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Nov 10", "sort_date": "2019-11-10T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-07T15:53:30Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "42807438"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496415"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496417"}}, {"type": "Plan", "id": "42807438", "attributes": {"created_at": "2019-06-22T06:21:58Z", "dates": "November 13, 2019", "files_expire_at": "2019-11-28T20:00:00Z", "items_count": 0, "last_time_at": "2019-11-13T18:00:00Z", "multi_day": false, "needed_positions_count": 6, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 11, "public": false, "rehearsal_time_count": 0, "series_title": "Rehearsal", "service_time_count": 1, "short_dates": "Nov 13", "sort_date": "2019-11-13T18:00:00Z", "title": "Open Rehearsal", "total_length": 0, "updated_at": "2019-10-15T04:29:36Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496418"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496417"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1262528"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/42807438"}}, {"type": "Plan", "id": "39496418", "attributes": {"created_at": "2018-12-04T22:43:56Z", "dates": "November 17, 2019", "files_expire_at": "2019-12-02T12:00:00Z", "items_count": 20, "last_time_at": "2019-11-17T10:30:00Z", "multi_day": false, "needed_positions_count": 12, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 20, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Nov 17", "sort_date": "2019-11-17T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-07T15:54:32Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496420"}}, "previous_plan": {"data": {"type": "Plan", "id": "42807438"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496418"}}, {"type": "Plan", "id": "39496420", "attributes": {"created_at": "2018-12-04T22:44:07Z", "dates": "November 24, 2019", "files_expire_at": "2019-12-09T12:00:00Z", "items_count": 20, "last_time_at": "2019-11-24T10:30:00Z", "multi_day": false, "needed_positions_count": 14, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 20, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Nov 24", "sort_date": "2019-11-24T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-07T15:53:03Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496504"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496418"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496420"}}, {"type": "Plan", "id": "39496504", "attributes": {"created_at": "2018-12-04T22:52:30Z", "dates": "December 1, 2019", "files_expire_at": "2019-12-16T12:00:00Z", "items_count": 23, "last_time_at": "2019-12-01T10:30:00Z", "multi_day": false, "needed_positions_count": 18, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 18, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Dec 1", "sort_date": "2019-12-01T10:30:00Z", "title": "Communion Sunday (Advent)", "total_length": 0, "updated_at": "2019-10-07T15:53:07Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496506"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496420"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496504"}}, {"type": "Plan", "id": "39496506", "attributes": {"created_at": "2018-12-04T22:52:43Z", "dates": "December 8, 2019", "files_expire_at": "2019-12-23T12:00:00Z", "items_count": 20, "last_time_at": "2019-12-08T10:30:00Z", "multi_day": false, "needed_positions_count": 14, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 16, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Dec 8", "sort_date": "2019-12-08T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-07T15:53:08Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39562217"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496504"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496506"}}, {"type": "Plan", "id": "39562217", "attributes": {"created_at": "2018-12-10T15:25:36Z", "dates": "December 14, 2019", "files_expire_at": "2019-12-29T19:00:00Z", "items_count": 0, "last_time_at": "2019-12-14T18:00:00Z", "multi_day": false, "needed_positions_count": 85, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 3, "public": false, "rehearsal_time_count": 0, "series_title": "Bastrop Christmas Parade", "service_time_count": 1, "short_dates": "Dec 14", "sort_date": "2019-12-14T18:00:00Z", "title": "Christmas Parade", "total_length": 0, "updated_at": "2019-10-16T15:05:11Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496508"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496506"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1262528"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39562217"}}], "included": [], "meta": {"total_count": 14, "count": 10, "next": {"offset": 10}, "can_order_by": ["title", "created_at", "updated_at", "sort_date"], "can_query_by": ["created_at", "updated_at", "title"], "can_include": ["contributors", "my_schedules", "plan_times", "series"], "can_filter": ["future", "past", "after", "before", "no_dates"], "parent": {"id": "122061", "type": "ServiceType"}}}
\ No newline at end of file
diff --git a/tests/resources/planningcenter/service_types_122061_plans_filter_past_per_page_10_order__sort_date.json b/tests/resources/planningcenter/service_types_122061_plans_filter_past_per_page_10_order__sort_date.json
new file mode 100644
index 000000000..366451d77
--- /dev/null
+++ b/tests/resources/planningcenter/service_types_122061_plans_filter_past_per_page_10_order__sort_date.json
@@ -0,0 +1 @@
+{"links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans?filter=past&order=-sort_date&per_page=10", "next": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans?filter=past&offset=10&order=-sort_date&per_page=10"}, "data": [{"type": "Plan", "id": "39496208", "attributes": {"created_at": "2018-12-04T22:35:39Z", "dates": "October 20, 2019", "files_expire_at": "2019-11-04T12:00:00Z", "items_count": 21, "last_time_at": "2019-10-20T10:30:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 24, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Oct 20", "sort_date": "2019-10-20T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-20T00:49:09Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496412"}}, "previous_plan": {"data": {"type": "Plan", "id": "42256679"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496208"}}, {"type": "Plan", "id": "42256679", "attributes": {"created_at": "2019-05-16T18:45:16Z", "dates": "October 16, 2019", "files_expire_at": "2019-10-31T20:00:00Z", "items_count": 33, "last_time_at": "2019-10-16T18:00:00Z", "multi_day": false, "needed_positions_count": 5, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 10, "public": false, "rehearsal_time_count": 0, "series_title": "Rehearsal", "service_time_count": 1, "short_dates": "Oct 16", "sort_date": "2019-10-16T18:00:00Z", "title": "Open Rehearsal", "total_length": 425, "updated_at": "2019-10-16T14:44:50Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496208"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496202"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1262528"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/42256679"}}, {"type": "Plan", "id": "39496202", "attributes": {"created_at": "2018-12-04T22:35:30Z", "dates": "October 13, 2019", "files_expire_at": "2019-10-28T12:00:00Z", "items_count": 22, "last_time_at": "2019-10-13T10:30:00Z", "multi_day": false, "needed_positions_count": 1, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 23, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Oct 13", "sort_date": "2019-10-13T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-10-14T12:31:29Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "42256679"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496196"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496202"}}, {"type": "Plan", "id": "39496196", "attributes": {"created_at": "2018-12-04T22:35:21Z", "dates": "October 6, 2019", "files_expire_at": "2019-10-21T12:00:00Z", "items_count": 24, "last_time_at": "2019-10-06T10:30:00Z", "multi_day": false, "needed_positions_count": 1, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 26, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Oct 6", "sort_date": "2019-10-06T10:30:00Z", "title": "Communion Sunday", "total_length": 0, "updated_at": "2019-10-06T13:40:08Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496202"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496191"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "11480165"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496196"}}, {"type": "Plan", "id": "39496191", "attributes": {"created_at": "2018-12-04T22:35:12Z", "dates": "September 29, 2019", "files_expire_at": "2019-10-14T12:00:00Z", "items_count": 21, "last_time_at": "2019-09-29T10:30:00Z", "multi_day": false, "needed_positions_count": 2, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 23, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Sept 29", "sort_date": "2019-09-29T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-09-28T20:20:57Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496196"}}, "previous_plan": {"data": {"type": "Plan", "id": "39496186"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496191"}}, {"type": "Plan", "id": "39496186", "attributes": {"created_at": "2018-12-04T22:35:02Z", "dates": "September 22, 2019", "files_expire_at": "2019-10-07T12:00:00Z", "items_count": 21, "last_time_at": "2019-09-22T10:30:00Z", "multi_day": false, "needed_positions_count": 1, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 24, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Sept 22", "sort_date": "2019-09-22T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-09-22T17:11:58Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496191"}}, "previous_plan": {"data": {"type": "Plan", "id": "39495999"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39496186"}}, {"type": "Plan", "id": "39495999", "attributes": {"created_at": "2018-12-04T22:24:23Z", "dates": "September 15, 2019", "files_expire_at": "2019-09-30T12:00:00Z", "items_count": 21, "last_time_at": "2019-09-15T10:30:00Z", "multi_day": false, "needed_positions_count": 3, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 21, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Sept 15", "sort_date": "2019-09-15T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-09-15T13:31:00Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39496186"}}, "previous_plan": {"data": {"type": "Plan", "id": "39495996"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39495999"}}, {"type": "Plan", "id": "39495996", "attributes": {"created_at": "2018-12-04T22:24:15Z", "dates": "September 8, 2019", "files_expire_at": "2019-09-23T12:00:00Z", "items_count": 21, "last_time_at": "2019-09-08T10:30:00Z", "multi_day": false, "needed_positions_count": 1, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 22, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Sept 8", "sort_date": "2019-09-08T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-09-08T00:41:24Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39495999"}}, "previous_plan": {"data": {"type": "Plan", "id": "39495995"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39495996"}}, {"type": "Plan", "id": "39495995", "attributes": {"created_at": "2018-12-04T22:24:07Z", "dates": "September 1, 2019", "files_expire_at": "2019-09-16T12:00:00Z", "items_count": 26, "last_time_at": "2019-09-01T10:30:00Z", "multi_day": false, "needed_positions_count": 1, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 26, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Sept 1", "sort_date": "2019-09-01T10:30:00Z", "title": "Communion Sunday", "total_length": 0, "updated_at": "2019-09-01T16:43:54Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39495996"}}, "previous_plan": {"data": {"type": "Plan", "id": "39495991"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39495995"}}, {"type": "Plan", "id": "39495991", "attributes": {"created_at": "2018-12-04T22:23:59Z", "dates": "August 25, 2019", "files_expire_at": "2019-09-09T12:00:00Z", "items_count": 21, "last_time_at": "2019-08-25T10:30:00Z", "multi_day": false, "needed_positions_count": 2, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 23, "public": false, "rehearsal_time_count": 1, "series_title": "Worship", "service_time_count": 1, "short_dates": "Aug 25", "sort_date": "2019-08-25T10:30:00Z", "title": "Morning Worship", "total_length": 0, "updated_at": "2019-08-25T15:27:00Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "122061"}}, "next_plan": {"data": {"type": "Plan", "id": "39495995"}}, "previous_plan": {"data": {"type": "Plan", "id": "39495988"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1262528"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/122061/plans/39495991"}}], "included": [], "meta": {"total_count": 445, "count": 10, "next": {"offset": 10}, "can_order_by": ["title", "created_at", "updated_at", "sort_date"], "can_query_by": ["created_at", "updated_at", "title"], "can_include": ["contributors", "my_schedules", "plan_times", "series"], "can_filter": ["future", "past", "after", "before", "no_dates"], "parent": {"id": "122061", "type": "ServiceType"}}}
\ No newline at end of file
diff --git a/tests/resources/planningcenter/service_types_561347_plans_44583533_items_include_song_arrangement_per_page_100.json b/tests/resources/planningcenter/service_types_561347_plans_44583533_items_include_song_arrangement_per_page_100.json
new file mode 100644
index 000000000..6c744bedd
--- /dev/null
+++ b/tests/resources/planningcenter/service_types_561347_plans_44583533_items_include_song_arrangement_per_page_100.json
@@ -0,0 +1 @@
+{"links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items?include=song%2Carrangement&per_page=100"}, "data": [{"type": "Item", "id": "602462101", "attributes": {"created_at": "2019-10-04T19:05:24Z", "custom_arrangement_sequence": null, "custom_arrangement_sequence_short": null, "description": null, "html_details": null, "item_type": "song", "key_name": "G", "length": 0, "sequence": 1, "service_position": "during", "title": "Amazing Grace", "updated_at": "2019-10-04T19:05:24Z"}, "relationships": {"plan": {"data": {"type": "Plan", "id": "44583533"}}, "song": {"links": {"related": "https://api.planningcenteronline.com/services/v2/songs/2732168"}, "data": {"type": "Song", "id": "2732168"}}, "arrangement": {"links": {"related": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/602462101/arrangement"}, "data": {"type": "Arrangement", "id": "3170419"}}, "key": {"data": {"type": "Key", "id": "2570033"}}, "selected_layout": {"data": null}, "selected_background": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/602462101"}}, {"type": "Item", "id": "602463006", "attributes": {"created_at": "2019-10-04T19:10:18Z", "custom_arrangement_sequence": null, "custom_arrangement_sequence_short": null, "description": null, "html_details": null, "item_type": "item", "key_name": null, "length": 0, "sequence": 2, "service_position": "during", "title": "Test Slide without Detail", "updated_at": "2019-10-04T19:10:18Z"}, "relationships": {"plan": {"data": {"type": "Plan", "id": "44583533"}}, "song": {"data": null}, "arrangement": {"data": null}, "key": {"data": null}, "selected_layout": {"data": null}, "selected_background": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/602463006"}}, {"type": "Item", "id": "602462905", "attributes": {"created_at": "2019-10-04T19:09:24Z", "custom_arrangement_sequence": null, "custom_arrangement_sequence_short": null, "description": null, "html_details": null, "item_type": "item", "key_name": null, "length": 0, "sequence": 3, "service_position": "during", "title": "John 3:16", "updated_at": "2019-10-04T19:09:24Z"}, "relationships": {"plan": {"data": {"type": "Plan", "id": "44583533"}}, "song": {"data": null}, "arrangement": {"data": null}, "key": {"data": null}, "selected_layout": {"data": null}, "selected_background": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/602462905"}}, {"type": "Item", "id": "606373847", "attributes": {"created_at": "2019-10-23T20:54:49Z", "custom_arrangement_sequence": null, "custom_arrangement_sequence_short": null, "description": null, "html_details": "Lorem ipsum dolor sit amet, a at vitae, nulla venenatis suscipit eros, pede aliquam justo sed, malesuada ut ultrices venenatis gravida. Nulla erat mi. Fusce diam elementum fusce in, sodales vitae lorem sem nulla cum, donec etiam potenti lacus. Quis blandit. Molestie ultricies vestibulum aliquam, eleifend in, nisl eu sollicitudin vitae magna ut, nunc eros eros ante vitae. Varius a. Metus justo, vel et tortor nulla maecenas wisi, donec pellentesque velit ipsum integer, mauris fusce risus consectetuer aliquam nulla, arcu est. Lectus lorem. Venenatis aliquam erat id at nulla, purus ut. Quis tellus velit, consectetuer eget eu.
", "item_type": "item", "key_name": null, "length": 0, "sequence": 4, "service_position": "during", "title": "Custom Slide with long details", "updated_at": "2019-10-23T20:56:13Z"}, "relationships": {"plan": {"data": {"type": "Plan", "id": "44583533"}}, "song": {"data": null}, "arrangement": {"data": null}, "key": {"data": null}, "selected_layout": {"data": null}, "selected_background": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/606373847"}}, {"type": "Item", "id": "606373849", "attributes": {"created_at": "2019-10-23T20:54:49Z", "custom_arrangement_sequence": null, "custom_arrangement_sequence_short": null, "description": null, "html_details": "Bold, Italics, Underline
\r\nRed, Black, Blue, Yellow, Green, Pink, Orange, Purple, White
\r\n\r\n- bullet 1 to throw away
\r\n- bullet 2 to throw away
\r\n
\r\nThese are details that should go into a slide. \u00a0Line breaks should be auto added and a slide should be formed from these details.
I also need a short line here.
", "item_type": "item", "key_name": null, "length": 0, "sequence": 5, "service_position": "during", "title": "Custom Slide with html", "updated_at": "2019-10-23T20:57:42Z"}, "relationships": {"plan": {"data": {"type": "Plan", "id": "44583533"}}, "song": {"data": null}, "arrangement": {"data": null}, "key": {"data": null}, "selected_layout": {"data": null}, "selected_background": {"data": null}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/606373849"}}], "included": [{"type": "Song", "id": "2732168", "attributes": {"admin": "CCLI Administration Account", "author": "Edwin Othello Excell, John Newton, and John P. Rees", "ccli_number": 22025, "copyright": " Public Domain", "created_at": "2011-08-08T03:01:54Z", "hidden": false, "last_scheduled_at": "2019-09-29T09:00:00Z", "last_scheduled_short_dates": "Sept 29, 2019", "notes": null, "themes": "Assurance, Grace, Praise, Salvation, Testimony", "title": "Amazing Grace", "updated_at": "2011-10-13T15:33:11Z"}, "links": {"self": "https://api.planningcenteronline.com/services/v2/songs/2732168"}}, {"type": "Arrangement", "id": "3170419", "attributes": {"archived_at": null, "bpm": null, "chord_chart": " G C G\r\nAmazing grace how sweet the sound\r\n D D7\r\nThat saved a wretch like me\r\n G C G\r\nI once was lost but now I'm found\r\n Em D G\r\nWas blind but now I see\r\n\r\n\r\n G C G\r\n'Twas grace that taught my heart to fear\r\n D D7\r\nAnd grace my fears relieved\r\n G C G\r\nHow precious did that grace appear\r\n Em D G\r\nThe hour I first believed\r\n\r\n\r\n G C G\r\nThrough many dangers toils and snares\r\n D D7\r\nI have already come\r\n G C G\r\n'Tis grace hath brought me safe thus far\r\n Em D G\r\nAnd grace will lead me home\r\n\r\n\r\n G C G\r\nWhen we've been there ten thousand years\r\n D D7\r\nBright shining as the sun\r\n G C G\r\nWe've no less days to sing God's praise\r\n Em D G\r\nThan when we first begun\r\n\r\n{END}", "chord_chart_chord_color": 0, "chord_chart_columns": 1, "chord_chart_font": "Courier", "chord_chart_font_size": 14, "chord_chart_key": "G", "created_at": "2011-08-08T03:01:54Z", "has_chord_chart": true, "has_chords": true, "isrc": null, "length": 0, "lyrics": "Amazing grace how sweet the sound\nThat saved a wretch like me\nI once was lost but now I'm found\nWas blind but now I see\n\n'Twas grace that taught my heart to fear\nAnd grace my fears relieved\nHow precious did that grace appear\nThe hour I first believed\n\nThrough many dangers toils and snares\nI have already come\n'Tis grace hath brought me safe thus far\nAnd grace will lead me home\n\nWhen we've been there ten thousand years\nBright shining as the sun\nWe've no less days to sing God's praise\nThan when we first begun\n\nEND", "lyrics_enabled": true, "meter": null, "name": "Default Arrangement", "notes": null, "number_chart_enabled": false, "numeral_chart_enabled": false, "print_margin": "0.5in", "print_orientation": "Portrait", "print_page_size": "Letter", "rehearsal_mix_id": null, "sequence": [], "sequence_short": [], "updated_at": "2019-10-04T19:05:24Z"}, "relationships": {"updated_by": {"data": {"type": "Person", "id": "9597859"}}, "created_by": {"data": {"type": "Person", "id": "1262528"}}, "song": {"data": {"type": "Song", "id": "2732168"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533/items/602462101/arrangement"}}], "meta": {"total_count": 5, "count": 5, "can_include": ["arrangement", "item_notes", "item_times", "key", "media", "selected_attachment", "song"], "parent": {"id": "44583533", "type": "Plan"}}}
\ No newline at end of file
diff --git a/tests/resources/planningcenter/service_types_561347_plans_filter_future_per_page_10_order_sort_date.json b/tests/resources/planningcenter/service_types_561347_plans_filter_future_per_page_10_order_sort_date.json
new file mode 100644
index 000000000..8a6b4359a
--- /dev/null
+++ b/tests/resources/planningcenter/service_types_561347_plans_filter_future_per_page_10_order_sort_date.json
@@ -0,0 +1 @@
+{"links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans?filter=future&order=sort_date&per_page=10"}, "data": [], "included": [], "meta": {"total_count": 0, "count": 0, "can_order_by": ["title", "created_at", "updated_at", "sort_date"], "can_query_by": ["created_at", "updated_at", "title"], "can_include": ["contributors", "my_schedules", "plan_times", "series"], "can_filter": ["future", "past", "after", "before", "no_dates"], "parent": {"id": "561347", "type": "ServiceType"}}}
\ No newline at end of file
diff --git a/tests/resources/planningcenter/service_types_561347_plans_filter_past_per_page_10_order__sort_date.json b/tests/resources/planningcenter/service_types_561347_plans_filter_past_per_page_10_order__sort_date.json
new file mode 100644
index 000000000..4f8260a73
--- /dev/null
+++ b/tests/resources/planningcenter/service_types_561347_plans_filter_past_per_page_10_order__sort_date.json
@@ -0,0 +1 @@
+{"links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans?filter=past&order=-sort_date&per_page=10", "next": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans?filter=past&offset=10&order=-sort_date&per_page=10"}, "data": [{"type": "Plan", "id": "44786590", "attributes": {"created_at": "2019-10-18T14:31:50Z", "dates": "October 20, 2019", "files_expire_at": "2019-11-04T10:00:00Z", "items_count": 2, "last_time_at": "2019-10-20T09:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 0, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Oct 20", "sort_date": "2019-10-20T09:00:00Z", "title": null, "total_length": 0, "updated_at": "2019-10-21T16:34:30Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": null}, "previous_plan": {"data": {"type": "Plan", "id": "44583671"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "9597859"}}, "updated_by": {"data": {"type": "Person", "id": "9597859"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44786590"}}, {"type": "Plan", "id": "44583671", "attributes": {"created_at": "2019-10-04T19:14:25Z", "dates": "October 13, 2019", "files_expire_at": "2019-10-28T10:00:00Z", "items_count": 1, "last_time_at": "2019-10-13T09:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 0, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Oct 13", "sort_date": "2019-10-13T09:00:00Z", "title": null, "total_length": 0, "updated_at": "2019-10-04T19:14:34Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "44786590"}}, "previous_plan": {"data": {"type": "Plan", "id": "44583667"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "9597859"}}, "updated_by": {"data": {"type": "Person", "id": "9597859"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583671"}}, {"type": "Plan", "id": "44583667", "attributes": {"created_at": "2019-10-04T19:13:49Z", "dates": "October 6, 2019", "files_expire_at": "2019-10-21T10:00:00Z", "items_count": 1, "last_time_at": "2019-10-06T09:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 0, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Oct 6", "sort_date": "2019-10-06T09:00:00Z", "title": null, "total_length": 0, "updated_at": "2019-10-04T19:14:07Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "44583671"}}, "previous_plan": {"data": {"type": "Plan", "id": "44583533"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "9597859"}}, "updated_by": {"data": {"type": "Person", "id": "9597859"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583667"}}, {"type": "Plan", "id": "44583533", "attributes": {"created_at": "2019-10-04T19:04:40Z", "dates": "September 29, 2019", "files_expire_at": "2019-10-14T10:00:00Z", "items_count": 5, "last_time_at": "2019-09-29T09:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 0, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 0, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Sept 29", "sort_date": "2019-09-29T09:00:00Z", "title": null, "total_length": 0, "updated_at": "2019-10-23T20:57:42Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "44583667"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287436"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "9597859"}}, "updated_by": {"data": {"type": "Person", "id": "9597859"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/44583533"}}, {"type": "Plan", "id": "27287436", "attributes": {"created_at": "2016-07-07T02:38:08Z", "dates": "December 24, 2016", "files_expire_at": "2017-01-08T20:00:00Z", "items_count": 0, "last_time_at": "2016-12-24T08:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 2, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Dec 24", "sort_date": "2016-12-24T08:00:00Z", "title": null, "total_length": 0, "updated_at": "2016-07-07T02:38:39Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "44583533"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287435"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/27287436"}}, {"type": "Plan", "id": "27287435", "attributes": {"created_at": "2016-07-07T02:38:08Z", "dates": "December 17, 2016", "files_expire_at": "2017-01-01T20:00:00Z", "items_count": 0, "last_time_at": "2016-12-17T08:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 2, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Dec 17", "sort_date": "2016-12-17T08:00:00Z", "title": null, "total_length": 0, "updated_at": "2016-07-07T02:38:28Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "27287436"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287434"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/27287435"}}, {"type": "Plan", "id": "27287434", "attributes": {"created_at": "2016-07-07T02:38:08Z", "dates": "December 10, 2016", "files_expire_at": "2016-12-25T20:00:00Z", "items_count": 0, "last_time_at": "2016-12-10T08:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 2, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Dec 10", "sort_date": "2016-12-10T08:00:00Z", "title": null, "total_length": 0, "updated_at": "2016-07-07T02:38:18Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "27287435"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287412"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/27287434"}}, {"type": "Plan", "id": "27287412", "attributes": {"created_at": "2016-07-07T02:35:23Z", "dates": "December 3, 2016", "files_expire_at": "2016-12-18T20:00:00Z", "items_count": 0, "last_time_at": "2016-12-03T08:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 2, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Dec 3", "sort_date": "2016-12-03T08:00:00Z", "title": null, "total_length": 0, "updated_at": "2016-07-07T02:37:54Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "27287434"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287411"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/27287412"}}, {"type": "Plan", "id": "27287411", "attributes": {"created_at": "2016-07-07T02:35:23Z", "dates": "November 26, 2016", "files_expire_at": "2016-12-11T20:00:00Z", "items_count": 0, "last_time_at": "2016-11-26T08:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 2, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Nov 26", "sort_date": "2016-11-26T08:00:00Z", "title": null, "total_length": 0, "updated_at": "2016-07-07T02:37:44Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "27287412"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287410"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/27287411"}}, {"type": "Plan", "id": "27287410", "attributes": {"created_at": "2016-07-07T02:35:23Z", "dates": "November 19, 2016", "files_expire_at": "2016-12-04T20:00:00Z", "items_count": 0, "last_time_at": "2016-11-19T08:00:00Z", "multi_day": false, "needed_positions_count": 0, "other_time_count": 1, "permissions": "Editor", "plan_notes_count": 0, "plan_people_count": 2, "public": false, "rehearsal_time_count": 0, "series_title": null, "service_time_count": 1, "short_dates": "Nov 19", "sort_date": "2016-11-19T08:00:00Z", "title": null, "total_length": 0, "updated_at": "2016-07-07T02:37:27Z"}, "relationships": {"service_type": {"data": {"type": "ServiceType", "id": "561347"}}, "next_plan": {"data": {"type": "Plan", "id": "27287411"}}, "previous_plan": {"data": {"type": "Plan", "id": "27287409"}}, "attachment_types": {"data": []}, "series": {"data": null}, "created_by": {"data": {"type": "Person", "id": "1510686"}}, "updated_by": {"data": {"type": "Person", "id": "1510686"}}}, "links": {"self": "https://api.planningcenteronline.com/services/v2/service_types/561347/plans/27287410"}}], "included": [], "meta": {"total_count": 51, "count": 10, "next": {"offset": 10}, "can_order_by": ["title", "created_at", "updated_at", "sort_date"], "can_query_by": ["created_at", "updated_at", "title"], "can_include": ["contributors", "my_schedules", "plan_times", "series"], "can_filter": ["future", "past", "after", "before", "no_dates"], "parent": {"id": "561347", "type": "ServiceType"}}}
\ No newline at end of file