diff --git a/plinth/modules/backups/api.py b/plinth/modules/backups/api.py index 43b60e7c0..7d3d072fe 100644 --- a/plinth/modules/backups/api.py +++ b/plinth/modules/backups/api.py @@ -25,8 +25,12 @@ TODO: - Implement unit tests. """ +import logging + from plinth import actions, action_utils, module_loader +logger = logging.getLogger(__name__) + def validate(backup): """Validate the backup' information schema.""" @@ -76,6 +80,21 @@ def _validate_service(service): assert service['kind'] in ('config', 'site', 'module') +class BackupError: + """Represent an backup/restore operation error.""" + def __init__(self, error_type, app, hook=None): + """Initialize the error object.""" + self.error_type = error_type + self.app = app + self.hook = hook + + def __eq__(self, other_error): + """Compare to error objects.""" + return (self.error_type == other_error.error_type and + self.app == other_error.app and + self.hook == other_error.hook) + + class Packet: """Information passed to a handlers for backup/restore operations.""" @@ -97,6 +116,7 @@ class Packet: self.root = root self.apps = apps self.label = label + self.errors = [] self.directories = [] self.files = [] @@ -221,6 +241,19 @@ class BackupApp: self.manifest == other_app.manifest and \ self.has_data == other_app.has_data + def run_hook(self, hook, packet): + """Run a hook inside an application.""" + if not hasattr(self.app, hook): + return + + try: + getattr(self.app, hook)(packet) + except Exception as exception: + logger.exception( + 'Error running backup/restore hook for app %s: %s', self.name, + exception) + packet.errors.append(BackupError('hook', self.app, hook=hook)) + def get_all_apps_for_backup(): """Return a list of all applications that can be backed up.""" @@ -411,6 +444,40 @@ def _restore_services(original_state): service_handler.restart() +def _run_hooks(hook, packet): + """Run pre/post operation hooks in applications. + + Using the manifest mechanism, applications will convey to the backups + framework how they needs to be backed up. Using this declarative approach + reduces the burden of implementation on behalf of the applications. + However, not all backup necessities may be satisfied in this manner no + matter how feature rich the framework. So, applications should have the + ability to customize the backup/restore processes suiting to their needs. + + For this, each application may optionally implement methods (hooks) that + will be called during the backup or restore process. If these methods are + named appropriately, the backups API will automatically call the methods + and there is no need to register the methods. + + The following hooks are currently available for implementation: + + - backup_pre(packet): + Called before the backup process starts for the application. + - backup_post(packet): + Called after the backup process has completed for the application. + - restore_pre(packet): + Called before the restore process starts for the application. + - restore_post(packet): + Called after the restore process has completed for the application. + + """ + logger.info('Running %s hooks', hook) + for app in packet.apps: + app.run_hook(hook, packet) + + def _run_operation(handler, packet): """Run handler and pre/post hooks for backup/restore operations.""" + _run_hooks(packet.operation + '_pre', packet) handler(packet) + _run_hooks(packet.operation + '_post', packet) diff --git a/plinth/modules/backups/tests/test_api.py b/plinth/modules/backups/tests/test_api.py index be6e377d6..73ae04bdd 100644 --- a/plinth/modules/backups/tests/test_api.py +++ b/plinth/modules/backups/tests/test_api.py @@ -58,6 +58,27 @@ def _get_backup_app(name): return api.BackupApp(name, MagicMock(backup=_get_test_manifest(name))) +class TestBackupApp(unittest.TestCase): + """Test the BackupApp class.""" + def test_run_hook(self): + """Test running a hook on an application.""" + packet = api.Packet('backup', 'apps', '/', []) + hook = 'testhook_pre' + app = MagicMock() + backup_app = api.BackupApp('app_name', app) + backup_app.run_hook(hook, packet) + app.testhook_pre.assert_has_calls([call(packet)]) + assert not packet.errors + + app.testhook_pre.reset_mock() + app.testhook_pre.side_effect = Exception() + backup_app.run_hook(hook, packet) + self.assertEqual(packet.errors, [api.BackupError('hook', app, hook=hook)]) + + del app.testhook_pre + backup_app.run_hook(hook, packet) + + class TestBackupProcesses(unittest.TestCase): """Test cases for backup processes""" @@ -209,6 +230,21 @@ class TestBackupProcesses(unittest.TestCase): ] run.assert_has_calls(calls) + @staticmethod + def test__run_operation(): + """Test that operation runs handler and app hooks.""" + apps = [_get_backup_app('a'), _get_backup_app('b')] + packet = api.Packet('backup', 'apps', '/', apps) + packet.apps[0].run_hook = MagicMock() + packet.apps[1].run_hook = MagicMock() + handler = MagicMock() + api._run_operation(handler, packet) + handler.assert_has_calls([call(packet)]) + + calls = [call('backup_pre', packet), call('backup_post', packet)] + packet.apps[0].run_hook.assert_has_calls(calls) + packet.apps[1].run_hook.assert_has_calls(calls) + class TestBackupModule(unittest.TestCase): """Tests of the backups django module, like views or forms."""