diff --git a/openlp/core/common/httputils.py b/openlp/core/common/httputils.py index e7d11fad5..40077f630 100644 --- a/openlp/core/common/httputils.py +++ b/openlp/core/common/httputils.py @@ -32,6 +32,7 @@ import requests from openlp.core.common import trace_error_handler from openlp.core.common.registry import Registry +from openlp.core.common.settings import ProxyMode, Settings log = logging.getLogger(__name__ + '.__init__') @@ -64,6 +65,39 @@ CONNECTION_TIMEOUT = 30 CONNECTION_RETRIES = 2 +def get_proxy_settings(mode=None): + """ + Create a dictionary containing the proxy settings. + + :param ProxyMode | None mode: Specify the source of the proxy settings + :return: A dict using the format expected by the requests library. + :rtype: dict | None + """ + settings = Settings() + if mode is None: + mode = settings.value('advanced/proxy mode') + if mode == ProxyMode.NO_PROXY: + return {'http': None, 'https': None} + elif mode == ProxyMode.SYSTEM_PROXY: + # The requests library defaults to using the proxy settings in the environment variables + return + elif mode == ProxyMode.MANUAL_PROXY: + http_addr = settings.value('advanced/proxy http') + https_addr = settings.value('advanced/proxy https') + username = settings.value('advanced/proxy username') + password = settings.value('advanced/proxy password') + basic_auth = '' + if username: + basic_auth = '{username}:{password}@'.format(username=username, password=password) + http_value = None + https_value = None + if http_addr is not None: + http_value = 'http://{basic_auth}{http_addr}'.format(basic_auth=basic_auth, http_addr=http_addr) + if https_addr is not None: + https_value = 'https://{basic_auth}{https_addr}'.format(basic_auth=basic_auth, https_addr=https_addr) + return {'http': http_value, 'https': https_value} + + def get_user_agent(): """ Return a user agent customised for the platform the user is on. @@ -75,7 +109,7 @@ def get_user_agent(): return browser_list[random_index] -def get_web_page(url, headers=None, update_openlp=False, proxies=None): +def get_web_page(url, headers=None, update_openlp=False, proxy_mode=None): """ Attempts to download the webpage at url and returns that page or None. @@ -90,6 +124,8 @@ def get_web_page(url, headers=None, update_openlp=False, proxies=None): headers = {} if 'user-agent' not in [key.lower() for key in headers.keys()]: headers['User-Agent'] = get_user_agent() + if proxy_mode is None: + proxies = get_proxy_settings(mode=proxy_mode) log.debug('Downloading URL = %s' % url) retries = 0 while retries < CONNECTION_RETRIES: diff --git a/openlp/core/common/settings.py b/openlp/core/common/settings.py index 91a587616..74d686b09 100644 --- a/openlp/core/common/settings.py +++ b/openlp/core/common/settings.py @@ -26,6 +26,7 @@ import datetime import json import logging import os +from enum import IntEnum from tempfile import gettempdir from PyQt5 import QtCore, QtGui @@ -38,6 +39,13 @@ log = logging.getLogger(__name__) __version__ = 2 + +class ProxyMode(IntEnum): + NO_PROXY = 1 + SYSTEM_PROXY = 2 + MANUAL_PROXY = 3 + + # Fix for bug #1014422. X11_BYPASS_DEFAULT = True if is_linux(): # pragma: no cover @@ -116,6 +124,11 @@ class Settings(QtCore.QSettings): 'advanced/print file meta data': False, 'advanced/print notes': False, 'advanced/print slide text': False, + 'advanced/proxy mode': ProxyMode.SYSTEM_PROXY, + 'advanced/proxy http': '', + 'advanced/proxy https': '', + 'advanced/proxy username': '', + 'advanced/proxy password': '', 'advanced/recent file count': 4, 'advanced/save current plugin': False, 'advanced/slide limits': SlideLimits.End, diff --git a/openlp/core/ui/advancedtab.py b/openlp/core/ui/advancedtab.py index ffec0e5b8..fa970f234 100644 --- a/openlp/core/ui/advancedtab.py +++ b/openlp/core/ui/advancedtab.py @@ -35,6 +35,7 @@ from openlp.core.lib import SettingsTab, build_icon from openlp.core.ui.style import HAS_DARK_STYLE from openlp.core.widgets.edits import PathEdit from openlp.core.widgets.enums import PathEditType +from openlp.core.widgets.widgets import ProxyWidget log = logging.getLogger(__name__) @@ -76,6 +77,9 @@ class AdvancedTab(SettingsTab): self.media_plugin_check_box = QtWidgets.QCheckBox(self.ui_group_box) self.media_plugin_check_box.setObjectName('media_plugin_check_box') self.ui_layout.addRow(self.media_plugin_check_box) + self.hide_mouse_check_box = QtWidgets.QCheckBox(self.ui_group_box) + self.hide_mouse_check_box.setObjectName('hide_mouse_check_box') + self.ui_layout.addWidget(self.hide_mouse_check_box) self.double_click_live_check_box = QtWidgets.QCheckBox(self.ui_group_box) self.double_click_live_check_box.setObjectName('double_click_live_check_box') self.ui_layout.addRow(self.double_click_live_check_box) @@ -116,6 +120,24 @@ class AdvancedTab(SettingsTab): self.use_dark_style_checkbox = QtWidgets.QCheckBox(self.ui_group_box) self.use_dark_style_checkbox.setObjectName('use_dark_style_checkbox') self.ui_layout.addRow(self.use_dark_style_checkbox) + # Service Item Slide Limits + self.slide_group_box = QtWidgets.QGroupBox(self.left_column) + self.slide_group_box.setObjectName('slide_group_box') + self.slide_layout = QtWidgets.QVBoxLayout(self.slide_group_box) + self.slide_layout.setObjectName('slide_layout') + self.slide_label = QtWidgets.QLabel(self.slide_group_box) + self.slide_label.setWordWrap(True) + self.slide_layout.addWidget(self.slide_label) + self.end_slide_radio_button = QtWidgets.QRadioButton(self.slide_group_box) + self.end_slide_radio_button.setObjectName('end_slide_radio_button') + self.slide_layout.addWidget(self.end_slide_radio_button) + self.wrap_slide_radio_button = QtWidgets.QRadioButton(self.slide_group_box) + self.wrap_slide_radio_button.setObjectName('wrap_slide_radio_button') + self.slide_layout.addWidget(self.wrap_slide_radio_button) + self.next_item_radio_button = QtWidgets.QRadioButton(self.slide_group_box) + self.next_item_radio_button.setObjectName('next_item_radio_button') + self.slide_layout.addWidget(self.next_item_radio_button) + self.left_layout.addWidget(self.slide_group_box) # Data Directory self.data_directory_group_box = QtWidgets.QGroupBox(self.left_column) self.data_directory_group_box.setObjectName('data_directory_group_box') @@ -142,33 +164,6 @@ class AdvancedTab(SettingsTab): self.data_directory_layout.addRow(self.data_directory_copy_check_layout) self.data_directory_layout.addRow(self.new_data_directory_has_files_label) self.left_layout.addWidget(self.data_directory_group_box) - # Hide mouse - self.hide_mouse_group_box = QtWidgets.QGroupBox(self.right_column) - self.hide_mouse_group_box.setObjectName('hide_mouse_group_box') - self.hide_mouse_layout = QtWidgets.QVBoxLayout(self.hide_mouse_group_box) - self.hide_mouse_layout.setObjectName('hide_mouse_layout') - self.hide_mouse_check_box = QtWidgets.QCheckBox(self.hide_mouse_group_box) - self.hide_mouse_check_box.setObjectName('hide_mouse_check_box') - self.hide_mouse_layout.addWidget(self.hide_mouse_check_box) - self.right_layout.addWidget(self.hide_mouse_group_box) - # Service Item Slide Limits - self.slide_group_box = QtWidgets.QGroupBox(self.right_column) - self.slide_group_box.setObjectName('slide_group_box') - self.slide_layout = QtWidgets.QVBoxLayout(self.slide_group_box) - self.slide_layout.setObjectName('slide_layout') - self.slide_label = QtWidgets.QLabel(self.slide_group_box) - self.slide_label.setWordWrap(True) - self.slide_layout.addWidget(self.slide_label) - self.end_slide_radio_button = QtWidgets.QRadioButton(self.slide_group_box) - self.end_slide_radio_button.setObjectName('end_slide_radio_button') - self.slide_layout.addWidget(self.end_slide_radio_button) - self.wrap_slide_radio_button = QtWidgets.QRadioButton(self.slide_group_box) - self.wrap_slide_radio_button.setObjectName('wrap_slide_radio_button') - self.slide_layout.addWidget(self.wrap_slide_radio_button) - self.next_item_radio_button = QtWidgets.QRadioButton(self.slide_group_box) - self.next_item_radio_button.setObjectName('next_item_radio_button') - self.slide_layout.addWidget(self.next_item_radio_button) - self.right_layout.addWidget(self.slide_group_box) # Display Workarounds self.display_workaround_group_box = QtWidgets.QGroupBox(self.right_column) self.display_workaround_group_box.setObjectName('display_workaround_group_box') @@ -223,6 +218,9 @@ class AdvancedTab(SettingsTab): self.service_name_example.setObjectName('service_name_example') self.service_name_layout.addRow(self.service_name_example_label, self.service_name_example) self.right_layout.addWidget(self.service_name_group_box) + # Proxies + self.proxy_widget = ProxyWidget(self.right_column) + self.right_layout.addWidget(self.proxy_widget) # After the last item on each side, add some spacing self.left_layout.addStretch() self.right_layout.addStretch() @@ -311,7 +309,6 @@ class AdvancedTab(SettingsTab): translate('OpenLP.AdvancedTab', 'Revert to the default service name "{name}".').format(name=UiStrings().DefaultServiceName)) self.service_name_example_label.setText(translate('OpenLP.AdvancedTab', 'Example:')) - self.hide_mouse_group_box.setTitle(translate('OpenLP.AdvancedTab', 'Mouse Cursor')) self.hide_mouse_check_box.setText(translate('OpenLP.AdvancedTab', 'Hide mouse cursor when over display window')) self.data_directory_new_label.setText(translate('OpenLP.AdvancedTab', 'Path:')) self.data_directory_cancel_button.setText(translate('OpenLP.AdvancedTab', 'Cancel')) @@ -334,6 +331,7 @@ class AdvancedTab(SettingsTab): self.wrap_slide_radio_button.setText(translate('OpenLP.GeneralTab', '&Wrap around')) self.next_item_radio_button.setText(translate('OpenLP.GeneralTab', '&Move to next/previous service item')) self.search_as_type_check_box.setText(translate('SongsPlugin.GeneralTab', 'Enable search as you type')) + self.proxy_widget.retranslate_ui() def load(self): """ @@ -436,6 +434,7 @@ class AdvancedTab(SettingsTab): if HAS_DARK_STYLE: settings.setValue('use_dark_style', self.use_dark_style_checkbox.isChecked()) settings.endGroup() + self.proxy_widget.save() def on_search_as_type_check_box_changed(self, check_state): self.is_search_as_you_type_enabled = (check_state == QtCore.Qt.Checked) diff --git a/tests/functional/openlp_core/common/test_httputils.py b/tests/functional/openlp_core/common/test_httputils.py index 7d3966279..1a979454c 100644 --- a/tests/functional/openlp_core/common/test_httputils.py +++ b/tests/functional/openlp_core/common/test_httputils.py @@ -27,13 +27,14 @@ import tempfile from unittest import TestCase from unittest.mock import MagicMock, patch -from openlp.core.common.httputils import get_user_agent, get_web_page, get_url_file_size, download_file +from openlp.core.common.httputils import ProxyMode, download_file, get_proxy_settings, get_url_file_size, \ + get_user_agent, get_web_page from openlp.core.common.path import Path +from openlp.core.common.settings import Settings from tests.helpers.testmixin import TestMixin class TestHttpUtils(TestCase, TestMixin): - """ A test suite to test out various http helper functions. """ @@ -240,3 +241,121 @@ class TestHttpUtils(TestCase, TestMixin): # THEN: socket.timeout should have been caught # NOTE: Test is if $tmpdir/tempfile is still there, then test fails since ftw deletes bad downloaded files assert os.path.exists(self.tempfile) is False, 'tempfile should have been deleted' + + +class TestGetProxySettings(TestCase, TestMixin): + def setUp(self): + self.build_settings() + self.addCleanup(self.destroy_settings) + + @patch('openlp.core.common.httputils.Settings') + def test_mode_arg_specified(self, MockSettings): + """ + Test that the argument is used rather than reading the 'advanced/proxy mode' setting + """ + # GIVEN: Mocked settings + mocked_settings = MagicMock() + MockSettings.return_value = mocked_settings + + # WHEN: Calling `get_proxy_settings` with the mode arg specified + get_proxy_settings(mode=ProxyMode.NO_PROXY) + + # THEN: The mode arg should have been used rather than looking it up in the settings + mocked_settings.value.assert_not_called() + + @patch('openlp.core.common.httputils.Settings') + def test_mode_incorrect_arg_specified(self, MockSettings): + """ + Test that the system settings are used when the mode arg specieied is invalid + """ + # GIVEN: Mocked settings + mocked_settings = MagicMock() + MockSettings.return_value = mocked_settings + + # WHEN: Calling `get_proxy_settings` with an invalid mode arg specified + result = get_proxy_settings(mode='qwerty') + + # THEN: An None should be returned + mocked_settings.value.assert_not_called() + assert result is None + + + def test_no_proxy_mode(self): + """ + Test that a dictionary with http and https values are set to None is returned, when `NO_PROXY` mode is specified + """ + # GIVEN: A `proxy mode` setting of NO_PROXY + Settings().setValue('advanced/proxy mode', ProxyMode.NO_PROXY) + + # WHEN: Calling `get_proxy_settings` + result = get_proxy_settings() + + # THEN: The returned value should be a dictionary with http and https values set to None + assert result == {'http': None, 'https': None} + + def test_system_proxy_mode(self): + """ + Test that None is returned, when `SYSTEM_PROXY` mode is specified + """ + # GIVEN: A `proxy mode` setting of SYSTEM_PROXY + Settings().setValue('advanced/proxy mode', ProxyMode.SYSTEM_PROXY) + + # WHEN: Calling `get_proxy_settings` + result = get_proxy_settings() + + # THEN: The returned value should be None + assert result is None + + def test_manual_proxy_mode_no_auth(self): + """ + Test that the correct proxy addresses are returned when basic authentication is not used + """ + # GIVEN: A `proxy mode` setting of MANUAL_PROXY with proxy servers, but no auth credentials are supplied + Settings().setValue('advanced/proxy mode', ProxyMode.MANUAL_PROXY) + Settings().setValue('advanced/proxy http', 'testhttp.server:port') + Settings().setValue('advanced/proxy https', 'testhttps.server:port') + Settings().setValue('advanced/proxy username', '') + Settings().setValue('advanced/proxy password', '') + + # WHEN: Calling `get_proxy_settings` + result = get_proxy_settings() + + # THEN: The returned value should be the proxy servers without authentication + assert result == {'http': 'http://testhttp.server:port', 'https': 'https://testhttps.server:port'} + + + def test_manual_proxy_mode_auth(self): + """ + Test that the correct proxy addresses are returned when basic authentication is used + """ + # GIVEN: A `proxy mode` setting of MANUAL_PROXY with proxy servers and auth credentials supplied + Settings().setValue('advanced/proxy mode', ProxyMode.MANUAL_PROXY) + Settings().setValue('advanced/proxy http', 'testhttp.server:port') + Settings().setValue('advanced/proxy https', 'testhttps.server:port') + Settings().setValue('advanced/proxy username', 'user') + Settings().setValue('advanced/proxy password', 'pass') + + # WHEN: Calling `get_proxy_settings` + result = get_proxy_settings() + + # THEN: The returned value should be the proxy servers with the authentication credentials + assert result == {'http': 'http://user:pass@testhttp.server:port', + 'https': 'https://user:pass@testhttps.server:port'} + + def test_manual_proxy_mode_no_servers(self): + """ + Test that the system proxies are overidden when the MANUAL_PROXY mode is specified, but no server addresses are + supplied + """ + # GIVEN: A `proxy mode` setting of MANUAL_PROXY with no servers specified + Settings().setValue('advanced/proxy mode', ProxyMode.MANUAL_PROXY) + Settings().setValue('advanced/proxy http', None) + Settings().setValue('advanced/proxy https', None) + Settings().setValue('advanced/proxy username', 'user') + Settings().setValue('advanced/proxy password', 'pass') + + # WHEN: Calling `get_proxy_settings` + result = get_proxy_settings() + + # THEN: The returned value should be the proxy servers set to None + assert result == {'http': None, 'https': None}