Fix for bug #1000729. Adds topic, copyright, CCLI number searching, including natural sort for CCLI number search, based on the existing 'Author' search.

bzr-revno: 2626
Fixes: https://launchpad.net/bugs/1000729
This commit is contained in:
chris@minkus.me.uk 2016-03-05 20:48:27 +00:00 committed by Tim Bentley
commit 3f7cb577b4
6 changed files with 288 additions and 6 deletions

View File

@ -26,7 +26,7 @@ import os
import shutil import shutil
from PyQt5 import QtCore, QtWidgets from PyQt5 import QtCore, QtWidgets
from sqlalchemy.sql import or_ from sqlalchemy.sql import and_, or_
from openlp.core.common import Registry, AppLocation, Settings, check_directory_exists, UiStrings, translate from openlp.core.common import Registry, AppLocation, Settings, check_directory_exists, UiStrings, translate
from openlp.core.lib import MediaManagerItem, ItemCapabilities, PluginStatus, ServiceItemContext, \ from openlp.core.lib import MediaManagerItem, ItemCapabilities, PluginStatus, ServiceItemContext, \
@ -37,7 +37,7 @@ from openlp.plugins.songs.forms.songmaintenanceform import SongMaintenanceForm
from openlp.plugins.songs.forms.songimportform import SongImportForm from openlp.plugins.songs.forms.songimportform import SongImportForm
from openlp.plugins.songs.forms.songexportform import SongExportForm from openlp.plugins.songs.forms.songexportform import SongExportForm
from openlp.plugins.songs.lib import VerseType, clean_string, delete_song from openlp.plugins.songs.lib import VerseType, clean_string, delete_song
from openlp.plugins.songs.lib.db import Author, AuthorType, Song, Book, MediaFile, SongBookEntry from openlp.plugins.songs.lib.db import Author, AuthorType, Song, Book, MediaFile, SongBookEntry, Topic
from openlp.plugins.songs.lib.ui import SongStrings from openlp.plugins.songs.lib.ui import SongStrings
from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics, SongXML from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics, SongXML
@ -52,8 +52,11 @@ class SongSearch(object):
Titles = 2 Titles = 2
Lyrics = 3 Lyrics = 3
Authors = 4 Authors = 4
Books = 5 Topics = 5
Themes = 6 Books = 6
Themes = 7
Copyright = 8
CCLInumber = 9
class SongMediaItem(MediaManagerItem): class SongMediaItem(MediaManagerItem):
@ -151,9 +154,17 @@ class SongMediaItem(MediaManagerItem):
translate('SongsPlugin.MediaItem', 'Search Lyrics...')), translate('SongsPlugin.MediaItem', 'Search Lyrics...')),
(SongSearch.Authors, ':/songs/song_search_author.png', SongStrings.Authors, (SongSearch.Authors, ':/songs/song_search_author.png', SongStrings.Authors,
translate('SongsPlugin.MediaItem', 'Search Authors...')), translate('SongsPlugin.MediaItem', 'Search Authors...')),
(SongSearch.Topics, ':/songs/song_search_topic.png', SongStrings.Topics,
translate('SongsPlugin.MediaItem', 'Search Topics...')),
(SongSearch.Books, ':/songs/song_book_edit.png', SongStrings.SongBooks, (SongSearch.Books, ':/songs/song_book_edit.png', SongStrings.SongBooks,
translate('SongsPlugin.MediaItem', 'Search Songbooks...')), translate('SongsPlugin.MediaItem', 'Search Songbooks...')),
(SongSearch.Themes, ':/slides/slide_theme.png', UiStrings().Themes, UiStrings().SearchThemes) (SongSearch.Themes, ':/slides/slide_theme.png', UiStrings().Themes, UiStrings().SearchThemes),
(SongSearch.Copyright, ':/songs/song_search_copy.png',
translate('SongsPlugin.MediaItem', 'Copyright'),
translate('SongsPlugin.MediaItem', 'Search Copyright...')),
(SongSearch.CCLInumber, ':/songs/song_search_ccli.png',
translate('SongsPlugin.MediaItem', 'CCLI number'),
translate('SongsPlugin.MediaItem', 'Search CCLI number...'))
]) ])
self.search_text_edit.set_current_search_type(Settings().value('%s/last search type' % self.settings_section)) self.search_text_edit.set_current_search_type(Settings().value('%s/last search type' % self.settings_section))
self.config_update() self.config_update()
@ -184,14 +195,33 @@ class SongMediaItem(MediaManagerItem):
search_results = self.plugin.manager.get_all_objects( search_results = self.plugin.manager.get_all_objects(
Author, Author.display_name.like(search_string), Author.display_name.asc()) Author, Author.display_name.like(search_string), Author.display_name.asc())
self.display_results_author(search_results) self.display_results_author(search_results)
elif search_type == SongSearch.Topics:
log.debug('Topics Search')
search_string = '%' + search_keywords + '%'
search_results = self.plugin.manager.get_all_objects(
Topic, Topic.name.like(search_string), Topic.name.asc())
self.display_results_topic(search_results)
elif search_type == SongSearch.Books: elif search_type == SongSearch.Books:
log.debug('Songbook Search') log.debug('Songbook Search')
self.display_results_book(search_keywords) self.display_results_book(search_keywords)
elif search_type == SongSearch.Themes: elif search_type == SongSearch.Themes:
log.debug('Theme Search') log.debug('Theme Search')
search_string = '%' + search_keywords + '%' search_string = '%' + search_keywords + '%'
search_results = self.plugin.manager.get_all_objects(Song, Song.theme_name.like(search_string)) search_results = self.plugin.manager.get_all_objects(
Song, Song.theme_name.like(search_string), Song.theme_name.asc())
self.display_results_themes(search_results)
elif search_type == SongSearch.Copyright:
log.debug('Copyright Search')
search_string = '%' + search_keywords + '%'
search_results = self.plugin.manager.get_all_objects(
Song, and_(Song.copyright.like(search_string), Song.copyright != ''))
self.display_results_song(search_results) self.display_results_song(search_results)
elif search_type == SongSearch.CCLInumber:
log.debug('CCLI number Search')
search_string = '%' + search_keywords + '%'
search_results = self.plugin.manager.get_all_objects(
Song, and_(Song.ccli_number.like(search_string), Song.ccli_number != ''))
self.display_results_cclinumber(search_results)
self.check_search_result() self.check_search_result()
def search_entire(self, search_keywords): def search_entire(self, search_keywords):
@ -215,6 +245,12 @@ class SongMediaItem(MediaManagerItem):
log.debug('on_song_list_load - finished') log.debug('on_song_list_load - finished')
def display_results_song(self, search_results): def display_results_song(self, search_results):
"""
Display the song search results in the media manager list
:param search_results: A list of db Song objects
:return: None
"""
log.debug('display results Song') log.debug('display results Song')
self.save_auto_select_id() self.save_auto_select_id()
self.list_view.clear() self.list_view.clear()
@ -234,6 +270,12 @@ class SongMediaItem(MediaManagerItem):
self.auto_select_id = -1 self.auto_select_id = -1
def display_results_author(self, search_results): def display_results_author(self, search_results):
"""
Display the song search results in the media manager list, grouped by author
:param search_results: A list of db Author objects
:return: None
"""
log.debug('display results Author') log.debug('display results Author')
self.list_view.clear() self.list_view.clear()
for author in search_results: for author in search_results:
@ -247,6 +289,13 @@ class SongMediaItem(MediaManagerItem):
self.list_view.addItem(song_name) self.list_view.addItem(song_name)
def display_results_book(self, search_keywords): def display_results_book(self, search_keywords):
"""
Display the song search results in the media manager list, grouped by book
:param search_keywords: A list of search keywords - book first, then number
:return: None
"""
log.debug('display results Book') log.debug('display results Book')
self.list_view.clear() self.list_view.clear()
@ -270,6 +319,64 @@ class SongMediaItem(MediaManagerItem):
song_name.setData(QtCore.Qt.UserRole, songbook_entry.song.id) song_name.setData(QtCore.Qt.UserRole, songbook_entry.song.id)
self.list_view.addItem(song_name) self.list_view.addItem(song_name)
def display_results_topic(self, search_results):
"""
Display the song search results in the media manager list, grouped by topic
:param search_results: A list of db Topic objects
:return: None
"""
log.debug('display results Topic')
self.list_view.clear()
search_results = sorted(search_results, key=lambda topic: self._natural_sort_key(topic.name))
for topic in search_results:
songs = sorted(topic.songs, key=lambda song: song.sort_key)
for song in songs:
# Do not display temporary songs
if song.temporary:
continue
song_detail = '%s (%s)' % (topic.name, song.title)
song_name = QtWidgets.QListWidgetItem(song_detail)
song_name.setData(QtCore.Qt.UserRole, song.id)
self.list_view.addItem(song_name)
def display_results_themes(self, search_results):
"""
Display the song search results in the media manager list, sorted by theme
:param search_results: A list of db Song objects
:return: None
"""
log.debug('display results Themes')
self.list_view.clear()
for song in search_results:
# Do not display temporary songs
if song.temporary:
continue
song_detail = '%s (%s)' % (song.theme_name, song.title)
song_name = QtWidgets.QListWidgetItem(song_detail)
song_name.setData(QtCore.Qt.UserRole, song.id)
self.list_view.addItem(song_name)
def display_results_cclinumber(self, search_results):
"""
Display the song search results in the media manager list, sorted by CCLI number
:param search_results: A list of db Song objects
:return: None
"""
log.debug('display results CCLI number')
self.list_view.clear()
songs = sorted(search_results, key=lambda song: self._natural_sort_key(song.ccli_number))
for song in songs:
# Do not display temporary songs
if song.temporary:
continue
song_detail = '%s (%s)' % (song.ccli_number, song.title)
song_name = QtWidgets.QListWidgetItem(song_detail)
song_name.setData(QtCore.Qt.UserRole, song.id)
self.list_view.addItem(song_name)
def on_clear_text_button_click(self): def on_clear_text_button_click(self):
""" """
Clear the search text. Clear the search text.
@ -587,6 +694,14 @@ class SongMediaItem(MediaManagerItem):
# List must be empty at the end # List must be empty at the end
return not author_list return not author_list
def _natural_sort_key(self, s):
"""
Return a tuple by which s is sorted.
:param s: A string value from the list we want to sort.
"""
return [int(text) if text.isdecimal() else text.lower()
for text in re.split('(\d+)', s)]
def search(self, string, show_error): def search(self, string, show_error):
""" """
Search for some songs Search for some songs

