Merge branch 'full-service-item' into 'master'

Move JSON rendering into ServiceItem object; Provide entire service item object through API

See merge request openlp/openlp!221
This commit is contained in:
Raoul Snyman 2020-07-25 04:01:40 +00:00
commit 3ebd175738
6 changed files with 533 additions and 51 deletions

View File

@ -19,16 +19,10 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # # along with this program. If not, see <https://www.gnu.org/licenses/>. #
########################################################################## ##########################################################################
import logging import logging
import os
import urllib.request
from pathlib import Path
from openlp.core.api.lib import login_required from openlp.core.api.lib import login_required
from openlp.core.common import ThemeLevel from openlp.core.common import ThemeLevel
from openlp.core.common.registry import Registry from openlp.core.common.registry import Registry
from openlp.core.common.applocation import AppLocation
from openlp.core.lib import create_thumb
from openlp.core.lib.serviceitem import ItemCapabilities
from flask import jsonify, request, abort, Blueprint from flask import jsonify, request, abort, Blueprint
@ -41,44 +35,11 @@ def controller_text_api():
log.debug('controller-v2-live-item') log.debug('controller-v2-live-item')
live_controller = Registry().get('live_controller') live_controller = Registry().get('live_controller')
current_item = live_controller.service_item current_item = live_controller.service_item
data = [] live_item = {}
if current_item: if current_item:
for index, frame in enumerate(current_item.get_frames()): live_item = current_item.to_dict()
item = {} live_item['slides'][live_controller.selected_row]['selected'] = True
item['tag'] = index + 1 return jsonify(live_item)
item['selected'] = live_controller.selected_row == index
item['title'] = current_item.title
if current_item.is_text():
if frame['verse']:
item['tag'] = str(frame['verse'])
item['text'] = frame['text']
item['html'] = current_item.rendered_slides[index]['text']
item['chords'] = current_item.rendered_slides[index]['chords']
elif current_item.is_image() and not frame.get('image', '') and \
Registry().get('settings_thread').value('api/thumbnails'):
thumbnail_path = os.path.join('images', 'thumbnails', frame['title'])
full_thumbnail_path = AppLocation.get_data_path() / thumbnail_path
if not full_thumbnail_path.exists():
create_thumb(Path(current_item.get_frame_path(index)), full_thumbnail_path, False)
item['img'] = urllib.request.pathname2url(os.path.sep + str(thumbnail_path))
item['text'] = str(frame['title'])
item['html'] = str(frame['title'])
else:
# presentations and other things
if current_item.is_capable(ItemCapabilities.HasDisplayTitle):
item['title'] = str(frame['display_title'])
if current_item.is_capable(ItemCapabilities.HasNotes):
item['slide_notes'] = str(frame['notes'])
if current_item.is_capable(ItemCapabilities.HasThumbnails) and \
Registry().get('settings_thread').value('api/thumbnails'):
# If the file is under our app directory tree send the portion after the match
data_path = str(AppLocation.get_data_path())
if frame['image'][0:len(data_path)] == data_path:
item['img'] = urllib.request.pathname2url(frame['image'][len(data_path):])
item['text'] = str(frame['title'])
item['html'] = str(frame['title'])
data.append(item)
return jsonify(data)
@controller_views.route('/show', methods=['POST']) @controller_views.route('/show', methods=['POST'])

View File

