Merge lp:~trb143/openlp/reporting into lp:openlp

Proposed by Tim Bentley
Status: Merged
Merged at revision: 2700
Proposed branch: lp:~trb143/openlp/reporting
Merge into: lp:openlp
Diff against target: 402 lines (+218/-28)
6 files modified
openlp/core/common/settings.py (+1/-0)
openlp/plugins/songs/lib/openlyricsxml.py (+1/-1)
openlp/plugins/songs/lib/songcompare.py (+3/-3)
openlp/plugins/songs/reporting.py (+106/-0)
openlp/plugins/songs/songsplugin.py (+27/-8)
tests/functional/openlp_core_ui/test_servicemanager.py (+80/-16)
To merge this branch: bzr merge lp:~trb143/openlp/reporting
Reviewer Review Type Date Requested Status
Raoul Snyman Approve
Review via email: mp+309375@code.launchpad.net

This proposal supersedes a proposal from 2016-10-25.

Description of the change

My dad needed a report of all the songs on their database, they had 1800.
Made this into a reporting option and cleaned up the menu.
Fixed some errors spotted as well

Fixed issues and comments
1800 songs takes about 3 secs to run on my i7

lp:~trb143/openlp/reporting (revision 2701)
[SUCCESS] https://ci.openlp.io/job/Branch-01-Pull/1801/
[SUCCESS] https://ci.openlp.io/job/Branch-02-Functional-Tests/1712/
[SUCCESS] https://ci.openlp.io/job/Branch-03-Interface-Tests/1650/
[SUCCESS] https://ci.openlp.io/job/Branch-04a-Windows_Functional_Tests/1406/
[SUCCESS] https://ci.openlp.io/job/Branch-04b-Windows_Interface_Tests/996/
[SUCCESS] https://ci.openlp.io/job/Branch-05a-Code_Analysis/1064/
[SUCCESS] https://ci.openlp.io/job/Branch-05b-Test_Coverage/932/
[SUCCESS] https://ci.openlp.io/job/Branch-05c-Code_Analysis2/93/

To post a comment you must log in.
Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

1. There's a "csv" module, use it.
2. I should be able to specify the file name, not just where to save it to.
3. Have you tested this with > 1000 songs? How long does it take? Some sort of progress window necessary?
4. You call it a report internally, but you're not very specific for the user. Rather call it a "Song List Report".

More comments inline.

review: Needs Fixing
Revision history for this message
Phill (phill-ridout) wrote : Posted in a previous version of this proposal

See my inline comments about your test doc stings and comments.

And also see my inline comments with regard to the Unicode literals. They're not required, and seem only to be implemented to ease porting from py2! https://www.python.org/dev/peps/pep-0414/#proposal

Are there any tests for the new module/method you've addded:
reporting.py
on_tools_report_song_list_triggered

Revision history for this message
Phill (phill-ridout) wrote : Posted in a previous version of this proposal

Still missing tests for your new module. Guess its up to superfly if he's happy with that.

Also, just a few inline comments

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

Couple extra things:

1. The save dialog says "output folder" or something, just make it say "Save file"
2. There's no list of file extensions in the dialog, and no default file name and/or extension.

