Future proof downgrades

This commit is contained in:
Daniel Martin 2022-05-10 21:45:54 +00:00 committed by Raoul Snyman
parent a1ab6e26e7
commit 13269b3977
6 changed files with 220 additions and 29 deletions

View File

@ -32,7 +32,7 @@ import sys
import time
from datetime import datetime
from pathlib import Path
from shutil import copytree
from shutil import copytree, move
from traceback import format_exception
from PyQt5 import QtCore, QtGui, QtWebEngineWidgets, QtWidgets # noqa
@ -311,6 +311,71 @@ def set_up_logging(log_path):
print(f'Logging to: {file_path} and level {log.level}')
def backup_if_version_changed(settings):
"""
Check version of settings and the application version and backup if the version is different.
Returns true if a backup was not required or the backup succeeded,
false if backup required but was cancelled or failed.
:param Settings settings: The settings object
:rtype: bool
"""
is_downgrade = get_version()['version'] < settings.value('core/application version')
# No need to backup if version matches and we're not downgrading
if not (settings.version_mismatched() and settings.value('core/has run wizard')) and not is_downgrade:
return True
now = datetime.now()
data_folder_path = AppLocation.get_data_path()
timestamp = time.strftime("%Y%m%d-%H%M%S")
data_folder_backup_path = data_folder_path.with_name(data_folder_path.name + '-' + timestamp)
# Warning if OpenLP is downgrading
if is_downgrade:
close_result = QtWidgets.QMessageBox.warning(
None, translate('OpenLP', 'Downgrade'),
translate('OpenLP', 'OpenLP has found a configuration file created by a newer version of OpenLP. '
'OpenLP will start with a fresh install as downgrading data is not supported. Any existing data '
'will be backed up to:\n\n{data_folder_backup_path}\n\n'
'Do you want to continue?').format(data_folder_backup_path=data_folder_backup_path),
QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No),
QtWidgets.QMessageBox.No)
if close_result == QtWidgets.QMessageBox.No:
# Return false as backup failed.
return False
# Backup the settings
if settings.version_mismatched() or is_downgrade:
settings_back_up_path = data_folder_path / (now.strftime('%Y-%m-%d %H-%M') + '.conf')
log.info(f'Settings are being backed up to {settings_back_up_path}')
if not is_downgrade:
# Inform user of settings backup location
QtWidgets.QMessageBox.information(
None, translate('OpenLP', 'Settings Backup'),
translate('OpenLP', 'Your settings are about to be upgraded. A backup will be created at '
'{settings_back_up_path}').format(settings_back_up_path=settings_back_up_path))
# Backup the settings
try:
settings.export(settings_back_up_path)
except OSError:
QtWidgets.QMessageBox.warning(
None, translate('OpenLP', 'Settings Backup'),
translate('OpenLP', 'Settings back up failed.\n\nOpenLP will attempt to continue.'))
# Backup and remove data folder if downgrading
if is_downgrade:
log.info(f'Data folder being backed up to {data_folder_backup_path}')
try:
# We don't want to use data from newer versions, so rather than a copy, we'll just move/rename
move(data_folder_path, data_folder_backup_path)
except OSError:
log.exception('Failed to backup data for downgrade')
QtWidgets.QMessageBox.critical(None, translate('OpenLP', 'OpenLP Backup'),
translate('OpenLP', 'Backup of the data folder failed during downgrade.'))
return False
# Reset all the settings if we're downgrading
if is_downgrade:
settings.clear()
settings.upgrade_settings()
return True
def main():
"""
The main function which parses command line options and then runs
@ -359,11 +424,11 @@ def main():
log.info('Running portable')
portable_settings_path = data_path / 'OpenLP.ini'
# Make this our settings file
log.info('INI file: {name}'.format(name=portable_settings_path))
log.info(f'INI file: {portable_settings_path}')
Settings.set_filename(portable_settings_path)
portable_settings = Settings()
# Set our data path
log.info('Data path: {name}'.format(name=data_path))
log.info(f'Data path: {data_path}')
# Point to our data path
portable_settings.setValue('advanced/data path', data_path)
portable_settings.setValue('advanced/is portable', True)
@ -406,24 +471,11 @@ def main():
if app.is_data_path_missing():
server.close_server()
sys.exit()
if settings.can_upgrade():
now = datetime.now()
# Only back up if OpenLP has previously run.
if settings.value('core/has run wizard'):
back_up_path = AppLocation.get_data_path() / (now.strftime('%Y-%m-%d %H-%M') + '.conf')
log.info('Settings about to be upgraded. Existing settings are being backed up to {back_up_path}'
.format(back_up_path=back_up_path))
QtWidgets.QMessageBox.information(
None, translate('OpenLP', 'Settings Upgrade'),
translate('OpenLP', 'Your settings are about to be upgraded. A backup will be created at '
'{back_up_path}').format(back_up_path=back_up_path))
try:
settings.export(back_up_path)
except OSError:
QtWidgets.QMessageBox.warning(
None, translate('OpenLP', 'Settings Upgrade'),
translate('OpenLP', 'Settings back up failed.\n\nContinuing to upgrade.'))
settings.upgrade_settings()
# Do a backup
if not backup_if_version_changed(settings):
# Backup failed, stop before we damage data.
server.close_server()
sys.exit()
# First time checks in settings
if not settings.value('core/has run wizard'):
if not FirstTimeLanguageForm().exec():

View File

@ -653,9 +653,17 @@ class Settings(QtCore.QSettings):
key = self.group() + '/' + key
return Settings.__default_settings__[key]
def can_upgrade(self):
def from_future(self):
"""
Can / should the settings be upgraded
Is the settings version higher then the version required by OpenLP
:rtype: bool
"""
return __version__ < self.value('settings/version')
def version_mismatched(self):
"""
Are the settings a different version as required by OpenLP
:rtype: bool
"""

View File

@ -884,8 +884,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow, LogMixin, RegistryPropert
self.log_info('hook upgrade_plugin_settings')
self.plugin_manager.hook_upgrade_plugin_settings(import_settings)
# If settings are from the future, we can't import.
if import_settings.from_future():
QtWidgets.QMessageBox.critical(self, translate('OpenLP.MainWindow', 'Import settings'),
translate('OpenLP.MainWindow', 'OpenLP cannot import settings '
'from a newer version of OpenLP.\n\n'
'Processing has terminated and '
'no changes have been made.'),
QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok))
return
# Upgrade settings to prepare the import.
if import_settings.can_upgrade():
if import_settings.version_mismatched():
import_settings.upgrade_settings()
# Lets do a basic sanity check. If it contains this string we can assume it was created by OpenLP and so we'll
# load what we can from it, and just silently ignore anything we don't recognise.

View File

@ -237,13 +237,25 @@ def test_upgrade_multiple_one_invalid(mocked_remove, mocked_setValue, mocked_val
assert mocked_contains.call_args_list == [call('multiple/value 1'), call('multiple/value 2')]
def test_can_upgrade():
"""Test the Settings.can_upgrade() method"""
def test_from_future(settings):
"""Test the Settings.from_future() method"""
# GIVEN: A Settings object
settings.setValue('settings/version', 100)
# WHEN: from_future() is called
result = settings.from_future()
# THEN: The result should be true
assert result is True, 'The settings should be detected as a newer version'
def test_version_mismatched():
"""Test the Settings.version_mismatched() method"""
# GIVEN: A Settings object
local_settings = Settings()
# WHEN: can_upgrade() is run
result = local_settings.can_upgrade()
# WHEN: version_mismatched() is run
result = local_settings.version_mismatched()
# THEN: The result should be True
assert result is True, 'The settings should be upgradeable'

View File

@ -19,6 +19,10 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
##########################################################################
import sys
import pytest
from pathlib import Path
from tempfile import mkdtemp
from unittest.mock import MagicMock, patch
@ -27,10 +31,38 @@ from PyQt5 import QtCore, QtWidgets
# Mock QtWebEngineWidgets
sys.modules['PyQt5.QtWebEngineWidgets'] = MagicMock()
from openlp.core.app import parse_options
from openlp.core.app import parse_options, backup_if_version_changed, main as app_main
from openlp.core.common import is_win
@pytest.fixture
def app_main_env():
with patch('openlp.core.app.Settings') as mock_settings, \
patch('openlp.core.app.Registry') as mock_registry, \
patch('openlp.core.app.AppLocation') as mock_apploc, \
patch('openlp.core.app.LanguageManager'), \
patch('openlp.core.app.qInitResources'), \
patch('openlp.core.app.parse_options'), \
patch('openlp.core.app.QtWidgets.QApplication'), \
patch('openlp.core.app.QtWidgets.QMessageBox.warning') as mock_warn, \
patch('openlp.core.app.QtWidgets.QMessageBox.information'), \
patch('openlp.core.app.OpenLP') as mock_openlp, \
patch('openlp.core.app.Server') as mock_server, \
patch('openlp.core.app.sys'):
mock_registry.return_value = MagicMock()
mock_settings.return_value = MagicMock()
openlp_server = MagicMock()
mock_server.return_value = openlp_server
openlp_server.is_another_instance_running.return_value = False
mock_apploc.get_data_path.return_value = Path()
mock_apploc.get_directory.return_value = Path()
mock_warn.return_value = True
openlp_instance = MagicMock()
mock_openlp.return_value = openlp_instance
openlp_instance.is_data_path_missing.return_value = False
yield
def test_parse_options_basic():
"""
Test the parse options process works
@ -269,3 +301,57 @@ def test_backup_on_upgrade(mocked_question, mocked_get_version, qapp, settings):
assert mocked_question.call_count == 1, 'A question should have been asked!'
qapp.splash.hide.assert_called_once_with()
qapp.splash.show.assert_called_once_with()
@patch('openlp.core.app.OpenLP')
@patch('openlp.core.app.sys')
@patch('openlp.core.app.backup_if_version_changed')
def test_main(mock_backup, mock_sys, mock_openlp, app_main_env):
"""
Test the main method performs primary actions
"""
# GIVEN: A mocked openlp instance
openlp_instance = MagicMock()
mock_openlp.return_value = openlp_instance
openlp_instance.is_data_path_missing.return_value = False
mock_backup.return_value = True
# WHEN: the main method is run
app_main()
# THEN: Check the application is run and exited
openlp_instance.run.assert_called_once()
mock_sys.exit.assert_called_once()
@patch('openlp.core.app.QtWidgets.QMessageBox.warning')
@patch('openlp.core.app.get_version')
@patch('openlp.core.app.AppLocation.get_data_path')
@patch('openlp.core.app.move')
def test_main_future_settings(mock_move, mock_get_path, mock_version, mock_warn, app_main_env, settings):
"""
Test the backup_if_version_changed method backs up data if version from the future and user consents
"""
# GIVEN: A mocked openlp instance with mocked future settings
settings.from_future = MagicMock(return_value=True)
settings.version_mismatched = MagicMock(return_value=True)
settings.clear = MagicMock()
settings.setValue('core/application version', '3.0.1')
mock_warn.return_value = QtWidgets.QMessageBox.Yes
MOCKED_VERSION = {
'full': '2.9.3',
'version': '2.9.3',
'build': 'None'
}
mock_version.return_value = MOCKED_VERSION
temp_folder = Path(mkdtemp())
mock_get_path.return_value = temp_folder
# WHEN: the main method is run
result = backup_if_version_changed(settings)
# THEN: Check everything was backed up, the settings were cleared and the warn prompt was shown
assert result is True
mock_move.assert_called_once()
settings.clear.assert_called_once_with()
mock_warn.assert_called_once()

View File

@ -730,3 +730,27 @@ def test_screen_changed_modal_sets_timestamp_before_blocking_on_modal(mocked_war
# the blocking modal is shown.
mocked_warning.assert_called_once()
assert main_window.screen_change_timestamp is None
@patch('openlp.core.ui.mainwindow.QtWidgets.QMessageBox.critical')
@patch('openlp.core.ui.mainwindow.FileDialog')
@patch('openlp.core.ui.mainwindow.shutil')
@patch('openlp.core.ui.mainwindow.Settings')
def test_on_settings_import_item_clicked(mock_settings, mock_shutil, mock_dialog, mock_crit, main_window_reduced):
"""
Check we don't attempt to import incompatible settings from the future
"""
# GIVEN: a
settings_instance = MagicMock()
mock_settings.return_value = settings_instance
mock_dialog.getOpenFileName.return_value = [MagicMock(name='bob'), '']
settings_instance.from_future.return_value = True
Registry().register('plugin_manager', MagicMock())
mock_crit.return_value = True
# WHEN: the function is called
main_window_reduced.on_settings_import_item_clicked()
# THEN: The from_future should have been checked, but code should not have started to copy values
settings_instance.from_future.assert_called_once_with()
settings_instance.value.assert_not_called()