@ -25,6 +25,7 @@ import datetime
import logging import logging
import ntpath import ntpath
import os import os
import urllib.request
import uuid import uuid
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
@ -32,7 +33,6 @@ from shutil import copytree, copy, move
from PyQt5 import QtGui from PyQt5 import QtGui
from openlp.core.state import State
from openlp.core.common import ThemeLevel, sha256_file_hash from openlp.core.common import ThemeLevel, sha256_file_hash
from openlp.core.common.applocation import AppLocation from openlp.core.common.applocation import AppLocation
from openlp.core.common.enum import ServiceItemType from openlp.core.common.enum import ServiceItemType
@ -41,7 +41,9 @@ from openlp.core.common.mixins import RegistryProperties
from openlp.core.common.registry import Registry from openlp.core.common.registry import Registry
from openlp.core.display.render import remove_tags, render_tags, render_chords_for_printing from openlp.core.display.render import remove_tags, render_tags, render_chords_for_printing
from openlp.core.lib import ItemCapabilities from openlp.core.lib import ItemCapabilities
from openlp.core.lib import create_thumb
from openlp.core.lib.theme import BackgroundType from openlp.core.lib.theme import BackgroundType
from openlp.core.state import State
from openlp.core.ui.icons import UiIcons from openlp.core.ui.icons import UiIcons
from openlp.core.ui.media import parse_stream_path from openlp.core.ui.media import parse_stream_path
@ -844,3 +846,59 @@ class ServiceItem(RegistryProperties):
elif self.is_image() and self.slides and 'thumbnail' in self.slides[0]: elif self.is_image() and self.slides and 'thumbnail' in self.slides[0]:
return os.path.dirname(self.slides[0]['thumbnail']) return os.path.dirname(self.slides[0]['thumbnail'])
return None return None
def to_dict(self):
"""
Convert the service item into a dictionary
"""
data_dict = {
'title': self.title,
'name': self.name,
'type': str(self.service_item_type),
'theme': self.theme,
'footer': self.raw_footer,
'audit': self.audit,
'notes': self.notes,
'fromPlugin': self.from_plugin,
'capabilities': self.capabilities,
'backgroundAudio': [str(file_path) for file_path in self.background_audio],
'isThemeOverwritten': self.theme_overwritten,
'slides': []
}
for index, frame in enumerate(self.get_frames()):
item = {
'tag': index + 1,
'title': self.title,
'selected': False
}
if self.is_text():
if frame['verse']:
item['tag'] = str(frame['verse'])
item['text'] = frame['text']
item['html'] = self.rendered_slides[index]['text']
item['chords'] = self.rendered_slides[index]['chords']
elif self.is_image() and not frame.get('image', '') and \
Registry().get('settings_thread').value('api/thumbnails'):
thumbnail_path = os.path.join('images', 'thumbnails', frame['title'])
full_thumbnail_path = AppLocation.get_data_path() / thumbnail_path
if not full_thumbnail_path.exists():
create_thumb(Path(self.get_frame_path(index)), full_thumbnail_path, False)
item['img'] = urllib.request.pathname2url(os.path.sep + str(thumbnail_path))
item['text'] = str(frame['title'])
item['html'] = str(frame['title'])
else:
# presentations and other things
if self.is_capable(ItemCapabilities.HasDisplayTitle):
item['title'] = str(frame['display_title'])
if self.is_capable(ItemCapabilities.HasNotes):
item['slide_notes'] = str(frame['notes'])
if self.is_capable(ItemCapabilities.HasThumbnails) and \
Registry().get('settings_thread').value('api/thumbnails'):
# If the file is under our app directory tree send the portion after the match
data_path = str(AppLocation.get_data_path())
if frame['image'][0:len(data_path)] == data_path:
item['img'] = urllib.request.pathname2url(frame['image'][len(data_path):])
item['text'] = str(frame['title'])
item['html'] = str(frame['title'])
data_dict['slides'].append(item)
return data_dict

View File

@ -417,10 +417,10 @@ class ProjectorDB(Manager):
""" """
source_dict = {} source_dict = {}
# Apparently, there was a change to the projector object. Test for which object has db id # Apparently, there was a change to the projector object. Test for which object has db id
if hasattr(projector, "id"): if hasattr(projector, 'entry') and hasattr(projector.entry, 'id'):
chk = projector.id
elif hasattr(projector.entry, "id"):
chk = projector.entry.id chk = projector.entry.id
else:
chk = projector.id
# Get default list first # Get default list first
for key in projector.source_available: for key in projector.source_available:

View File