review: Needs Fixing
Revision history for this message
Raoul Snyman (raoul-snyman) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'openlp/core/common/settings.py'
2--- openlp/core/common/settings.py 2016-09-02 16:19:28 +0000
3+++ openlp/core/common/settings.py 2016-10-26 18:03:31 +0000
4@@ -381,6 +381,7 @@
5 'shortcuts/themeScreen': [QtGui.QKeySequence(QtCore.Qt.Key_T)],
6 'shortcuts/toolsReindexItem': [],
7 'shortcuts/toolsFindDuplicates': [],
8+ 'shortcuts/toolsSongListReport': [],
9 'shortcuts/toolsAlertItem': [QtGui.QKeySequence(QtCore.Qt.Key_F7)],
10 'shortcuts/toolsFirstTimeWizard': [],
11 'shortcuts/toolsOpenDataFolder': [],
12
13=== modified file 'openlp/plugins/songs/lib/openlyricsxml.py'
14--- openlp/plugins/songs/lib/openlyricsxml.py 2016-08-05 19:57:25 +0000
15+++ openlp/plugins/songs/lib/openlyricsxml.py 2016-10-26 18:03:31 +0000
16@@ -458,7 +458,7 @@
17 self._add_tag_to_formatting(tag, tags_element)
18 # Replace end tags.
19 for tag in end_tags:
20- text = text.replace('{{{tag}}}'.format(tag=tag), '</tag>')
21+ text = text.replace('{{/{tag}}}'.format(tag=tag), '</tag>')
22 # Replace \n with <br/>.
23 text = text.replace('\n', '<br/>')
24 element = etree.XML('<lines>{text}</lines>'.format(text=text))
25
26=== modified file 'openlp/plugins/songs/lib/songcompare.py'
27--- openlp/plugins/songs/lib/songcompare.py 2015-12-31 22:46:06 +0000
28+++ openlp/plugins/songs/lib/songcompare.py 2016-10-26 18:03:31 +0000
29@@ -46,13 +46,13 @@
30 MAX_TYPO_SIZE = 3
31
32
33-def songs_probably_equal(song_tupel):
34+def songs_probably_equal(song_tuple):
35 """
36 Calculate and return whether two songs are probably equal.
37
38- :param song_tupel: A tuple of two songs to compare.
39+ :param song_tuple: A tuple of two songs to compare.
40 """
41- song1, song2 = song_tupel
42+ song1, song2 = song_tuple
43 pos1, lyrics1 = song1
44 pos2, lyrics2 = song2
45 if len(lyrics1) < len(lyrics2):
46
47=== added file 'openlp/plugins/songs/reporting.py'
48--- openlp/plugins/songs/reporting.py 1970-01-01 00:00:00 +0000
49+++ openlp/plugins/songs/reporting.py 2016-10-26 18:03:31 +0000
50@@ -0,0 +1,106 @@
51+# -*- coding: utf-8 -*-
52+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
53+
54+###############################################################################
55+# OpenLP - Open Source Lyrics Projection #
56+# --------------------------------------------------------------------------- #
57+# Copyright (c) 2008-2016 OpenLP Developers #
58+# --------------------------------------------------------------------------- #
59+# This program is free software; you can redistribute it and/or modify it #
60+# under the terms of the GNU General Public License as published by the Free #
61+# Software Foundation; version 2 of the License. #
62+# #
63+# This program is distributed in the hope that it will be useful, but WITHOUT #
64+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
65+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
66+# more details. #
67+# #
68+# You should have received a copy of the GNU General Public License along #
69+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
70+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
71+###############################################################################
72+"""
73+The :mod:`db` module provides the ability to provide a csv file of all songs
74+"""
75+import csv
76+import logging
77+
78+from PyQt5 import QtWidgets
79+
80+from openlp.core.common import Registry, translate
81+from openlp.core.lib.ui import critical_error_message_box
82+from openlp.plugins.songs.lib.db import Song
83+
84+
85+log = logging.getLogger(__name__)
86+
87+
88+def report_song_list():
89+ """
90+ Export the song list as a CSV file.
91+ :return: Nothing
92+ """
93+ main_window = Registry().get('main_window')
94+ plugin = Registry().get('songs').plugin
95+ report_file_name, filter_used = QtWidgets.QFileDialog.getSaveFileName(
96+ main_window,
97+ translate('SongPlugin.ReportSongList', 'Save File'),
98+ translate('SongPlugin.ReportSongList', 'song_extract.csv'),
99+ translate('SongPlugin.ReportSongList', 'CSV format (*.csv)'))
100+
101+ if not report_file_name:
102+ main_window.error_message(
103+ translate('SongPlugin.ReportSongList', 'Output Path Not Selected'),
104+ translate('SongPlugin.ReportSongList', 'You have not set a valid output location for your '
105+ 'report. \nPlease select an existing path '
106+ 'on your computer.')
107+ )
108+ return
109+ if not report_file_name.endswith('csv'):
110+ report_file_name += '.csv'
111+ file_handle = None
112+ Registry().get('application').set_busy_cursor()
113+ try:
114+ file_handle = open(report_file_name, 'wt')
115+ fieldnames = ('Title', 'Alternative Title', 'Copyright', 'Author(s)', 'Song Book', 'Topic')
116+ writer = csv.DictWriter(file_handle, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
117+ headers = dict((n, n) for n in fieldnames)
118+ writer.writerow(headers)
119+ song_list = plugin.manager.get_all_objects(Song)
120+ for song in song_list:
121+ author_list = []
122+ for author_song in song.authors_songs:
123+ author_list.append(author_song.author.display_name)
124+ author_string = ' | '.join(author_list)
125+ book_list = []
126+ for book_song in song.songbook_entries:
127+ if hasattr(book_song, 'entry') and book_song.entry:
128+ book_list.append('{name} #{entry}'.format(name=book_song.songbook.name, entry=book_song.entry))
129+ book_string = ' | '.join(book_list)
130+ topic_list = []
131+ for topic_song in song.topics:
132+ if hasattr(topic_song, 'name'):
133+ topic_list.append(topic_song.name)
134+ topic_string = ' | '.join(topic_list)
135+ writer.writerow({'Title': song.title,
136+ 'Alternative Title': song.alternate_title,
137+ 'Copyright': song.copyright,
138+ 'Author(s)': author_string,
139+ 'Song Book': book_string,
140+ 'Topic': topic_string})
141+ Registry().get('application').set_normal_cursor()
142+ main_window.information_message(
143+ translate('SongPlugin.ReportSongList', 'Report Creation'),
144+ translate('SongPlugin.ReportSongList',
145+ 'Report \n{name} \nhas been successfully created. ').format(name=report_file_name)
146+ )
147+ except OSError as ose:
148+ Registry().get('application').set_normal_cursor()
149+ log.exception('Failed to write out song usage records')
150+ critical_error_message_box(translate('SongPlugin.ReportSongList', 'Song Extraction Failed'),
151+ translate('SongPlugin.ReportSongList',
152+ 'An error occurred while extracting: {error}'
153+ ).format(error=ose.strerror))
154+ finally:
155+ if file_handle:
156+ file_handle.close()
157
158=== modified file 'openlp/plugins/songs/songsplugin.py'
159--- openlp/plugins/songs/songsplugin.py 2016-03-31 16:34:22 +0000
160+++ openlp/plugins/songs/songsplugin.py 2016-10-26 18:03:31 +0000
161@@ -36,6 +36,7 @@
162 from openlp.core.lib import Plugin, StringContent, build_icon
163 from openlp.core.lib.db import Manager
164 from openlp.core.lib.ui import create_action
165+from openlp.plugins.songs import reporting
166 from openlp.plugins.songs.forms.duplicatesongremovalform import DuplicateSongRemovalForm
167 from openlp.plugins.songs.forms.songselectform import SongSelectForm
168 from openlp.plugins.songs.lib import clean_song, upgrade
169@@ -102,13 +103,13 @@
170 self.songselect_form.initialise()
171 self.song_import_item.setVisible(True)
172 self.song_export_item.setVisible(True)
173- self.tools_reindex_item.setVisible(True)
174- self.tools_find_duplicates.setVisible(True)
175+ self.song_tools_menu.menuAction().setVisible(True)
176 action_list = ActionList.get_instance()
177 action_list.add_action(self.song_import_item, UiStrings().Import)
178 action_list.add_action(self.song_export_item, UiStrings().Export)
179 action_list.add_action(self.tools_reindex_item, UiStrings().Tools)
180 action_list.add_action(self.tools_find_duplicates, UiStrings().Tools)
181+ action_list.add_action(self.tools_report_song_list, UiStrings().Tools)
182
183 def add_import_menu_item(self, import_menu):
184 """
185@@ -151,19 +152,37 @@
186 :param tools_menu: The actual **Tools** menu item, so that your actions can use it as their parent.
187 """
188 log.info('add tools menu')
189+ self.tools_menu = tools_menu
190+ self.song_tools_menu = QtWidgets.QMenu(tools_menu)
191+ self.song_tools_menu.setObjectName('song_tools_menu')
192+ self.song_tools_menu.setTitle(translate('SongsPlugin', 'Songs'))
193 self.tools_reindex_item = create_action(
194 tools_menu, 'toolsReindexItem',
195 text=translate('SongsPlugin', '&Re-index Songs'),
196 icon=':/plugins/plugin_songs.png',
197 statustip=translate('SongsPlugin', 'Re-index the songs database to improve searching and ordering.'),
198- visible=False, triggers=self.on_tools_reindex_item_triggered)
199- tools_menu.addAction(self.tools_reindex_item)
200+ triggers=self.on_tools_reindex_item_triggered)
201 self.tools_find_duplicates = create_action(
202 tools_menu, 'toolsFindDuplicates',
203 text=translate('SongsPlugin', 'Find &Duplicate Songs'),
204 statustip=translate('SongsPlugin', 'Find and remove duplicate songs in the song database.'),
205- visible=False, triggers=self.on_tools_find_duplicates_triggered, can_shortcuts=True)
206- tools_menu.addAction(self.tools_find_duplicates)
207+ triggers=self.on_tools_find_duplicates_triggered, can_shortcuts=True)
208+ self.tools_report_song_list = create_action(
209+ tools_menu, 'toolsSongListReport',
210+ text=translate('SongsPlugin', 'Song List Report'),
211+ statustip=translate('SongsPlugin', 'Produce a CSV file of all the songs in the database.'),
212+ triggers=self.on_tools_report_song_list_triggered)
213+
214+ self.tools_menu.addAction(self.song_tools_menu.menuAction())
215+ self.song_tools_menu.addAction(self.tools_reindex_item)
216+ self.song_tools_menu.addAction(self.tools_find_duplicates)
217+ self.song_tools_menu.addAction(self.tools_report_song_list)
218+
219+ self.song_tools_menu.menuAction().setVisible(False)
220+
221+ @staticmethod
222+ def on_tools_report_song_list_triggered():
223+ reporting.report_song_list()
224
225 def on_tools_reindex_item_triggered(self):
226 """
227@@ -326,13 +345,13 @@
228 self.manager.finalise()
229 self.song_import_item.setVisible(False)
230 self.song_export_item.setVisible(False)
231- self.tools_reindex_item.setVisible(False)
232- self.tools_find_duplicates.setVisible(False)
233 action_list = ActionList.get_instance()
234 action_list.remove_action(self.song_import_item, UiStrings().Import)
235 action_list.remove_action(self.song_export_item, UiStrings().Export)
236 action_list.remove_action(self.tools_reindex_item, UiStrings().Tools)
237 action_list.remove_action(self.tools_find_duplicates, UiStrings().Tools)
238+ action_list.add_action(self.tools_report_song_list, UiStrings().Tools)
239+ self.song_tools_menu.menuAction().setVisible(False)
240 super(SongsPlugin, self).finalise()
241
242 def new_service_created(self):
243
244=== modified file 'tests/functional/openlp_core_ui/test_servicemanager.py'
245--- tests/functional/openlp_core_ui/test_servicemanager.py 2016-07-17 19:46:06 +0000
246+++ tests/functional/openlp_core_ui/test_servicemanager.py 2016-10-26 18:03:31 +0000
247@@ -28,6 +28,7 @@
248 import PyQt5
249
250 from openlp.core.common import Registry, ThemeLevel
251+from openlp.core.ui.lib.toolbar import OpenLPToolbar
252 from openlp.core.lib import ServiceItem, ServiceItemType, ItemCapabilities
253 from openlp.core.ui import ServiceManager
254
255@@ -544,8 +545,8 @@
256 self.assertEqual(service_manager.theme_menu.menuAction().setVisible.call_count, 1,
257 'Should have be called once')
258
259- @patch(u'openlp.core.ui.servicemanager.Settings')
260- @patch(u'PyQt5.QtCore.QTimer.singleShot')
261+ @patch('openlp.core.ui.servicemanager.Settings')
262+ @patch('PyQt5.QtCore.QTimer.singleShot')
263 def test_single_click_preview_true(self, mocked_singleShot, MockedSettings):
264 """
265 Test that when "Preview items when clicked in Service Manager" enabled the preview timer starts
266@@ -561,8 +562,8 @@
267 mocked_singleShot.assert_called_with(PyQt5.QtWidgets.QApplication.instance().doubleClickInterval(),
268 service_manager.on_single_click_preview_timeout)
269
270- @patch(u'openlp.core.ui.servicemanager.Settings')
271- @patch(u'PyQt5.QtCore.QTimer.singleShot')
272+ @patch('openlp.core.ui.servicemanager.Settings')
273+ @patch('PyQt5.QtCore.QTimer.singleShot')
274 def test_single_click_preview_false(self, mocked_singleShot, MockedSettings):
275 """
276 Test that when "Preview items when clicked in Service Manager" disabled the preview timer doesn't start
277@@ -577,9 +578,9 @@
278 # THEN: timer should not be started
279 self.assertEqual(mocked_singleShot.call_count, 0, 'Should not be called')
280
281- @patch(u'openlp.core.ui.servicemanager.Settings')
282- @patch(u'PyQt5.QtCore.QTimer.singleShot')
283- @patch(u'openlp.core.ui.servicemanager.ServiceManager.make_live')
284+ @patch('openlp.core.ui.servicemanager.Settings')
285+ @patch('PyQt5.QtCore.QTimer.singleShot')
286+ @patch('openlp.core.ui.servicemanager.ServiceManager.make_live')
287 def test_single_click_preview_double(self, mocked_make_live, mocked_singleShot, MockedSettings):
288 """
289 Test that when a double click has registered the preview timer doesn't start
290@@ -596,7 +597,7 @@
291 mocked_make_live.assert_called_with()
292 self.assertEqual(mocked_singleShot.call_count, 0, 'Should not be called')
293
294- @patch(u'openlp.core.ui.servicemanager.ServiceManager.make_preview')
295+ @patch('openlp.core.ui.servicemanager.ServiceManager.make_preview')
296 def test_single_click_timeout_single(self, mocked_make_preview):
297 """
298 Test that when a single click has been registered, the item is sent to preview
299@@ -609,8 +610,8 @@
300 self.assertEqual(mocked_make_preview.call_count, 1,
301 'ServiceManager.make_preview() should have been called once')
302
303- @patch(u'openlp.core.ui.servicemanager.ServiceManager.make_preview')
304- @patch(u'openlp.core.ui.servicemanager.ServiceManager.make_live')
305+ @patch('openlp.core.ui.servicemanager.ServiceManager.make_preview')
306+ @patch('openlp.core.ui.servicemanager.ServiceManager.make_live')
307 def test_single_click_timeout_double(self, mocked_make_live, mocked_make_preview):
308 """
309 Test that when a double click has been registered, the item does not goes to preview
310@@ -623,9 +624,9 @@
311 # THEN: make_preview() should not have been called
312 self.assertEqual(mocked_make_preview.call_count, 0, 'ServiceManager.make_preview() should not be called')
313
314- @patch(u'openlp.core.ui.servicemanager.shutil.copy')
315- @patch(u'openlp.core.ui.servicemanager.zipfile')
316- @patch(u'openlp.core.ui.servicemanager.ServiceManager.save_file_as')
317+ @patch('openlp.core.ui.servicemanager.shutil.copy')
318+ @patch('openlp.core.ui.servicemanager.zipfile')
319+ @patch('openlp.core.ui.servicemanager.ServiceManager.save_file_as')
320 def test_save_file_raises_permission_error(self, mocked_save_file_as, mocked_zipfile, mocked_shutil_copy):
321 """
322 Test that when a PermissionError is raised when trying to save a file, it is handled correctly
323@@ -652,9 +653,9 @@
324 self.assertTrue(result)
325 mocked_save_file_as.assert_called_with()
326
327- @patch(u'openlp.core.ui.servicemanager.shutil.copy')
328- @patch(u'openlp.core.ui.servicemanager.zipfile')
329- @patch(u'openlp.core.ui.servicemanager.ServiceManager.save_file_as')
330+ @patch('openlp.core.ui.servicemanager.shutil.copy')
331+ @patch('openlp.core.ui.servicemanager.zipfile')
332+ @patch('openlp.core.ui.servicemanager.ServiceManager.save_file_as')
333 def test_save_local_file_raises_permission_error(self, mocked_save_file_as, mocked_zipfile, mocked_shutil_copy):
334 """
335 Test that when a PermissionError is raised when trying to save a local file, it is handled correctly
336@@ -679,3 +680,66 @@
337 # THEN: The "save_as" method is called to save the service
338 self.assertTrue(result)
339 mocked_save_file_as.assert_called_with()
340+
341+ @patch('openlp.core.ui.servicemanager.ServiceManager.regenerate_service_items')
342+ def test_theme_change_global(self, mocked_regenerate_service_items):
343+ """
344+ Test that when a Toolbar theme combobox displays correctly when the theme is set to Global
345+ """
346+ # GIVEN: A service manager, a service to display with a theme level in the renderer
347+ mocked_renderer = MagicMock()
348+ service_manager = ServiceManager(None)
349+ Registry().register('renderer', mocked_renderer)
350+ service_manager.toolbar = OpenLPToolbar(None)
351+ service_manager.toolbar.add_toolbar_action('theme_combo_box', triggers=MagicMock())
352+ service_manager.toolbar.add_toolbar_action('theme_label', triggers=MagicMock())
353+
354+ # WHEN: The service manager has a Global theme
355+ mocked_renderer.theme_level = ThemeLevel.Global
356+ result = service_manager.theme_change()
357+
358+ # THEN: The the theme toolbar should not be visible
359+ self.assertFalse(service_manager.toolbar.actions['theme_combo_box'].isVisible(),
360+ 'The visibility should be False')
361+
362+ @patch('openlp.core.ui.servicemanager.ServiceManager.regenerate_service_items')
363+ def test_theme_change_service(self, mocked_regenerate_service_items):
364+ """
365+ Test that when a Toolbar theme combobox displays correctly when the theme is set to Theme
366+ """
367+ # GIVEN: A service manager, a service to display with a theme level in the renderer
368+ mocked_renderer = MagicMock()
369+ service_manager = ServiceManager(None)
370+ Registry().register('renderer', mocked_renderer)
371+ service_manager.toolbar = OpenLPToolbar(None)
372+ service_manager.toolbar.add_toolbar_action('theme_combo_box', triggers=MagicMock())
373+ service_manager.toolbar.add_toolbar_action('theme_label', triggers=MagicMock())
374+
375+ # WHEN: The service manager has a Service theme
376+ mocked_renderer.theme_level = ThemeLevel.Service
377+ result = service_manager.theme_change()
378+
379+ # THEN: The the theme toolbar should be visible
380+ self.assertTrue(service_manager.toolbar.actions['theme_combo_box'].isVisible(),
381+ 'The visibility should be True')
382+
383+ @patch('openlp.core.ui.servicemanager.ServiceManager.regenerate_service_items')
384+ def test_theme_change_song(self, mocked_regenerate_service_items):
385+ """
386+ Test that when a Toolbar theme combobox displays correctly when the theme is set to Song
387+ """
388+ # GIVEN: A service manager, a service to display with a theme level in the renderer
389+ mocked_renderer = MagicMock()
390+ service_manager = ServiceManager(None)
391+ Registry().register('renderer', mocked_renderer)
392+ service_manager.toolbar = OpenLPToolbar(None)
393+ service_manager.toolbar.add_toolbar_action('theme_combo_box', triggers=MagicMock())
394+ service_manager.toolbar.add_toolbar_action('theme_label', triggers=MagicMock())
395+
396+ # WHEN: The service manager has a Song theme
397+ mocked_renderer.theme_level = ThemeLevel.Song
398+ result = service_manager.theme_change()
399+
400+ # THEN: The the theme toolbar should be visible
401+ self.assertTrue(service_manager.toolbar.actions['theme_combo_box'].isVisible(),
402+ 'The visibility should be True')