openlp-core team mailing list archive
-
openlp-core team
-
Mailing list archive
-
Message #28286
[Merge] lp:~sam92/openlp/multiple-songbooks into lp:openlp
Samuel Mehrbrodt has proposed merging lp:~sam92/openlp/multiple-songbooks into lp:openlp.
Commit message:
Add support for multiple songbooks
* Migrate DB schema
* Add listbox to song edit dialog and enlarge that dialog a bit
* Rename "Song Book" to "Songbook"
* In the search results, display one row for each songbook entry (the same is done when multiple authors are available)
Requested reviews:
Tomas Groth (tomasgroth)
Raoul Snyman (raoul-snyman)
For more details, see:
https://code.launchpad.net/~sam92/openlp/multiple-songbooks/+merge/282095
lp:~sam92/openlp/multiple-songbooks (revision 2607)
[SUCCESS] https://ci.openlp.io/job/Branch-01-Pull/1226/
[SUCCESS] https://ci.openlp.io/job/Branch-02-Functional-Tests/1151/
[SUCCESS] https://ci.openlp.io/job/Branch-03-Interface-Tests/1090/
[FAILURE] https://ci.openlp.io/job/Branch-04a-Windows_Functional_Tests/928/
Stopping after failure
--
Your team OpenLP Core is subscribed to branch lp:openlp.
=== added file 'openlp/core/utils/db.py'
--- openlp/core/utils/db.py 1970-01-01 00:00:00 +0000
+++ openlp/core/utils/db.py 2016-01-09 16:14:13 +0000
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+The :mod:`db` module provides helper functions for database related methods.
+"""
+import sqlalchemy
+import logging
+
+from copy import deepcopy
+
+log = logging.getLogger(__name__)
+
+
+def drop_column(op, tablename, columnname):
+ drop_columns(op, tablename, [columnname])
+
+
+def drop_columns(op, tablename, columns):
+ """
+ Column dropping functionality for SQLite, as there is no DROP COLUMN support in SQLite
+
+ From https://github.com/klugjohannes/alembic-sqlite
+ """
+
+ # get the db engine and reflect database tables
+ engine = op.get_bind()
+ meta = sqlalchemy.MetaData(bind=engine)
+ meta.reflect()
+
+ # create a select statement from the old table
+ old_table = meta.tables[tablename]
+ select = sqlalchemy.sql.select([c for c in old_table.c if c.name not in columns])
+
+ # get remaining columns without table attribute attached
+ remaining_columns = [deepcopy(c) for c in old_table.columns if c.name not in columns]
+ for column in remaining_columns:
+ column.table = None
+
+ # create a temporary new table
+ new_tablename = '{0}_new'.format(tablename)
+ op.create_table(new_tablename, *remaining_columns)
+ meta.reflect()
+ new_table = meta.tables[new_tablename]
+
+ # copy data from old table
+ insert = sqlalchemy.sql.insert(new_table).from_select([c.name for c in remaining_columns], select)
+ engine.execute(insert)
+
+ # drop the old table and rename the new table to take the old tables
+ # position
+ op.drop_table(tablename)
+ op.rename_table(new_tablename, tablename)
=== modified file 'openlp/plugins/songs/forms/editsongdialog.py'
--- openlp/plugins/songs/forms/editsongdialog.py 2015-12-31 22:46:06 +0000
+++ openlp/plugins/songs/forms/editsongdialog.py 2016-01-09 16:14:13 +0000
@@ -37,7 +37,7 @@
def setupUi(self, edit_song_dialog):
edit_song_dialog.setObjectName('edit_song_dialog')
edit_song_dialog.setWindowIcon(build_icon(u':/icon/openlp-logo.svg'))
- edit_song_dialog.resize(650, 400)
+ edit_song_dialog.resize(900, 600)
edit_song_dialog.setModal(True)
self.dialog_layout = QtWidgets.QVBoxLayout(edit_song_dialog)
self.dialog_layout.setSpacing(8)
@@ -173,22 +173,33 @@
self.topic_remove_layout.addWidget(self.topic_remove_button)
self.topics_layout.addLayout(self.topic_remove_layout)
self.authors_right_layout.addWidget(self.topics_group_box)
- self.song_book_group_box = QtWidgets.QGroupBox(self.authors_tab)
- self.song_book_group_box.setObjectName('song_book_group_box')
- self.song_book_layout = QtWidgets.QFormLayout(self.song_book_group_box)
- self.song_book_layout.setObjectName('song_book_layout')
- self.song_book_name_label = QtWidgets.QLabel(self.song_book_group_box)
- self.song_book_name_label.setObjectName('song_book_name_label')
- self.song_book_combo_box = create_combo_box(self.song_book_group_box, 'song_book_combo_box')
- self.song_book_name_label.setBuddy(self.song_book_combo_box)
- self.song_book_layout.addRow(self.song_book_name_label, self.song_book_combo_box)
- self.song_book_number_label = QtWidgets.QLabel(self.song_book_group_box)
- self.song_book_number_label.setObjectName('song_book_number_label')
- self.song_book_number_edit = QtWidgets.QLineEdit(self.song_book_group_box)
- self.song_book_number_edit.setObjectName('song_book_number_edit')
- self.song_book_number_label.setBuddy(self.song_book_number_edit)
- self.song_book_layout.addRow(self.song_book_number_label, self.song_book_number_edit)
- self.authors_right_layout.addWidget(self.song_book_group_box)
+ self.songbook_group_box = QtWidgets.QGroupBox(self.authors_tab)
+ self.songbook_group_box.setObjectName('songbook_group_box')
+ self.songbooks_layout = QtWidgets.QVBoxLayout(self.songbook_group_box)
+ self.songbooks_layout.setObjectName('songbooks_layout')
+ self.songbook_add_layout = QtWidgets.QHBoxLayout()
+ self.songbook_add_layout.setObjectName('songbook_add_layout')
+ self.songbooks_combo_box = create_combo_box(self.songbook_group_box, 'songbooks_combo_box')
+ self.songbook_add_layout.addWidget(self.songbooks_combo_box)
+ self.songbook_entry_edit = QtWidgets.QLineEdit(self.songbook_group_box)
+ self.songbook_entry_edit.setMaximumWidth(100)
+ self.songbook_add_layout.addWidget(self.songbook_entry_edit)
+ self.songbook_add_button = QtWidgets.QPushButton(self.songbook_group_box)
+ self.songbook_add_button.setObjectName('songbook_add_button')
+ self.songbook_add_layout.addWidget(self.songbook_add_button)
+ self.songbooks_layout.addLayout(self.songbook_add_layout)
+ self.songbooks_list_view = QtWidgets.QListWidget(self.songbook_group_box)
+ self.songbooks_list_view.setAlternatingRowColors(True)
+ self.songbooks_list_view.setObjectName('songbooks_list_view')
+ self.songbooks_layout.addWidget(self.songbooks_list_view)
+ self.songbook_remove_layout = QtWidgets.QHBoxLayout()
+ self.songbook_remove_layout.setObjectName('songbook_remove_layout')
+ self.songbook_remove_layout.addStretch()
+ self.songbook_remove_button = QtWidgets.QPushButton(self.songbook_group_box)
+ self.songbook_remove_button.setObjectName('songbook_remove_button')
+ self.songbook_remove_layout.addWidget(self.songbook_remove_button)
+ self.songbooks_layout.addLayout(self.songbook_remove_layout)
+ self.authors_right_layout.addWidget(self.songbook_group_box)
self.authors_tab_layout.addLayout(self.authors_right_layout)
self.song_tab_widget.addTab(self.authors_tab, '')
# theme tab
@@ -303,15 +314,15 @@
self.author_add_button.setText(translate('SongsPlugin.EditSongForm', '&Add to Song'))
self.author_edit_button.setText(translate('SongsPlugin.EditSongForm', '&Edit Author Type'))
self.author_remove_button.setText(translate('SongsPlugin.EditSongForm', '&Remove'))
- self.maintenance_button.setText(translate('SongsPlugin.EditSongForm', '&Manage Authors, Topics, Song Books'))
- self.topics_group_box.setTitle(SongStrings.Topic)
+ self.maintenance_button.setText(translate('SongsPlugin.EditSongForm', '&Manage Authors, Topics, Songbooks'))
+ self.topics_group_box.setTitle(SongStrings.Topics)
self.topic_add_button.setText(translate('SongsPlugin.EditSongForm', 'A&dd to Song'))
self.topic_remove_button.setText(translate('SongsPlugin.EditSongForm', 'R&emove'))
- self.song_book_group_box.setTitle(SongStrings.SongBook)
- self.song_book_name_label.setText(translate('SongsPlugin.EditSongForm', 'Book:'))
- self.song_book_number_label.setText(translate('SongsPlugin.EditSongForm', 'Number:'))
+ self.songbook_group_box.setTitle(SongStrings.SongBooks)
+ self.songbook_add_button.setText(translate('SongsPlugin.EditSongForm', 'Add &to Song'))
+ self.songbook_remove_button.setText(translate('SongsPlugin.EditSongForm', 'Re&move'))
self.song_tab_widget.setTabText(self.song_tab_widget.indexOf(self.authors_tab),
- translate('SongsPlugin.EditSongForm', 'Authors, Topics && Song Book'))
+ translate('SongsPlugin.EditSongForm', 'Authors, Topics && Songbooks'))
self.theme_group_box.setTitle(UiStrings().Theme)
self.theme_add_button.setText(translate('SongsPlugin.EditSongForm', 'New &Theme'))
self.rights_group_box.setTitle(translate('SongsPlugin.EditSongForm', 'Copyright Information'))
=== modified file 'openlp/plugins/songs/forms/editsongform.py'
--- openlp/plugins/songs/forms/editsongform.py 2016-01-08 17:28:47 +0000
+++ openlp/plugins/songs/forms/editsongform.py 2016-01-09 16:14:13 +0000
@@ -35,7 +35,7 @@
from openlp.core.lib import FileDialog, PluginStatus, MediaType, create_separated_list
from openlp.core.lib.ui import set_case_insensitive_completer, critical_error_message_box, find_and_set_in_combo_box
from openlp.plugins.songs.lib import VerseType, clean_song
-from openlp.plugins.songs.lib.db import Book, Song, Author, AuthorType, Topic, MediaFile
+from openlp.plugins.songs.lib.db import Book, Song, Author, AuthorType, Topic, MediaFile, SongBookEntry
from openlp.plugins.songs.lib.ui import SongStrings
from openlp.plugins.songs.lib.openlyricsxml import SongXML
from openlp.plugins.songs.forms.editsongdialog import Ui_EditSongDialog
@@ -70,6 +70,9 @@
self.topic_add_button.clicked.connect(self.on_topic_add_button_clicked)
self.topic_remove_button.clicked.connect(self.on_topic_remove_button_clicked)
self.topics_list_view.itemClicked.connect(self.on_topic_list_view_clicked)
+ self.songbook_add_button.clicked.connect(self.on_songbook_add_button_clicked)
+ self.songbook_remove_button.clicked.connect(self.on_songbook_remove_button_clicked)
+ self.songbooks_list_view.itemClicked.connect(self.on_songbook_list_view_clicked)
self.copyright_insert_button.clicked.connect(self.on_copyright_insert_button_triggered)
self.verse_add_button.clicked.connect(self.on_verse_add_button_clicked)
self.verse_list_widget.doubleClicked.connect(self.on_verse_edit_button_clicked)
@@ -126,6 +129,11 @@
author_item.setData(QtCore.Qt.UserRole, (author.id, author_type))
self.authors_list_view.addItem(author_item)
+ def add_songbook_entry_to_list(self, songbook_id, songbook_name, entry):
+ songbook_entry_item = QtWidgets.QListWidgetItem(SongBookEntry.get_display_name(songbook_name, entry))
+ songbook_entry_item.setData(QtCore.Qt.UserRole, (songbook_id, entry))
+ self.songbooks_list_view.addItem(songbook_entry_item)
+
def _extract_verse_order(self, verse_order):
"""
Split out the verse order
@@ -220,17 +228,6 @@
result = self._validate_verse_list(self.verse_order_edit.text(), self.verse_list_widget.rowCount())
if not result:
return False
- text = self.song_book_combo_box.currentText()
- if self.song_book_combo_box.findText(text, QtCore.Qt.MatchExactly) < 0:
- if QtWidgets.QMessageBox.question(
- self, translate('SongsPlugin.EditSongForm', 'Add Book'),
- translate('SongsPlugin.EditSongForm', 'This song book does not exist, do you want to add it?'),
- QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
- QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes:
- book = Book.populate(name=text, publisher='')
- self.manager.save_object(book)
- else:
- return False
# Validate tags (lp#1199639)
misplaced_tags = []
verse_tags = []
@@ -328,6 +325,9 @@
if self.topics_combo_box.hasFocus() and self.topics_combo_box.currentText():
self.on_topic_add_button_clicked()
return
+ if self.songbooks_combo_box.hasFocus() and self.songbooks_combo_box.currentText():
+ self.on_songbook_add_button_clicked()
+ return
QtWidgets.QDialog.keyPressEvent(self, event)
def initialise(self):
@@ -368,12 +368,12 @@
self.topics = []
self._load_objects(Topic, self.topics_combo_box, self.topics)
- def load_books(self):
- """
- Load the song books into the combobox
- """
- self.books = []
- self._load_objects(Book, self.song_book_combo_box, self.books)
+ def load_songbooks(self):
+ """
+ Load the Songbooks into the combobox
+ """
+ self.songbooks = []
+ self._load_objects(Book, self.songbooks_combo_box, self.songbooks)
def load_themes(self, theme_list):
"""
@@ -414,12 +414,13 @@
self.verse_list_widget.setRowCount(0)
self.authors_list_view.clear()
self.topics_list_view.clear()
+ self.songbooks_list_view.clear()
+ self.songbook_entry_edit.clear()
self.audio_list_widget.clear()
self.title_edit.setFocus()
- self.song_book_number_edit.clear()
self.load_authors()
self.load_topics()
- self.load_books()
+ self.load_songbooks()
self.load_media_files()
self.theme_combo_box.setEditText('')
self.theme_combo_box.setCurrentIndex(0)
@@ -438,18 +439,11 @@
self.song_tab_widget.setCurrentIndex(0)
self.load_authors()
self.load_topics()
- self.load_books()
+ self.load_songbooks()
self.load_media_files()
self.song = self.manager.get_object(Song, song_id)
self.title_edit.setText(self.song.title)
- self.alternative_edit.setText(
- self.song.alternate_title if self.song.alternate_title else '')
- if self.song.song_book_id != 0:
- book_name = self.manager.get_object(Book, self.song.song_book_id)
- find_and_set_in_combo_box(self.song_book_combo_box, str(book_name.name))
- else:
- self.song_book_combo_box.setEditText('')
- self.song_book_combo_box.setCurrentIndex(0)
+ self.alternative_edit.setText(self.song.alternate_title if self.song.alternate_title else '')
if self.song.theme_name:
find_and_set_in_combo_box(self.theme_combo_box, str(self.song.theme_name))
else:
@@ -459,7 +453,6 @@
self.copyright_edit.setText(self.song.copyright if self.song.copyright else '')
self.comments_edit.setPlainText(self.song.comments if self.song.comments else '')
self.ccli_number_edit.setText(self.song.ccli_number if self.song.ccli_number else '')
- self.song_book_number_edit.setText(self.song.song_number if self.song.song_number else '')
# lazy xml migration for now
self.verse_list_widget.clear()
self.verse_list_widget.setRowCount(0)
@@ -521,6 +514,10 @@
topic_name = QtWidgets.QListWidgetItem(str(topic.name))
topic_name.setData(QtCore.Qt.UserRole, topic.id)
self.topics_list_view.addItem(topic_name)
+ self.songbooks_list_view.clear()
+ for songbook_entry in self.song.songbook_entries:
+ self.add_songbook_entry_to_list(songbook_entry.songbook.id, songbook_entry.songbook.name,
+ songbook_entry.entry)
self.audio_list_widget.clear()
for media in self.song.media_files:
media_file = QtWidgets.QListWidgetItem(os.path.split(media.file_name)[1])
@@ -679,6 +676,48 @@
row = self.topics_list_view.row(item)
self.topics_list_view.takeItem(row)
+ def on_songbook_add_button_clicked(self):
+ item = int(self.songbooks_combo_box.currentIndex())
+ text = self.songbooks_combo_box.currentText()
+ if item == 0 and text:
+ if QtWidgets.QMessageBox.question(
+ self, translate('SongsPlugin.EditSongForm', 'Add Songbook'),
+ translate('SongsPlugin.EditSongForm', 'This Songbook does not exist, do you want to add it?'),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes:
+ songbook = Book.populate(name=text)
+ self.manager.save_object(songbook)
+ self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text())
+ self.load_songbooks()
+ self.songbooks_combo_box.setCurrentIndex(0)
+ self.songbook_entry_edit.clear()
+ else:
+ return
+ elif item > 0:
+ item_id = (self.songbooks_combo_box.itemData(item))
+ songbook = self.manager.get_object(Book, item_id)
+ if self.songbooks_list_view.findItems(str(songbook.name), QtCore.Qt.MatchExactly):
+ critical_error_message_box(
+ message=translate('SongsPlugin.EditSongForm', 'This Songbook is already in the list.'))
+ else:
+ self.add_songbook_entry_to_list(songbook.id, songbook.name, self.songbook_entry_edit.text())
+ self.songbooks_combo_box.setCurrentIndex(0)
+ self.songbook_entry_edit.clear()
+ else:
+ QtWidgets.QMessageBox.warning(
+ self, UiStrings().NISs,
+ translate('SongsPlugin.EditSongForm', 'You have not selected a valid Songbook. Either select a '
+ 'Songbook from the list, or type in a new Songbook and click the "Add to Song" '
+ 'button to add the new Songbook.'))
+
+ def on_songbook_list_view_clicked(self):
+ self.songbook_remove_button.setEnabled(True)
+
+ def on_songbook_remove_button_clicked(self):
+ self.songbook_remove_button.setEnabled(False)
+ row = self.songbooks_list_view.row(self.songbooks_list_view.currentItem())
+ self.songbooks_list_view.takeItem(row)
+
def on_verse_list_view_clicked(self):
self.verse_edit_button.setEnabled(True)
self.verse_delete_button.setEnabled(True)
@@ -841,17 +880,10 @@
"""
Maintenance button pressed
"""
- temp_song_book = None
- item = int(self.song_book_combo_box.currentIndex())
- text = self.song_book_combo_box.currentText()
- if item == 0 and text:
- temp_song_book = text
self.media_item.song_maintenance_form.exec(True)
self.load_authors()
- self.load_books()
+ self.load_songbooks()
self.load_topics()
- if temp_song_book:
- self.song_book_combo_box.setEditText(temp_song_book)
def on_preview(self, button):
"""
@@ -931,7 +963,7 @@
log.debug('SongEditForm.clearCaches')
self.authors = []
self.themes = []
- self.books = []
+ self.songbooks = []
self.topics = []
def reject(self):
@@ -980,12 +1012,6 @@
order.append('%s%s' % (verse_tag, verse_num))
self.song.verse_order = ' '.join(order)
self.song.ccli_number = self.ccli_number_edit.text()
- self.song.song_number = self.song_book_number_edit.text()
- book_name = self.song_book_combo_box.currentText()
- if book_name:
- self.song.book = self.manager.get_object_filtered(Book, Book.name == book_name)
- else:
- self.song.book = None
theme_name = self.theme_combo_box.currentText()
if theme_name:
self.song.theme_name = theme_name
@@ -1004,6 +1030,13 @@
topic = self.manager.get_object(Topic, topic_id)
if topic is not None:
self.song.topics.append(topic)
+ self.song.songbook_entries = []
+ for row in range(self.songbooks_list_view.count()):
+ item = self.songbooks_list_view.item(row)
+ songbook_id = item.data(QtCore.Qt.UserRole)[0]
+ songbook = self.manager.get_object(Book, songbook_id)
+ entry = item.data(QtCore.Qt.UserRole)[1]
+ self.song.add_songbook_entry(songbook, entry)
# Save the song here because we need a valid id for the audio files.
clean_song(self.manager, self.song)
self.manager.save_object(self.song)
=== modified file 'openlp/plugins/songs/forms/songbookdialog.py'
--- openlp/plugins/songs/forms/songbookdialog.py 2015-12-31 22:46:06 +0000
+++ openlp/plugins/songs/forms/songbookdialog.py 2016-01-09 16:14:13 +0000
@@ -28,7 +28,7 @@
class Ui_SongBookDialog(object):
"""
- The user interface for the song book dialog.
+ The user interface for the Songbook dialog.
"""
def setupUi(self, song_book_dialog):
"""
@@ -63,6 +63,6 @@
"""
Translate the UI on the fly.
"""
- song_book_dialog.setWindowTitle(translate('SongsPlugin.SongBookForm', 'Song Book Maintenance'))
+ song_book_dialog.setWindowTitle(translate('SongsPlugin.SongBookForm', 'Songbook Maintenance'))
self.name_label.setText(translate('SongsPlugin.SongBookForm', '&Name:'))
self.publisher_label.setText(translate('SongsPlugin.SongBookForm', '&Publisher:'))
=== modified file 'openlp/plugins/songs/lib/db.py'
--- openlp/plugins/songs/lib/db.py 2015-12-31 22:46:06 +0000
+++ openlp/plugins/songs/lib/db.py 2016-01-09 16:14:13 +0000
@@ -160,6 +160,36 @@
self.authors_songs.remove(author_song)
return
+ def add_songbook_entry(self, songbook, entry):
+ """
+ Add a Songbook Entry to the song if it not yet exists
+
+ :param songbook_name: Name of the Songbook.
+ :param entry: Entry in the Songbook (usually a number)
+ """
+ for songbook_entry in self.songbook_entries:
+ if songbook_entry.songbook.name == songbook.name and songbook_entry.entry == entry:
+ return
+
+ new_songbook_entry = SongBookEntry()
+ new_songbook_entry.songbook = songbook
+ new_songbook_entry.entry = entry
+ self.songbook_entries.append(new_songbook_entry)
+
+
+class SongBookEntry(BaseModel):
+ """
+ SongBookEntry model
+ """
+ def __repr__(self):
+ return SongBookEntry.get_display_name(self.songbook.name, self.entry)
+
+ @staticmethod
+ def get_display_name(songbook_name, entry):
+ if entry:
+ return "%s #%s" % (songbook_name, entry)
+ return songbook_name
+
class Topic(BaseModel):
"""
@@ -182,6 +212,7 @@
* media_files_songs
* song_books
* songs
+ * songs_songbooks
* songs_topics
* topics
@@ -222,7 +253,6 @@
The *songs* table has the following columns:
* id
- * song_book_id
* title
* alternate_title
* lyrics
@@ -230,11 +260,17 @@
* copyright
* comments
* ccli_number
- * song_number
* theme_name
* search_title
* search_lyrics
+ **songs_songsbooks Table**
+ This is a mapping table between the *songs* and the *song_books* tables. It has the following columns:
+
+ * songbook_id
+ * song_id
+ * entry # The song number, like 120 or 550A
+
**songs_topics Table**
This is a bridging table between the *songs* and *topics* tables, which
serves to create a many-to-many relationship between the two tables. It
@@ -284,7 +320,6 @@
songs_table = Table(
'songs', metadata,
Column('id', types.Integer(), primary_key=True),
- Column('song_book_id', types.Integer(), ForeignKey('song_books.id'), default=None),
Column('title', types.Unicode(255), nullable=False),
Column('alternate_title', types.Unicode(255)),
Column('lyrics', types.UnicodeText, nullable=False),
@@ -292,7 +327,6 @@
Column('copyright', types.Unicode(255)),
Column('comments', types.UnicodeText),
Column('ccli_number', types.Unicode(64)),
- Column('song_number', types.Unicode(64)),
Column('theme_name', types.Unicode(128)),
Column('search_title', types.Unicode(255), index=True, nullable=False),
Column('search_lyrics', types.UnicodeText, nullable=False),
@@ -316,6 +350,14 @@
Column('author_type', types.Unicode(255), primary_key=True, nullable=False, server_default=text('""'))
)
+ # Definition of the "songs_songbooks" table
+ songs_songbooks_table = Table(
+ 'songs_songbooks', metadata,
+ Column('songbook_id', types.Integer(), ForeignKey('song_books.id'), primary_key=True),
+ Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True),
+ Column('entry', types.Unicode(255), primary_key=True, nullable=False)
+ )
+
# Definition of the "songs_topics" table
songs_topics_table = Table(
'songs_topics', metadata,
@@ -329,6 +371,9 @@
mapper(AuthorSong, authors_songs_table, properties={
'author': relation(Author)
})
+ mapper(SongBookEntry, songs_songbooks_table, properties={
+ 'songbook': relation(Book)
+ })
mapper(Book, song_books_table)
mapper(MediaFile, media_files_table)
mapper(Song, songs_table, properties={
@@ -337,8 +382,8 @@
'authors_songs': relation(AuthorSong, cascade="all, delete-orphan"),
# Use lazy='joined' to always load authors when the song is fetched from the database (bug 1366198)
'authors': relation(Author, secondary=authors_songs_table, viewonly=True, lazy='joined'),
- 'book': relation(Book, backref='songs'),
'media_files': relation(MediaFile, backref='songs', order_by=media_files_table.c.weight),
+ 'songbook_entries': relation(SongBookEntry, backref='song', cascade="all, delete-orphan"),
'topics': relation(Topic, backref='songs', secondary=songs_topics_table)
})
mapper(Topic, topics_table)
=== modified file 'openlp/plugins/songs/lib/mediaitem.py'
--- openlp/plugins/songs/lib/mediaitem.py 2016-01-08 17:28:47 +0000
+++ openlp/plugins/songs/lib/mediaitem.py 2016-01-09 16:14:13 +0000
@@ -37,7 +37,7 @@
from openlp.plugins.songs.forms.songimportform import SongImportForm
from openlp.plugins.songs.forms.songexportform import SongExportForm
from openlp.plugins.songs.lib import VerseType, clean_string, delete_song
-from openlp.plugins.songs.lib.db import Author, AuthorType, Song, Book, MediaFile
+from openlp.plugins.songs.lib.db import Author, AuthorType, Song, Book, MediaFile, SongBookEntry
from openlp.plugins.songs.lib.ui import SongStrings
from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics, SongXML
@@ -152,7 +152,7 @@
(SongSearch.Authors, ':/songs/song_search_author.png', SongStrings.Authors,
translate('SongsPlugin.MediaItem', 'Search Authors...')),
(SongSearch.Books, ':/songs/song_book_edit.png', SongStrings.SongBooks,
- translate('SongsPlugin.MediaItem', 'Search Song Books...')),
+ translate('SongsPlugin.MediaItem', 'Search Songbooks...')),
(SongSearch.Themes, ':/slides/slide_theme.png', UiStrings().Themes, UiStrings().SearchThemes)
])
self.search_text_edit.set_current_search_type(Settings().value('%s/last search type' % self.settings_section))
@@ -185,17 +185,8 @@
Author, Author.display_name.like(search_string), Author.display_name.asc())
self.display_results_author(search_results)
elif search_type == SongSearch.Books:
- log.debug('Books Search')
- search_string = '%' + search_keywords + '%'
- search_results = self.plugin.manager.get_all_objects(Book, Book.name.like(search_string), Book.name.asc())
- song_number = False
- if not search_results:
- search_keywords = search_keywords.rpartition(' ')
- search_string = '%' + search_keywords[0] + '%'
- search_results = self.plugin.manager.get_all_objects(Book,
- Book.name.like(search_string), Book.name.asc())
- song_number = re.sub(r'[^0-9]', '', search_keywords[2])
- self.display_results_book(search_results, song_number)
+ log.debug('Songbook Search')
+ self.display_results_book(search_keywords)
elif search_type == SongSearch.Themes:
log.debug('Theme Search')
search_string = '%' + search_keywords + '%'
@@ -255,21 +246,29 @@
song_name.setData(QtCore.Qt.UserRole, song.id)
self.list_view.addItem(song_name)
- def display_results_book(self, search_results, song_number=False):
+ def display_results_book(self, search_keywords):
log.debug('display results Book')
self.list_view.clear()
- for book in search_results:
- songs = sorted(book.songs, key=lambda song: int(re.match(r'[0-9]+', '0' + song.song_number).group()))
- for song in songs:
- # Do not display temporary songs
- if song.temporary:
- continue
- if song_number and song_number not in song.song_number:
- continue
- song_detail = '%s - %s (%s)' % (book.name, song.song_number, song.title)
- song_name = QtWidgets.QListWidgetItem(song_detail)
- song_name.setData(QtCore.Qt.UserRole, song.id)
- self.list_view.addItem(song_name)
+
+ search_keywords = search_keywords.rpartition(' ')
+ search_book = search_keywords[0]
+ search_entry = re.sub(r'[^0-9]', '', search_keywords[2])
+
+ songbook_entries = (self.plugin.manager.session.query(SongBookEntry)
+ .join(Book)
+ .order_by(Book.name)
+ .order_by(SongBookEntry.entry))
+ for songbook_entry in songbook_entries:
+ if songbook_entry.song.temporary:
+ continue
+ if search_book.lower() not in songbook_entry.songbook.name.lower():
+ continue
+ if search_entry not in songbook_entry.entry:
+ continue
+ song_detail = '%s #%s: %s' % (songbook_entry.songbook.name, songbook_entry.entry, songbook_entry.song.title)
+ song_name = QtWidgets.QListWidgetItem(song_detail)
+ song_name.setData(QtCore.Qt.UserRole, songbook_entry.song.id)
+ self.list_view.addItem(song_name)
def on_clear_text_button_click(self):
"""
@@ -524,8 +523,9 @@
item.raw_footer.append("%s %s" % (SongStrings.CopyrightSymbol, song.copyright))
else:
item.raw_footer.append(song.copyright)
- if self.display_songbook and song.book:
- item.raw_footer.append("%s #%s" % (song.book.name, song.song_number))
+ if self.display_songbook and song.songbook_entries:
+ songbooks = [str(songbook_entry) for songbook_entry in song.songbook_entries]
+ item.raw_footer.append(", ".join(songbooks))
if Settings().value('core/ccli number'):
item.raw_footer.append(translate('SongsPlugin.MediaItem',
'CCLI License: ') + Settings().value('core/ccli number'))
=== modified file 'openlp/plugins/songs/lib/openlyricsxml.py'
--- openlp/plugins/songs/lib/openlyricsxml.py 2015-12-31 22:46:06 +0000
+++ openlp/plugins/songs/lib/openlyricsxml.py 2016-01-09 16:14:13 +0000
@@ -266,13 +266,12 @@
element.set('type', AuthorType.Music)
else:
element.set('type', author_song.author_type)
- book = self.manager.get_object_filtered(Book, Book.id == song.song_book_id)
- if book is not None:
- book = book.name
+ if song.songbook_entries:
songbooks = etree.SubElement(properties, 'songbooks')
- element = self._add_text_to_element('songbook', songbooks, None, book)
- if song.song_number:
- element.set('entry', song.song_number)
+ for songbook_entry in song.songbook_entries:
+ element = self._add_text_to_element('songbook', songbooks, None, songbook_entry.songbook.name)
+ if songbook_entry.entry:
+ element.set('entry', songbook_entry.entry)
if song.topics:
themes = etree.SubElement(properties, 'themes')
for topic in song.topics:
@@ -744,8 +743,6 @@
:param properties: The property object (lxml.objectify.ObjectifiedElement).
:param song: The song object.
"""
- song.song_book_id = None
- song.song_number = ''
if hasattr(properties, 'songbooks'):
for songbook in properties.songbooks.songbook:
book_name = songbook.get('name', '')
@@ -755,10 +752,7 @@
# We need to create a book, because it does not exist.
book = Book.populate(name=book_name, publisher='')
self.manager.save_object(book)
- song.song_book_id = book.id
- song.song_number = songbook.get('entry', '')
- # We only support one song book, so take the first one.
- break
+ song.add_songbook_entry(book, songbook.get('entry', ''))
def _process_titles(self, properties, song):
"""
=== modified file 'openlp/plugins/songs/lib/ui.py'
--- openlp/plugins/songs/lib/ui.py 2015-12-31 22:46:06 +0000
+++ openlp/plugins/songs/lib/ui.py 2016-01-09 16:14:13 +0000
@@ -35,8 +35,8 @@
Authors = translate('OpenLP.Ui', 'Authors', 'Plural')
AuthorUnknown = translate('OpenLP.Ui', 'Author Unknown') # Used to populate the database.
CopyrightSymbol = translate('OpenLP.Ui', '\xa9', 'Copyright symbol.')
- SongBook = translate('OpenLP.Ui', 'Song Book', 'Singular')
- SongBooks = translate('OpenLP.Ui', 'Song Books', 'Plural')
+ SongBook = translate('OpenLP.Ui', 'Songbook', 'Singular')
+ SongBooks = translate('OpenLP.Ui', 'Songbooks', 'Plural')
SongIncomplete = translate('OpenLP.Ui', 'Title and/or verses not found')
SongMaintenance = translate('OpenLP.Ui', 'Song Maintenance')
Topic = translate('OpenLP.Ui', 'Topic', 'Singular')
=== modified file 'openlp/plugins/songs/lib/upgrade.py'
--- openlp/plugins/songs/lib/upgrade.py 2015-12-31 22:46:06 +0000
+++ openlp/plugins/songs/lib/upgrade.py 2016-01-09 16:14:13 +0000
@@ -29,10 +29,10 @@
from sqlalchemy.sql.expression import func, false, null, text
from openlp.core.lib.db import get_upgrade_op
-from openlp.core.common import trace_error_handler
+from openlp.core.utils.db import drop_columns
log = logging.getLogger(__name__)
-__version__ = 4
+__version__ = 5
# TODO: When removing an upgrade path the ftw-data needs updating to the minimum supported version
@@ -117,3 +117,34 @@
op.rename_table('authors_songs_tmp', 'authors_songs')
else:
log.warning('Skipping upgrade_4 step of upgrading the song db')
+
+
+def upgrade_5(session, metadata):
+ """
+ Version 5 upgrade.
+
+ This upgrade adds support for multiple songbooks
+ """
+ op = get_upgrade_op(session)
+ songs_table = Table('songs', metadata)
+ if 'song_book_id' in [col.name for col in songs_table.c.values()]:
+ log.warning('Skipping upgrade_5 step of upgrading the song db')
+ return
+
+ # Create the mapping table (songs <-> songbooks)
+ op.create_table('songs_songbooks',
+ Column('songbook_id', types.Integer(), ForeignKey('song_books.id'), primary_key=True),
+ Column('song_id', types.Integer(), ForeignKey('songs.id'), primary_key=True),
+ Column('entry', types.Unicode(255), primary_key=True, nullable=False))
+
+ # Migrate old data
+ op.execute('INSERT INTO songs_songbooks SELECT song_book_id, id, song_number FROM songs\
+ WHERE song_book_id IS NOT NULL AND song_number IS NOT NULL')
+
+ # Drop old columns
+ if metadata.bind.url.get_dialect().name == 'sqlite':
+ drop_columns(op, 'songs', ['song_book_id', 'song_number'])
+ else:
+ op.drop_constraint('songs_ibfk_1', 'songs', 'foreignkey')
+ op.drop_column('songs', 'song_book_id')
+ op.drop_column('songs', 'song_number')
=== added file 'tests/functional/openlp_core_utils/test_db.py'
--- tests/functional/openlp_core_utils/test_db.py 1970-01-01 00:00:00 +0000
+++ tests/functional/openlp_core_utils/test_db.py 2016-01-09 16:14:13 +0000
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers #
+# --------------------------------------------------------------------------- #
+# This program is free software; you can redistribute it and/or modify it #
+# under the terms of the GNU General Public License as published by the Free #
+# Software Foundation; version 2 of the License. #
+# #
+# This program is distributed in the hope that it will be useful, but WITHOUT #
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
+# more details. #
+# #
+# You should have received a copy of the GNU General Public License along #
+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
+###############################################################################
+"""
+Package to test the openlp.core.utils.db package.
+"""
+import os
+import shutil
+import sqlalchemy
+from unittest import TestCase
+from tempfile import mkdtemp
+
+from openlp.core.utils.db import drop_column, drop_columns
+from openlp.core.lib.db import init_db, get_upgrade_op
+from tests.utils.constants import TEST_RESOURCES_PATH
+
+
+class TestUtilsDBFunctions(TestCase):
+
+ def setUp(self):
+ """
+ Create temp folder for keeping db file
+ """
+ self.tmp_folder = mkdtemp()
+
+ def tearDown(self):
+ """
+ Clean up
+ """
+ shutil.rmtree(self.tmp_folder)
+
+ def delete_column_test(self):
+ """
+ Test deleting a single column in a table
+ """
+ # GIVEN: A temporary song db
+ db_path = os.path.join(TEST_RESOURCES_PATH, 'songs', 'songs-1.9.7.sqlite')
+ db_tmp_path = os.path.join(self.tmp_folder, 'songs-1.9.7.sqlite')
+ shutil.copyfile(db_path, db_tmp_path)
+ db_url = 'sqlite:///' + db_tmp_path
+ session, metadata = init_db(db_url)
+ op = get_upgrade_op(session)
+
+ # WHEN: Deleting a columns in a table
+ drop_column(op, 'songs', 'song_book_id')
+
+ # THEN: The column should have been deleted
+ meta = sqlalchemy.MetaData(bind=op.get_bind())
+ meta.reflect()
+ columns = meta.tables['songs'].columns
+
+ for column in columns:
+ if column.name == 'song_book_id':
+ self.fail("The column 'song_book_id' should have been deleted.")
+
+ def delete_columns_test(self):
+ """
+ Test deleting multiple columns in a table
+ """
+ # GIVEN: A temporary song db
+ db_path = os.path.join(TEST_RESOURCES_PATH, 'songs', 'songs-1.9.7.sqlite')
+ db_tmp_path = os.path.join(self.tmp_folder, 'songs-1.9.7.sqlite')
+ shutil.copyfile(db_path, db_tmp_path)
+ db_url = 'sqlite:///' + db_tmp_path
+ session, metadata = init_db(db_url)
+ op = get_upgrade_op(session)
+
+ # WHEN: Deleting a columns in a table
+ drop_columns(op, 'songs', ['song_book_id', 'song_number'])
+
+ # THEN: The columns should have been deleted
+ meta = sqlalchemy.MetaData(bind=op.get_bind())
+ meta.reflect()
+ columns = meta.tables['songs'].columns
+
+ for column in columns:
+ if column.name == 'song_book_id' or column.name == 'song_number':
+ self.fail("The column '%s' should have been deleted." % column.name)
=== modified file 'tests/functional/openlp_plugins/songs/test_db.py'
--- tests/functional/openlp_plugins/songs/test_db.py 2015-12-31 22:46:06 +0000
+++ tests/functional/openlp_plugins/songs/test_db.py 2016-01-09 16:14:13 +0000
@@ -27,7 +27,7 @@
from unittest import TestCase
from tempfile import mkdtemp
-from openlp.plugins.songs.lib.db import Song, Author, AuthorType
+from openlp.plugins.songs.lib.db import Song, Author, AuthorType, Book
from openlp.plugins.songs.lib import upgrade
from openlp.core.lib.db import upgrade_db
from tests.utils.constants import TEST_RESOURCES_PATH
@@ -179,6 +179,23 @@
# THEN: It should return the name with the type in brackets
self.assertEqual("John Doe (Translation)", display_name)
+ def test_add_songbooks(self):
+ """
+ Test that adding songbooks to a song works correctly
+ """
+ # GIVEN: A mocked song and songbook
+ song = Song()
+ song.songbook_entries = []
+ songbook = Book()
+ songbook.name = "Thy Word"
+
+ # WHEN: We add two songbooks to a Song
+ song.add_songbook_entry(songbook, "120")
+ song.add_songbook_entry(songbook, "550A")
+
+ # THEN: The song should have two songbook entries
+ self.assertEqual(len(song.songbook_entries), 2, 'There should be two Songbook entries.')
+
def test_upgrade_old_song_db(self):
"""
Test that we can upgrade an old song db to the current schema
@@ -192,7 +209,7 @@
# WHEN: upgrading the db
updated_to_version, latest_version = upgrade_db(db_url, upgrade)
- # Then the song db should have been upgraded to the latest version
+ # THEN: the song db should have been upgraded to the latest version
self.assertEqual(updated_to_version, latest_version,
'The song DB should have been upgrade to the latest version')
@@ -209,6 +226,6 @@
# WHEN: upgrading the db
updated_to_version, latest_version = upgrade_db(db_url, upgrade)
- # Then the song db should have been upgraded to the latest version without errors
+ # THEN: the song db should have been upgraded to the latest version without errors
self.assertEqual(updated_to_version, latest_version,
'The song DB should have been upgrade to the latest version')
=== modified file 'tests/functional/openlp_plugins/songs/test_mediaitem.py'
--- tests/functional/openlp_plugins/songs/test_mediaitem.py 2016-01-05 19:32:12 +0000
+++ tests/functional/openlp_plugins/songs/test_mediaitem.py 2016-01-09 16:14:13 +0000
@@ -29,7 +29,7 @@
from openlp.core.common import Registry, Settings
from openlp.core.lib import ServiceItem
from openlp.plugins.songs.lib.mediaitem import SongMediaItem
-from openlp.plugins.songs.lib.db import AuthorType
+from openlp.plugins.songs.lib.db import AuthorType, Song
from tests.functional import patch, MagicMock
from tests.helpers.testmixin import TestMixin
@@ -152,29 +152,36 @@
def build_song_footer_base_songbook_test(self):
"""
- Test build songs footer with basic song and a songbook
+ Test build songs footer with basic song and multiple songbooks
"""
# GIVEN: A Song and a Service Item
- mock_song = MagicMock()
- mock_song.title = 'My Song'
- mock_song.copyright = 'My copyright'
- mock_song.book = MagicMock()
- mock_song.book.name = "My songbook"
- mock_song.song_number = 12
+ song = Song()
+ song.title = 'My Song'
+ song.copyright = 'My copyright'
+ song.authors_songs = []
+ song.songbook_entries = []
+ song.ccli_number = ''
+ book1 = MagicMock()
+ book1.name = "My songbook"
+ book2 = MagicMock()
+ book2.name = "Thy songbook"
+ song.songbookentries = []
+ song.add_songbook_entry(book1, '12')
+ song.add_songbook_entry(book2, '502A')
service_item = ServiceItem(None)
# WHEN: I generate the Footer with default settings
- self.media_item.generate_footer(service_item, mock_song)
+ self.media_item.generate_footer(service_item, song)
# THEN: The songbook should not be in the footer
self.assertEqual(service_item.raw_footer, ['My Song', 'My copyright'])
# WHEN: I activate the "display songbook" option
self.media_item.display_songbook = True
- self.media_item.generate_footer(service_item, mock_song)
+ self.media_item.generate_footer(service_item, song)
# THEN: The songbook should be in the footer
- self.assertEqual(service_item.raw_footer, ['My Song', 'My copyright', 'My songbook #12'])
+ self.assertEqual(service_item.raw_footer, ['My Song', 'My copyright', 'My songbook #12, Thy songbook #502A'])
def build_song_footer_copyright_enabled_test(self):
"""
Follow ups