@ -76,10 +76,9 @@ def service_item_env(state):
def test_service_item_basic(settings): def test_service_item_basic(settings):
""" """
Test the Service Item - basic test Test creating a new Service Item without a plugin
""" """
# GIVEN: A new service item # GIVEN: A new service item
# WHEN: A service item is created (without a plugin) # WHEN: A service item is created (without a plugin)
service_item = ServiceItem(None) service_item = ServiceItem(None)
@ -88,6 +87,22 @@ def test_service_item_basic(settings):
assert service_item.missing_frames() is True, 'There should not be any frames in the service item' assert service_item.missing_frames() is True, 'There should not be any frames in the service item'
def test_service_item_with_plugin(settings):
"""
Test creating a new Service Item with a plugin
"""
# GIVEN: A new service item
mocked_plugin = MagicMock()
mocked_plugin.name = 'songs'
# WHEN: A service item is created (with a plugin)
service_item = ServiceItem(mocked_plugin)
# THEN: We should get back a valid service item
assert service_item.name == 'songs', 'The service item name should be the same as the plugin name'
assert service_item.is_valid is True, 'The new service item should be valid'
assert service_item.missing_frames() is True, 'There should not be any frames in the service item'
def test_service_item_load_custom_from_service(state_media, settings, service_item_env): def test_service_item_load_custom_from_service(state_media, settings, service_item_env):
""" """
Test the Service Item - adding a custom slide from a saved service Test the Service Item - adding a custom slide from a saved service
@ -522,3 +537,249 @@ def test_service_item_get_theme_data_song_level_global_fallback(settings):
# THEN: theme should be the global theme # THEN: theme should be the global theme
assert theme == mocked_theme_manager.global_theme assert theme == mocked_theme_manager.global_theme
def test_remove_capability(settings):
# GIVEN: A service item with a capability
service_item = ServiceItem(None)
service_item.add_capability(ItemCapabilities.CanEdit)
assert ItemCapabilities.CanEdit in service_item.capabilities
# WHEN: A capability is removed
service_item.remove_capability(ItemCapabilities.CanEdit)
# THEN: The capability should no longer be there
assert ItemCapabilities.CanEdit not in service_item.capabilities, 'The capability should not be in the list'
def test_to_dict_text_item(state_media, settings, service_item_env):
"""
Test that the to_dict() method returns the correct data for the service item
"""
# GIVEN: A ServiceItem with a service loaded from file
mocked_plugin = MagicMock()
mocked_plugin.name = 'songs'
service_item = ServiceItem(mocked_plugin)
service_item.add_icon = MagicMock()
FormattingTags.load_tags()
line = convert_file_service_item(TEST_PATH, 'serviceitem-song-linked-audio.osj')
service_item.set_from_service(line, Path('/test/'))
# WHEN: to_dict() is called
result = service_item.to_dict()
# THEN: The correct dictionary should be returned
expected_dict = {
'audit': ['Amazing Grace', ['John Newton'], '', ''],
'backgroundAudio': ['/test/amazing_grace.mp3'],
'capabilities': [2, 1, 5, 8, 9, 13, 15],
'footer': ['Amazing Grace', 'Written by: John Newton'],
'fromPlugin': False,
'isThemeOverwritten': False,
'name': 'songs',
'notes': '',
'slides': [
{
'chords': 'Amazing Grace! how sweet the sound\n'
'That saved a wretch like me;\n'
'I once was lost, but now am found,\n'
'Was blind, but now I see.',
'html': 'Amazing Grace! how sweet the sound\n'
'That saved a wretch like me;\n'
'I once was lost, but now am found,\n'
'Was blind, but now I see.',
'selected': False,
'tag': 'V1',
'text': 'Amazing Grace! how sweet the sound\n'
'That saved a wretch like me;\n'
'I once was lost, but now am found,\n'
'Was blind, but now I see.',
'title': 'Amazing Grace'
},
{
'chords': 'Twas grace that taught my heart to fear,\n'
'And grace my fears relieved;\n'
'How precious did that grace appear,\n'
'The hour I first believed!',
'html': 'Twas grace that taught my heart to fear,\n'
'And grace my fears relieved;\n'
'How precious did that grace appear,\n'
'The hour I first believed!',
'selected': False,
'tag': 'V2',
'text': 'Twas grace that taught my heart to fear,\n'
'And grace my fears relieved;\n'
'How precious did that grace appear,\n'
'The hour I first believed!',
'title': 'Amazing Grace'
},
{
'chords': 'Through many dangers, toils and snares\n'
'I have already come;\n'
'Tis grace that brought me safe thus far,\n'
'And grace will lead me home.',
'html': 'Through many dangers, toils and snares\n'
'I have already come;\n'
'Tis grace that brought me safe thus far,\n'
'And grace will lead me home.',
'selected': False,
'tag': 'V3',
'text': 'Through many dangers, toils and snares\n'
'I have already come;\n'
'Tis grace that brought me safe thus far,\n'
'And grace will lead me home.',
'title': 'Amazing Grace'
},
{
'chords': 'The Lord has promised good to me,\n'
'His word my hope secures;\n'
'He will my shield and portion be\n'
'As long as life endures.',
'html': 'The Lord has promised good to me,\n'
'His word my hope secures;\n'
'He will my shield and portion be\n'
'As long as life endures.',
'selected': False,
'tag': 'V4',
'text': 'The Lord has promised good to me,\n'
'His word my hope secures;\n'
'He will my shield and portion be\n'
'As long as life endures.',
'title': 'Amazing Grace'
},
{
'chords': 'Yes, when this heart and flesh shall fail,\n'
'And mortal life shall cease,\n'
'I shall possess within the veil\n'
'A life of joy and peace.',
'html': 'Yes, when this heart and flesh shall fail,\n'
'And mortal life shall cease,\n'
'I shall possess within the veil\n'
'A life of joy and peace.',
'selected': False,
'tag': 'V5',
'text': 'Yes, when this heart and flesh shall fail,\n'
'And mortal life shall cease,\n'
'I shall possess within the veil\n'
'A life of joy and peace.',
'title': 'Amazing Grace'
},
{
'chords': 'When weve been there a thousand years,\n'
'Bright shining as the sun,\n'
'Weve no less days to sing Gods praise\n'
'Than when we first begun.',
'html': 'When weve been there a thousand years,\n'
'Bright shining as the sun,\n'
'Weve no less days to sing Gods praise\n'
'Than when we first begun.',
'selected': False,
'tag': 'V6',
'text': 'When weve been there a thousand years,\n'
'Bright shining as the sun,\n'
'Weve no less days to sing Gods praise\n'
'Than when we first begun.',
'title': 'Amazing Grace'
}
],
'theme': None,
'title': 'Amazing Grace',
'type': 'ServiceItemType.Text'
}
assert result == expected_dict
def test_to_dict_image_item(state_media, settings, service_item_env):
"""
Test that the to_dict() method returns the correct data for the service item
"""
# GIVEN: A ServiceItem with a service loaded from file
mocked_plugin = MagicMock()
mocked_plugin.name = 'image'
service_item = ServiceItem(mocked_plugin)
service_item.add_icon = MagicMock()
FormattingTags.load_tags()
line = convert_file_service_item(TEST_PATH, 'serviceitem_image_2.osj')
with patch('openlp.core.lib.serviceitem.sha256_file_hash') as mocked_sha256_file_hash:
mocked_sha256_file_hash.return_value = '3a7ccbdb0b5a3db169c4692d7aad0ec8'
service_item.set_from_service(line)
# WHEN: to_dict() is called
result = service_item.to_dict()
# THEN: The correct dictionary should be returned
expected_dict = {
'audit': '',
'backgroundAudio': [],
'capabilities': [3, 1, 5, 6],
'footer': [],
'fromPlugin': False,
'isThemeOverwritten': False,
'name': 'images',
'notes': '',
'slides': [
{
'html': 'image_1.jpg',
'img': '/images/thumbnails/image_1.jpg',
'selected': False,
'tag': 1,
'text': 'image_1.jpg',
'title': 'Images'
}
],
'theme': -1,
'title': 'Images',
'type': 'ServiceItemType.Image'
}
assert result == expected_dict
def test_to_dict_presentation_item(state_media, settings, service_item_env):
"""
Test that the to_dict() method returns the correct data for the service item
"""
# GIVEN: A ServiceItem with a service loaded from file
mocked_plugin = MagicMock()
mocked_plugin.name = 'presentations'
service_item = ServiceItem(mocked_plugin)
presentation_name = 'test.pptx'
image = Path('thumbnails/abcd/slide1.png')
display_title = 'DisplayTitle'
notes = 'Note1\nNote2\n'
# WHEN: adding presentation to service_item
with patch('openlp.core.lib.serviceitem.sha256_file_hash') as mocked_sha256_file_hash,\
patch('openlp.core.lib.serviceitem.AppLocation.get_section_data_path') as mocked_get_section_data_path:
mocked_sha256_file_hash.return_value = '4a067fed6834ea2bc4b8819f11636365'
mocked_get_section_data_path.return_value = Path('.')
service_item.add_from_command(TEST_PATH, presentation_name, image, display_title, notes)
# WHEN: to_dict() is called
result = service_item.to_dict()
# THEN: The correct dictionary should be returned
expected_dict = {
'audit': '',
'backgroundAudio': [],
'capabilities': [],
'footer': [],
'fromPlugin': False,
'isThemeOverwritten': False,
'name': 'presentations',
'notes': '',
'slides': [
{
'html': 'test.pptx',
'selected': False,
'tag': 1,
'text': 'test.pptx',
'title': ''
}
],
'theme': None,
'title': '',
'type': 'ServiceItemType.Command'
}
assert result == expected_dict