View File

@ -3,8 +3,11 @@
<file>song_search_stop.png</file> <file>song_search_stop.png</file>
<file>song_search_all.png</file> <file>song_search_all.png</file>
<file>song_search_author.png</file> <file>song_search_author.png</file>
<file>song_search_ccli.png</file>
<file>song_search_copy.png</file>
<file>song_search_lyrics.png</file> <file>song_search_lyrics.png</file>
<file>song_search_title.png</file> <file>song_search_title.png</file>
<file>song_search_topic.png</file>
<file>topic_edit.png</file> <file>topic_edit.png</file>
<file>author_add.png</file> <file>author_add.png</file>
<file>author_delete.png</file> <file>author_delete.png</file>

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

View File

@ -48,6 +48,12 @@ class TestMediaItem(TestCase, TestMixin):
with patch('openlp.core.lib.mediamanageritem.MediaManagerItem._setup'), \ with patch('openlp.core.lib.mediamanageritem.MediaManagerItem._setup'), \
patch('openlp.plugins.songs.forms.editsongform.EditSongForm.__init__'): patch('openlp.plugins.songs.forms.editsongform.EditSongForm.__init__'):
self.media_item = SongMediaItem(None, MagicMock()) self.media_item = SongMediaItem(None, MagicMock())
self.media_item.save_auto_select_id = MagicMock()
self.media_item.list_view = MagicMock()
self.media_item.list_view.save_auto_select_id = MagicMock()
self.media_item.list_view.clear = MagicMock()
self.media_item.list_view.addItem = MagicMock()
self.media_item.auto_select_id = -1
self.media_item.display_songbook = False self.media_item.display_songbook = False
self.media_item.display_copyright_symbol = False self.media_item.display_copyright_symbol = False
self.setup_application() self.setup_application()
@ -60,6 +66,151 @@ class TestMediaItem(TestCase, TestMixin):
""" """
self.destroy_settings() self.destroy_settings()
def display_results_song_test(self):
"""
Test displaying song search results with basic song
"""
# GIVEN: Search results, plus a mocked QtListWidgetItem
with patch('openlp.core.lib.QtWidgets.QListWidgetItem') as MockedQListWidgetItem, \
patch('openlp.core.lib.QtCore.Qt.UserRole') as MockedUserRole:
mock_search_results = []
mock_song = MagicMock()
mock_song.id = 1
mock_song.title = 'My Song'
mock_song.sort_key = 'My Song'
mock_song.authors = []
mock_author = MagicMock()
mock_author.display_name = 'My Author'
mock_song.authors.append(mock_author)
mock_song.temporary = False
mock_search_results.append(mock_song)
mock_qlist_widget = MagicMock()
MockedQListWidgetItem.return_value = mock_qlist_widget
# WHEN: I display song search results
self.media_item.display_results_song(mock_search_results)
# THEN: The current list view is cleared, the widget is created, and the relevant attributes set
self.media_item.list_view.clear.assert_called_with()
self.media_item.save_auto_select_id.assert_called_with()
MockedQListWidgetItem.assert_called_with('My Song (My Author)')
mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_song.id)
self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
def display_results_author_test(self):
"""
Test displaying song search results grouped by author with basic song
"""
# GIVEN: Search results grouped by author, plus a mocked QtListWidgetItem
with patch('openlp.core.lib.QtWidgets.QListWidgetItem') as MockedQListWidgetItem, \
patch('openlp.core.lib.QtCore.Qt.UserRole') as MockedUserRole:
mock_search_results = []
mock_author = MagicMock()
mock_song = MagicMock()
mock_author.display_name = 'My Author'
mock_author.songs = []
mock_song.id = 1
mock_song.title = 'My Song'
mock_song.sort_key = 'My Song'
mock_song.temporary = False
mock_author.songs.append(mock_song)
mock_search_results.append(mock_author)
mock_qlist_widget = MagicMock()
MockedQListWidgetItem.return_value = mock_qlist_widget
# WHEN: I display song search results grouped by author
self.media_item.display_results_author(mock_search_results)
# THEN: The current list view is cleared, the widget is created, and the relevant attributes set
self.media_item.list_view.clear.assert_called_with()
MockedQListWidgetItem.assert_called_with('My Author (My Song)')
mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_song.id)
self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
def display_results_topic_test(self):
"""
Test displaying song search results grouped by topic with basic song
"""
# GIVEN: Search results grouped by topic, plus a mocked QtListWidgetItem
with patch('openlp.core.lib.QtWidgets.QListWidgetItem') as MockedQListWidgetItem, \
patch('openlp.core.lib.QtCore.Qt.UserRole') as MockedUserRole:
mock_search_results = []
mock_topic = MagicMock()
mock_song = MagicMock()
mock_topic.name = 'My Topic'
mock_topic.songs = []
mock_song.id = 1
mock_song.title = 'My Song'
mock_song.sort_key = 'My Song'
mock_song.temporary = False
mock_topic.songs.append(mock_song)
mock_search_results.append(mock_topic)
mock_qlist_widget = MagicMock()
MockedQListWidgetItem.return_value = mock_qlist_widget
# WHEN: I display song search results grouped by topic
self.media_item.display_results_topic(mock_search_results)
# THEN: The current list view is cleared, the widget is created, and the relevant attributes set
self.media_item.list_view.clear.assert_called_with()
MockedQListWidgetItem.assert_called_with('My Topic (My Song)')
mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_song.id)
self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
def display_results_themes_test(self):
"""
Test displaying song search results sorted by theme with basic song
"""
# GIVEN: Search results sorted by theme, plus a mocked QtListWidgetItem
with patch('openlp.core.lib.QtWidgets.QListWidgetItem') as MockedQListWidgetItem, \
patch('openlp.core.lib.QtCore.Qt.UserRole') as MockedUserRole:
mock_search_results = []
mock_song = MagicMock()
mock_song.id = 1
mock_song.title = 'My Song'
mock_song.sort_key = 'My Song'
mock_song.theme_name = 'My Theme'
mock_song.temporary = False
mock_search_results.append(mock_song)
mock_qlist_widget = MagicMock()
MockedQListWidgetItem.return_value = mock_qlist_widget
# WHEN: I display song search results sorted by theme
self.media_item.display_results_themes(mock_search_results)
# THEN: The current list view is cleared, the widget is created, and the relevant attributes set
self.media_item.list_view.clear.assert_called_with()
MockedQListWidgetItem.assert_called_with('My Theme (My Song)')
mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_song.id)
self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
def display_results_cclinumber_test(self):
"""
Test displaying song search results sorted by CCLI number with basic song
"""
# GIVEN: Search results sorted by CCLI number, plus a mocked QtListWidgetItem
with patch('openlp.core.lib.QtWidgets.QListWidgetItem') as MockedQListWidgetItem, \
patch('openlp.core.lib.QtCore.Qt.UserRole') as MockedUserRole:
mock_search_results = []
mock_song = MagicMock()
mock_song.id = 1
mock_song.title = 'My Song'
mock_song.sort_key = 'My Song'
mock_song.ccli_number = '12345'
mock_song.temporary = False
mock_search_results.append(mock_song)
mock_qlist_widget = MagicMock()
MockedQListWidgetItem.return_value = mock_qlist_widget
# WHEN: I display song search results sorted by CCLI number
self.media_item.display_results_cclinumber(mock_search_results)
# THEN: The current list view is cleared, the widget is created, and the relevant attributes set
self.media_item.list_view.clear.assert_called_with()
MockedQListWidgetItem.assert_called_with('12345 (My Song)')
mock_qlist_widget.setData.assert_called_with(MockedUserRole, mock_song.id)
self.media_item.list_view.addItem.assert_called_with(mock_qlist_widget)
def build_song_footer_one_author_test(self): def build_song_footer_one_author_test(self):
""" """
Test build songs footer with basic song and one author Test build songs footer with basic song and one author
@ -265,6 +416,19 @@ class TestMediaItem(TestCase, TestMixin):
# THEN: They should not match # THEN: They should not match
self.assertFalse(result, "Authors should not match") self.assertFalse(result, "Authors should not match")
def natural_sort_key_test(self):
"""
Test the _natural_sort_key function
"""
# GIVEN: A string to be converted into a sort key
string_sort_key = 'A1B12C'
# WHEN: We attempt to create a sort key
sort_key_result = self.media_item._natural_sort_key(string_sort_key)
# THEN: We should get back a tuple split on integers
self.assertEqual(sort_key_result, ['a', 1, 'b', 12, 'c'])
def build_remote_search_test(self): def build_remote_search_test(self):
""" """
Test results for the remote search api Test results for the remote search api