View File

@ -29,6 +29,26 @@ Test the States class.
""" """
def test_load_settings(state):
# GIVEN: Some modules
State().modules = {'mock': MagicMock}
# WHEN: load_settings() is run
State().load_settings()
# THEN: the modules should be empty
assert State().modules == {}, 'There should be no modules in the State object'
def test_save_settings(state):
# GIVEN: Niks
# WHEN: save_settings() is run
State().save_settings()
# THEN: Nothing should happen
assert True, 'There should be no exceptions'
def test_add_service(state): def test_add_service(state):
# GIVEN a new state # GIVEN a new state
# WHEN I add a new service # WHEN I add a new service
@ -128,3 +148,62 @@ def test_basic_preconditions_pass(state, registry):
assert State().modules['test'].pass_preconditions is True assert State().modules['test'].pass_preconditions is True
assert State().modules['test2'].pass_preconditions is False assert State().modules['test2'].pass_preconditions is False
assert State().modules['test1'].pass_preconditions is True assert State().modules['test1'].pass_preconditions is True
def test_missing_text(state):
"""
Test that settings the missing text in a module works
"""
# GIVEN: A state with a module
State().modules['test'] = MagicMock()
# WHEN: missing_text() is called
State().missing_text('test', 'Test test')
# THEN: The text is set
assert State().modules['test'].text == 'Test test', 'The text on the module should have been set'
def test_get_text(state):
"""
Test that the get_text() method returns the text of all the states
"""
# GIVEN: Some states with text
State().modules.update({'test1': MagicMock(text='Test 1'), 'test2': MagicMock(text='Test 2')})
# WHEN: get_text() is called
result = State().get_text()
# THEN: The correct text is returned
assert result == 'Test 1\nTest 2\n', 'The full text is returned'
def test_check_preconditions_no_required(state):
"""
Test that the check_preconditions() method returns the correct attribute when there are no requirements
"""
# GIVEN: A State with no requires
State().modules.update({'test_pre1': MagicMock(requires=None, pass_preconditions=True)})
# WHEN: check_preconditions() is called
result = State().check_preconditions('test_pre1')
# THEN: The correct result should be returned
assert result is True
def test_check_preconditions_required_module(state):
"""
Test that the check_preconditions() method returns the correct attribute when there is another required module
"""
# GIVEN: A State with two modules
State().modules.update({
'test_pre2': MagicMock(requires='test_pre3', pass_preconditions=True),
'test_pre3': MagicMock(requires=None, pass_preconditions=False)
})
# WHEN: check_preconditions() is called
result = State().check_preconditions('test_pre2')
# THEN: The correct result should be returned
assert result is False

View File

@ -27,7 +27,7 @@ PREREQUISITE: add_record() and get_all() functions validated.
import pytest import pytest
import os import os
import shutil import shutil
from unittest.mock import patch from unittest.mock import MagicMock, patch
from openlp.core.lib.db import upgrade_db from openlp.core.lib.db import upgrade_db
from openlp.core.projectors import upgrade from openlp.core.projectors import upgrade
@ -321,6 +321,21 @@ def test_get_projector_by_id_none(projector):
def test_get_projector_all_none(projector): def test_get_projector_all_none(projector):
"""
Test get_projector_all() when self.get_all_objects() returns None
"""
# GIVEN: Mocked out get_all_objects
with patch.object(projector, 'get_all_objects') as mocked_get_all_objects:
mocked_get_all_objects.return_value = None
# WHEN: We retrieve the database entries
results = projector.get_projector_all()
# THEN: Verify results is empty
assert [] == results, 'Returned results should have returned an empty list'
def test_get_projector_all_empty(projector):
""" """
Test get_projector_all() with no projectors in db Test get_projector_all() with no projectors in db
""" """
@ -329,7 +344,7 @@ def test_get_projector_all_none(projector):
# WHEN: We retrieve the database entries # WHEN: We retrieve the database entries
results = projector.get_projector_all() results = projector.get_projector_all()
# THEN: Verify results is None # THEN: Verify results is empty
assert [] == results, 'Returned results should have returned an empty list' assert [] == results, 'Returned results should have returned an empty list'
@ -436,3 +451,111 @@ def test_delete_projector_fail(projector):
# THEN: Results should be False # THEN: Results should be False
assert results is False, 'delete_projector() should have returned False' assert results is False, 'delete_projector() should have returned False'
def test_get_source_list_no_sources(projector):
"""
Test that an empty source list is returned
"""
# GIVEN: A mocked projector
mocked_projector = MagicMock(id='1', source_available=[])
# WHEN: get_source_list is run
results = projector.get_source_list(mocked_projector)
# THEN: The list should be empty
assert results == {}, 'The list of sources returned should be empty'
def test_get_source_list_source_is_none(projector):
"""
Test that a default code is returned when a source is not in the database
"""
# GIVEN: A mocked projector
mocked_projector = MagicMock(id='1', source_available=['11'])
with patch.object(projector, 'get_object_filtered') as mocked_get_object_filtered:
mocked_get_object_filtered.return_value = None
# WHEN: get_source_list is run
results = projector.get_source_list(mocked_projector)
# THEN: The list should contain the one default item
assert results == {'11': 'RGB 1'}, 'The list of sources returned should contain "RGB1"'
def test_get_source_list_source_has_item(projector):
"""
Test that a default code is returned when a source is in the database
"""
# GIVEN: A mocked projector
mocked_projector = MagicMock(entry=MagicMock(id='1'), source_available=['5Y'])
with patch.object(projector, 'get_object_filtered') as mocked_get_object_filtered:
mocked_get_object_filtered.return_value = MagicMock(text='VGA 1')
# WHEN: get_source_list is run
results = projector.get_source_list(mocked_projector)
# THEN: The list should contain the one default item
assert results == {'5Y': 'VGA 1'}, 'The list of sources returned should contain "RGB1"'
def test_get_source_by_id_none(projector):
"""
Test that no source in the db returns None
"""
# GIVEN: A Mocked get_object_filtered method
with patch.object(projector, 'get_object_filtered') as mocked_get_object_filtered:
mocked_get_object_filtered.return_value = None
# WHEN: Get the source by ID
result = projector.get_source_by_id('source')
# THEN: The result should be None
assert result is None, 'None should be returned by get_source_by_id'
def test_get_source_by_id(projector):
"""
Test that a source in the db returns that source
"""
# GIVEN: A Mocked get_object_filtered method
mocked_entry = MagicMock()
with patch.object(projector, 'get_object_filtered') as mocked_get_object_filtered:
mocked_get_object_filtered.return_value = mocked_entry
# WHEN: Get the source by ID
result = projector.get_source_by_id('source')
# THEN: The result should be the mocked entry
assert result is mocked_entry, 'The mocked entry should be returned by get_source_by_id'
def test_get_source_by_code_none(projector):
"""
Test that no source in the db returns None
"""
# GIVEN: A Mocked get_object_filtered method
with patch.object(projector, 'get_object_filtered') as mocked_get_object_filtered:
mocked_get_object_filtered.return_value = None
# WHEN: Get the source by code
result = projector.get_source_by_code('11', 52)
# THEN: The result should be None
assert result is None, 'None should be returned by get_source_by_code'
def test_get_source_by_code(projector):
"""
Test that a source in the db returns that source
"""
# GIVEN: A Mocked get_object_filtered method
mocked_entry = MagicMock()
with patch.object(projector, 'get_object_filtered') as mocked_get_object_filtered:
mocked_get_object_filtered.return_value = mocked_entry
# WHEN: Get the source by code
result = projector.get_source_by_code('5Y', 12)
# THEN: The result should be the mocked entry
assert result is mocked_entry, 'The mocked entry should be returned by get_source_by_code'