diff --git a/src/vorta/application.py b/src/vorta/application.py
index 1943d785..bb8ae0cc 100644
--- a/src/vorta/application.py
+++ b/src/vorta/application.py
@@ -10,7 +10,7 @@ from vorta.borg.borg_thread import BorgThread
from vorta.borg.create import BorgCreateThread
from vorta.borg.version import BorgVersionThread
from vorta.borg.break_lock import BorgBreakThread
-from vorta.config import TEMP_DIR
+from vorta.config import TEMP_DIR, PROFILE_BOOTSTRAP_FILE
from vorta.i18n import init_translations, translate
from vorta.models import BackupProfileModel, SettingsModel, cleanup_db
from vorta.qt_single_application import QtSingleApplication
@@ -19,6 +19,7 @@ from vorta.tray_menu import TrayMenu
from vorta.utils import borg_compat, parse_args
from vorta.views.main_window import MainWindow
from vorta.notifications import VortaNotifications
+from vorta.profile_export import ProfileExport
logger = logging.getLogger(__name__)
@@ -40,7 +41,6 @@ class VortaApp(QtSingleApplication):
backup_progress_event = QtCore.pyqtSignal(str)
def __init__(self, args_raw, single_app=False):
-
super().__init__(APP_ID, args_raw)
args = parse_args()
if self.isRunning():
@@ -61,6 +61,9 @@ class VortaApp(QtSingleApplication):
self.scheduler = VortaScheduler(self)
self.setApplicationName("Vorta")
+ # Import profile from ~/.vorta-init.json or add empty "Default" profile.
+ self.bootstrap_profile()
+
# Prepare tray and main window
self.tray = TrayMenu(self)
self.main_window = MainWindow(self)
@@ -246,3 +249,22 @@ class VortaApp(QtSingleApplication):
return
thread = BorgBreakThread(params['cmd'], params, parent=self)
thread.start()
+
+ def bootstrap_profile(self, bootstrap_file=PROFILE_BOOTSTRAP_FILE):
+ """
+ Make sure there is at least one profile when first starting Vorta.
+ Will either import a profile placed in ~/.vorta-init.json
+ or add an empty "Default" profile.
+ """
+ if bootstrap_file.is_file():
+ profile_export = ProfileExport.from_json(bootstrap_file)
+ profile = profile_export.to_db(overwrite_profile=True, overwrite_settings=True)
+ bootstrap_file.unlink()
+ notifier = VortaNotifications.pick()
+ notifier.deliver(self.tr('Profile import successful!'),
+ self.tr('Profile {} imported.').format(profile.name),
+ level='info')
+ logger.info('Profile {} imported.'.format(profile.name))
+ if BackupProfileModel.select().count() == 0:
+ default_profile = BackupProfileModel(name='Default')
+ default_profile.save()
diff --git a/src/vorta/assets/UI/exportwindow.ui b/src/vorta/assets/UI/exportwindow.ui
new file mode 100644
index 00000000..b4b400e7
--- /dev/null
+++ b/src/vorta/assets/UI/exportwindow.ui
@@ -0,0 +1,54 @@
+
+
+ Dialog
+
+
+
+ 0
+ 0
+ 555
+ 78
+
+
+
+
+ 0
+ 0
+
+
+
+ -
+
+
+ If set, sharing this file with others will disclose your borg passphrase. Use with caution!
+
+
+ Include borg passphrase in export
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Save
+
+
+
+
+
+
+
+
diff --git a/src/vorta/assets/UI/importwindow.ui b/src/vorta/assets/UI/importwindow.ui
new file mode 100644
index 00000000..b548238f
--- /dev/null
+++ b/src/vorta/assets/UI/importwindow.ui
@@ -0,0 +1,84 @@
+
+
+ Dialog
+
+
+
+ 0
+ 0
+ 551
+ 134
+
+
+
+
+ 0
+ 0
+
+
+
+ -
+
+
-
+
+
+ Borg passphrase:
+
+
+
+ -
+
+
+
+
+
+
+
+
+ QLineEdit::Password
+
+
+
+
+
+ false
+
+
+
+
+
+ -
+
+
+ Overwrite existing profile
+
+
+
+ -
+
+
+ Overwrite existing settings
+
+
+ true
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
diff --git a/src/vorta/assets/UI/mainwindow.ui b/src/vorta/assets/UI/mainwindow.ui
index 7c024a52..7dfd02ab 100644
--- a/src/vorta/assets/UI/mainwindow.ui
+++ b/src/vorta/assets/UI/mainwindow.ui
@@ -73,6 +73,9 @@
16
+
+ QToolButton::MenuButtonPopup
+
-
@@ -88,6 +91,16 @@
+ -
+
+
+ Export Profile
+
+
+
+
+
+
-
diff --git a/src/vorta/assets/icons/file-import-solid.svg b/src/vorta/assets/icons/file-import-solid.svg
new file mode 100644
index 00000000..d0c64cbe
--- /dev/null
+++ b/src/vorta/assets/icons/file-import-solid.svg
@@ -0,0 +1 @@
+
diff --git a/src/vorta/config.py b/src/vorta/config.py
index d396f59f..c744f55e 100644
--- a/src/vorta/config.py
+++ b/src/vorta/config.py
@@ -1,4 +1,5 @@
import os
+from pathlib import Path
import appdirs
APP_NAME = 'Vorta'
@@ -9,6 +10,7 @@ SETTINGS_DIR = dirs.user_data_dir
LOG_DIR = dirs.user_log_dir
CACHE_DIR = dirs.user_cache_dir
TEMP_DIR = os.path.join(CACHE_DIR, "tmp")
+PROFILE_BOOTSTRAP_FILE = Path.home() / '.vorta-init.json'
if not os.path.exists(SETTINGS_DIR):
os.makedirs(SETTINGS_DIR)
diff --git a/src/vorta/models.py b/src/vorta/models.py
index 12d7bb2d..8f7781be 100644
--- a/src/vorta/models.py
+++ b/src/vorta/models.py
@@ -290,10 +290,6 @@ def init_db(con=None):
db.create_tables([RepoModel, RepoPassword, BackupProfileModel, SourceFileModel, SettingsModel,
ArchiveModel, WifiSettingModel, EventLogModel, SchemaVersion])
- if BackupProfileModel.select().count() == 0:
- default_profile = BackupProfileModel(name='Default')
- default_profile.save()
-
# Delete old log entries after 3 months.
three_months_ago = datetime.now() - timedelta(days=180)
EventLogModel.delete().where(EventLogModel.start_time < three_months_ago)
diff --git a/src/vorta/profile_export.py b/src/vorta/profile_export.py
new file mode 100644
index 00000000..a138a5a5
--- /dev/null
+++ b/src/vorta/profile_export.py
@@ -0,0 +1,163 @@
+import datetime
+import json
+from json import JSONDecodeError
+
+from playhouse.shortcuts import model_to_dict, dict_to_model
+
+from vorta.keyring.abc import VortaKeyring
+from vorta.models import RepoModel, SourceFileModel, WifiSettingModel, SchemaVersion, \
+ SettingsModel, BackupProfileModel, db, SCHEMA_VERSION, init_db
+
+
+class ProfileExport:
+ def __init__(self, profile_dict):
+ self._profile_dict = profile_dict
+
+ @property
+ def id(self):
+ return self._profile_dict['id']
+
+ @property
+ def name(self):
+ return self._profile_dict['name']
+
+ @property
+ def schema_version(self):
+ return self._profile_dict['SchemaVersion']['version']
+
+ @property
+ def repo_url(self):
+ if 'repo' in self._profile_dict and \
+ type(self._profile_dict['repo']) == dict and 'url' in self._profile_dict['repo']:
+ return self._profile_dict['repo']['url']
+ return None
+
+ @property
+ def repo_password(self):
+ return self._profile_dict['password'] if 'password' in self._profile_dict else None
+
+ @repo_password.setter
+ def repo_password(self, password):
+ self._profile_dict['password'] = password
+
+ @classmethod
+ def from_db(cls, profile, store_password=True, include_settings=True):
+ profile_dict = model_to_dict(profile, exclude=[RepoModel.id]) # Have to retain profile ID
+
+ keyring = VortaKeyring.get_keyring()
+ if store_password:
+ profile_dict['password'] = keyring.get_password('vorta-repo', profile.repo.url)
+
+ # For all below, exclude ids to prevent collisions. DB will automatically reassign ids
+ # Add SourceFileModel
+ profile_dict['SourceFileModel'] = [
+ model_to_dict(
+ source,
+ recurse=False, exclude=[SourceFileModel.id]) for source in SourceFileModel.select().where(
+ SourceFileModel.profile == profile)]
+ # Add SchemaVersion
+ profile_dict['SchemaVersion'] = model_to_dict(SchemaVersion.get(id=1))
+
+ if include_settings:
+ # Add WifiSettingModel
+ profile_dict['WifiSettingModel'] = [
+ model_to_dict(
+ wifi, recurse=False) for wifi in WifiSettingModel.select().where(
+ WifiSettingModel.profile == profile.id)]
+ # Add SettingsModel
+ profile_dict['SettingsModel'] = [
+ model_to_dict(s, exclude=[SettingsModel.id]) for s in SettingsModel]
+ return ProfileExport(profile_dict)
+
+ def to_db(self, overwrite_profile=False, overwrite_settings=True):
+ profile_schema = self._profile_dict['SchemaVersion']['version']
+ keyring = VortaKeyring.get_keyring()
+ if SCHEMA_VERSION < profile_schema:
+ raise VersionException()
+ elif SCHEMA_VERSION > profile_schema:
+ # Add model upgrading code here, only needed if not adding columns
+ if profile_schema < 16:
+ for sourcedir in self._profile_dict['SourceFileModel']:
+ sourcedir['dir_files_count'] = -1
+ sourcedir['dir_size'] = -1
+ sourcedir['path_isdir'] = False
+
+ existing_profile = None
+ if overwrite_profile:
+ existing_profile = BackupProfileModel.get_or_none(BackupProfileModel.name == self.name)
+ if existing_profile:
+ self._profile_dict['id'] = existing_profile.id
+ if not overwrite_profile or not existing_profile:
+ # Guarantee uniqueness of ids
+ while BackupProfileModel.get_or_none(BackupProfileModel.id == self.id) is not None:
+ self._profile_dict['id'] += 1
+
+ # Add suffix incase names are the same
+ if BackupProfileModel.get_or_none(BackupProfileModel.name == self.name) is not None:
+ suffix = 1
+ while BackupProfileModel.get_or_none(BackupProfileModel.name == f"{self.name}-{suffix}") is not None:
+ suffix += 1
+ self._profile_dict['name'] = f"{self.name}-{suffix}"
+
+ # Load existing repo or restore it
+ if self._profile_dict['repo']:
+ repo = RepoModel.get_or_none(RepoModel.url == self.repo_url)
+ if repo is None:
+ # Load repo from export
+ repo = dict_to_model(RepoModel, self._profile_dict['repo'])
+ repo.save(force_insert=True)
+ self._profile_dict['repo'] = model_to_dict(repo)
+
+ if self.repo_password:
+ keyring.set_password('vorta-repo', self.repo_url, self.repo_password)
+ del self._profile_dict['password']
+
+ # Delete and recreate the tables to clear them
+ if overwrite_settings:
+ db.drop_tables([SettingsModel, WifiSettingModel])
+ db.create_tables([SettingsModel, WifiSettingModel])
+ SettingsModel.insert_many(self._profile_dict['SettingsModel']).execute()
+ WifiSettingModel.insert_many(self._profile_dict['WifiSettingModel']).execute()
+
+ # Set the profile ids to be match new profile
+ for source in self._profile_dict['SourceFileModel']:
+ source['profile'] = self.id
+ SourceFileModel.insert_many(self._profile_dict['SourceFileModel']).execute()
+
+ # Delete added dictionaries to make it match BackupProfileModel
+ del self._profile_dict['SettingsModel']
+ del self._profile_dict['SourceFileModel']
+ del self._profile_dict['WifiSettingModel']
+ del self._profile_dict['SchemaVersion']
+
+ # dict to profile
+ new_profile = dict_to_model(BackupProfileModel, self._profile_dict)
+ if overwrite_profile and existing_profile:
+ force_insert = False
+ else:
+ force_insert = True
+ new_profile.save(force_insert=force_insert)
+ init_db() # rerun db init code to perform the same operations on the new as as on application boot
+ return new_profile
+
+ @classmethod
+ def from_json(cls, filename):
+ with open(filename, 'r') as file:
+ try:
+ profile_export = ProfileExport(json.loads(file.read()))
+ except JSONDecodeError:
+ return None
+ return profile_export
+
+ def to_json(self):
+ return json.dumps(self._profile_dict, default=self._converter, indent=4)
+
+ @staticmethod
+ def _converter(obj):
+ if isinstance(obj, datetime.datetime):
+ return obj.__str__()
+
+
+class VersionException(Exception):
+ """ For when current_version < export_version. Should only occur if downgrading """
+ pass
diff --git a/src/vorta/views/export_window.py b/src/vorta/views/export_window.py
new file mode 100644
index 00000000..126ae1be
--- /dev/null
+++ b/src/vorta/views/export_window.py
@@ -0,0 +1,80 @@
+import logging
+import os
+from pathlib import Path
+
+from PyQt5 import uic
+from PyQt5.QtWidgets import QFileDialog, QMessageBox
+
+from vorta.keyring.abc import VortaKeyring
+from vorta.models import BackupProfileModel # noqa: F401
+from vorta.utils import get_asset
+from ..notifications import VortaNotifications
+from ..profile_export import ProfileExport
+
+uifile_import = get_asset('UI/exportwindow.ui')
+ExportWindowUI, ExportWindowBase = uic.loadUiType(uifile_import)
+uifile_export = get_asset('UI/importwindow.ui')
+ImportWindowUI, ImportWindowBase = uic.loadUiType(uifile_export)
+logger = logging.getLogger(__name__)
+
+
+class ExportWindow(ExportWindowBase, ExportWindowUI):
+ def __init__(self, profile):
+ """
+ @type profile: BackupProfileModel
+ """
+ super().__init__()
+ self.profile = profile
+ self.setupUi(self)
+ self.setWindowTitle(self.tr("Export Profile"))
+ self.buttonBox.accepted.connect(self.run)
+ self.buttonBox.rejected.connect(self.reject)
+
+ self.keyring = VortaKeyring.get_keyring()
+ profile = self.profile
+ if profile.repo is None or self.keyring.get_password('vorta-repo', profile.repo.url) is None:
+ self.storePassword.setCheckState(False)
+ self.storePassword.setDisabled(True)
+ self.storePassword.setToolTip(self.tr('The current profile_export has no password'))
+
+ def get_file(self):
+ """ Get targeted save file with custom extension """
+ default_file = os.path.join(Path.home(), '{}.json'.format(self.profile.name))
+ file_name = QFileDialog.getSaveFileName(
+ self,
+ self.tr("Save profile_export"),
+ default_file,
+ "JSON (*.json)")[0]
+ if file_name:
+ if not file_name.endswith('.json'):
+ file_name += '.json'
+ return file_name
+
+ def on_error(self, error, message):
+ logger.error(error)
+ QMessageBox.critical(None,
+ self.tr("Error while exporting"),
+ message)
+ self.close()
+
+ def run(self):
+ """ Attempt to write profile_export export to file """
+ filename = self.get_file()
+ if not filename:
+ return False
+ profile = self.profile
+ json_string = ProfileExport.from_db(profile, self.storePassword.isChecked()).to_json()
+ try:
+ with open(filename, 'w') as file:
+ file.write(json_string)
+ except (PermissionError, OSError) as e:
+ self.on_error(
+ e,
+ self.tr('The file {} could not be created. Please choose another location.').format(filename)
+ )
+ return False
+ else:
+ notifier = VortaNotifications.pick()
+ notifier.deliver(self.tr('Profile export successful!'),
+ self.tr('Profile export written to {}.').format(filename), level='info')
+ self.close()
diff --git a/src/vorta/views/import_window.py b/src/vorta/views/import_window.py
new file mode 100644
index 00000000..e50c6f29
--- /dev/null
+++ b/src/vorta/views/import_window.py
@@ -0,0 +1,86 @@
+from PyQt5 import QtCore
+from PyQt5.QtWidgets import QMessageBox
+
+from vorta.keyring.abc import VortaKeyring
+from vorta.models import BackupProfileModel, SCHEMA_VERSION
+from vorta.profile_export import VersionException
+from vorta.views.export_window import ImportWindowUI, ImportWindowBase, logger
+
+
+class ImportWindow(ImportWindowUI, ImportWindowBase):
+ profile_imported = QtCore.pyqtSignal(BackupProfileModel)
+
+ def __init__(self, profile_export):
+ """
+ @type profile_export: ProfileExport
+ """
+ super().__init__()
+ self.profile_export = profile_export
+ self.setupUi(self)
+ self.init_repo_password_field(profile_export)
+ self.init_overwrite_profile_checkbox()
+ self.buttonBox.accepted.connect(self.run)
+ self.buttonBox.rejected.connect(self.reject)
+ self.setWindowTitle(self.tr("Import Profile"))
+
+ def init_repo_password_field(self, profile_export):
+ """Try to prefill the borg passphrase either from the export or from the keyring."""
+ self.repoPassword.textChanged[str].connect(self.on_repo_password_changed)
+ if profile_export.repo_password:
+ self.repoPassword.setText(profile_export.repo_password)
+ self.repoPassword.setDisabled(True)
+ self.repoPassword.setToolTip(self.tr('The passphrase has been loaded from the export file'))
+ elif profile_export.repo_url:
+ keyring = VortaKeyring.get_keyring()
+ repo_password = keyring.get_password('vorta-repo', profile_export.repo_url)
+ if repo_password:
+ self.repoPassword.setText(repo_password)
+ self.repoPassword.setDisabled(True)
+ self.repoPassword.setToolTip(self.tr('The passphrase has been loaded from your keyring'))
+
+ def init_overwrite_profile_checkbox(self):
+ """Disable the overwrite profile checkbox if no profile with that name currently exists."""
+ existing_backup_profile = BackupProfileModel.get_or_none(
+ BackupProfileModel.name == self.profile_export.name
+ )
+ if not existing_backup_profile:
+ self.overwriteExistingProfile.setChecked(False)
+ self.overwriteExistingProfile.setEnabled(False)
+ self.overwriteExistingProfile.setToolTip(
+ self.tr(
+ 'A profile with the name {} does not exist. Nothing to overwrite.'.format(self.profile_export.name)
+ )
+ )
+
+ def on_repo_password_changed(self, password):
+ self.profile_export.repo_password = password
+
+ def on_error(self, error, message):
+ logger.error(error)
+ QMessageBox.critical(None,
+ self.tr("Error while importing"),
+ message)
+ self.close()
+
+ def run(self):
+ """ Attempt to read a profile export and import it """
+ try:
+ new_profile = self.profile_export.to_db(
+ overwrite_profile=self.overwriteExistingProfile.isChecked(),
+ overwrite_settings=self.overwriteExistingSettings.isChecked()
+ )
+ except AttributeError as e:
+ # Runs when model upgrading code in json_to_profile incomplete
+ schema_message = "Current schema: {0}\n Profile export schema: {1}".format(
+ SCHEMA_VERSION, self.profile_export.schema_version)
+ self.on_error(e, self.tr("Schema upgrade failure, file a bug report with the link in the Misc tab "
+ "with the following error: \n {0} \n {1}").format(str(e), schema_message))
+ except VersionException as e:
+ self.on_error(e, self.tr("Newer profile_export export files cannot be used on older versions."))
+ except PermissionError as e:
+ self.on_error(e, self.tr("Cannot read profile_export export file due to permission error."))
+ except FileNotFoundError as e:
+ self.on_error(e, self.tr("Profile export file not found."))
+ else:
+ self.profile_imported.emit(new_profile)
+ self.close()
diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py
index 521c7173..aecaca59 100644
--- a/src/vorta/views/main_window.py
+++ b/src/vorta/views/main_window.py
@@ -1,20 +1,26 @@
+from pathlib import Path
+
from PyQt5 import QtCore, uic
from PyQt5.QtCore import QPoint
from PyQt5.QtGui import QKeySequence
-from PyQt5.QtWidgets import QShortcut, QMessageBox, QCheckBox, QToolTip
+from PyQt5.QtWidgets import QShortcut, QMessageBox, QCheckBox, QMenu, QToolTip, QFileDialog
from vorta.borg.borg_thread import BorgThread
from vorta.models import BackupProfileModel, SettingsModel
from vorta.utils import borg_compat, get_asset, is_system_tray_available, get_network_status_monitor
from vorta.views.partials.loading_button import LoadingButton
from vorta.views.utils import get_colored_icon
+from vorta.profile_export import ProfileExport
from .archive_tab import ArchiveTab
+from .export_window import ExportWindow
+from .import_window import ImportWindow
from .misc_tab import MiscTab
from .profile_add_edit_dialog import AddProfileWindow, EditProfileWindow
from .repo_tab import RepoTab
from .schedule_tab import ScheduleTab
from .source_tab import SourceTab
+
uifile = get_asset('UI/mainwindow.ui')
MainWindowUI, MainWindowBase = uic.loadUiType(uifile)
@@ -69,13 +75,14 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.app.backup_cancelled_event.connect(self.backup_cancelled_event)
# Init profile list
- for profile in BackupProfileModel.select().order_by(BackupProfileModel.name):
- self.profileSelector.addItem(profile.name, profile.id)
- current_profile_index = self.profileSelector.findData(self.current_profile.id)
- self.profileSelector.setCurrentIndex(current_profile_index)
+ self.populate_profile_selector()
self.profileSelector.currentIndexChanged.connect(self.profile_select_action)
self.profileRenameButton.clicked.connect(self.profile_rename_action)
+ self.profileExportButton.clicked.connect(self.profile_export_action)
self.profileDeleteButton.clicked.connect(self.profile_delete_action)
+ profile_add_menu = QMenu()
+ profile_add_menu.addAction(self.tr('Import from file...'), self.profile_import_action)
+ self.profileAddButton.setMenu(profile_add_menu)
self.profileAddButton.clicked.connect(self.profile_add_action)
# OS-specific startup options:
@@ -100,6 +107,7 @@ class MainWindow(MainWindowBase, MainWindowUI):
def set_icons(self):
self.profileAddButton.setIcon(get_colored_icon('plus'))
self.profileRenameButton.setIcon(get_colored_icon('edit'))
+ self.profileExportButton.setIcon(get_colored_icon('file-import-solid'))
self.profileDeleteButton.setIcon(get_colored_icon('trash'))
def set_progress(self, text=''):
@@ -120,8 +128,18 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.cancelButton.setEnabled(not create_enabled)
self.cancelButton.repaint()
+ def populate_profile_selector(self):
+ self.profileSelector.clear()
+ for profile in BackupProfileModel.select().order_by(BackupProfileModel.name):
+ self.profileSelector.addItem(profile.name, profile.id)
+ current_profile_index = self.profileSelector.findData(self.current_profile.id)
+ self.profileSelector.setCurrentIndex(current_profile_index)
+
def profile_select_action(self, index):
- self.current_profile = BackupProfileModel.get(id=self.profileSelector.currentData())
+ backup_profile_id = self.profileSelector.currentData()
+ if not backup_profile_id:
+ return
+ self.current_profile = BackupProfileModel.get(id=backup_profile_id)
self.archiveTab.populate_from_profile()
self.repoTab.populate_from_profile()
self.sourceTab.populate_from_profile()
@@ -168,6 +186,49 @@ class MainWindow(MainWindowBase, MainWindowUI):
window.profile_changed.connect(self.profile_add_edit_result)
window.rejected.connect(lambda: self.profileSelector.setCurrentIndex(self.profileSelector.currentIndex()))
+ def profile_export_action(self):
+ """
+ React to pressing "Export Profile" button and save current
+ profile as .json file.
+ """
+ window = ExportWindow(profile=self.current_profile.refresh())
+ self.window = window
+ window.setParent(self, QtCore.Qt.Sheet)
+ window.show()
+
+ def profile_import_action(self):
+ """
+ React to "Import Profile". Ask to select a .json file and import it as
+ new profile.
+ """
+ def profile_imported_event(profile):
+ QMessageBox.information(None,
+ self.tr('Profile import successful!'),
+ self.tr('Profile {} imported.').format(profile.name))
+ self.repoTab.populate_repositories()
+ self.scheduleTab.populate_logs()
+ self.scheduleTab.populate_wifi()
+ self.miscTab.populate()
+ self.populate_profile_selector()
+
+ filename = QFileDialog.getOpenFileName(
+ self,
+ self.tr("Load profile"),
+ str(Path.home()),
+ self.tr("JSON (*.json);;All files (*)"))[0]
+ if filename:
+ profile_export = ProfileExport.from_json(filename)
+ if profile_export is None:
+ QMessageBox.critical(None,
+ self.tr('Error'),
+ self.tr('This file does not contain valid JSON.'))
+ return
+ window = ImportWindow(profile_export=profile_export)
+ self.window = window
+ window.setParent(self, QtCore.Qt.Sheet)
+ window.profile_imported.connect(profile_imported_event)
+ window.show()
+
def profile_add_edit_result(self, profile_name, profile_id):
# Profile is renamed
if self.profileSelector.currentData() == profile_id:
@@ -196,11 +257,11 @@ class MainWindow(MainWindowBase, MainWindowUI):
def closeEvent(self, event):
# Save window state in SettingsModel
- SettingsModel.update({SettingsModel.str_value: str(self.width())})\
- .where(SettingsModel.key == 'previous_window_width')\
+ SettingsModel.update({SettingsModel.str_value: str(self.width())}) \
+ .where(SettingsModel.key == 'previous_window_width') \
.execute()
- SettingsModel.update({SettingsModel.str_value: str(self.height())})\
- .where(SettingsModel.key == 'previous_window_height')\
+ SettingsModel.update({SettingsModel.str_value: str(self.height())}) \
+ .where(SettingsModel.key == 'previous_window_height') \
.execute()
if not is_system_tray_available():
diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py
index e943b35f..aa395ad1 100644
--- a/src/vorta/views/misc_tab.py
+++ b/src/vorta/views/misc_tab.py
@@ -1,12 +1,12 @@
from PyQt5 import uic
from PyQt5.QtWidgets import QCheckBox
-from vorta.i18n import translate
-from vorta.utils import get_asset
-from vorta.autostart import open_app_at_startup
-from vorta.models import SettingsModel, BackupProfileMixin, get_misc_settings
from vorta._version import __version__
+from vorta.autostart import open_app_at_startup
from vorta.config import LOG_DIR
+from vorta.i18n import translate
+from vorta.models import SettingsModel, BackupProfileMixin, get_misc_settings
+from vorta.utils import get_asset
uifile = get_asset('UI/misctab.ui')
MiscTabUI, MiscTabBase = uic.loadUiType(uifile)
@@ -21,6 +21,15 @@ class MiscTab(MiscTabBase, MiscTabUI, BackupProfileMixin):
self.logLink.setText(f'Log')
+ self.populate()
+
+ def populate(self):
+ # clear layout
+ while self.checkboxLayout.count():
+ child = self.checkboxLayout.takeAt(0)
+ if child.widget():
+ child.widget().deleteLater()
+ # dynamically add widgets for settings
for setting in SettingsModel.select().where(SettingsModel.type == 'checkbox'):
x = filter(lambda s: s['key'] == setting.key, get_misc_settings())
if not list(x): # Skip settings that aren't specified in vorta.models.
diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py
index 35aca6e9..86daa238 100644
--- a/src/vorta/views/repo_tab.py
+++ b/src/vorta/views/repo_tab.py
@@ -1,11 +1,12 @@
import os
+
from PyQt5 import uic, QtCore
from PyQt5.QtWidgets import QApplication, QMessageBox
from vorta.models import RepoModel, ArchiveModel, BackupProfileMixin
from vorta.utils import pretty_bytes, get_private_keys, get_asset, borg_compat
-from .ssh_dialog import SSHAddWindow
from .repo_add_dialog import AddRepoWindow, ExistingRepoWindow
+from .ssh_dialog import SSHAddWindow
from .utils import get_colored_icon
uifile = get_asset('UI/repotab.ui')
@@ -25,8 +26,7 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
self.repoSelector.addItem(self.tr('+ Initialize New Repository'), 'new')
self.repoSelector.addItem(self.tr('+ Add Existing Repository'), 'existing')
self.repoSelector.insertSeparator(3)
- self.set_repos()
- self.repoSelector.currentIndexChanged.connect(self.repo_select_action)
+ self.populate_repositories()
self.repoRemoveToolbutton.clicked.connect(self.repo_unlink_action)
self.copyURLbutton.clicked.connect(self.copy_URL_action)
@@ -71,6 +71,15 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
for repo in RepoModel.select():
self.repoSelector.addItem(repo.url, repo.id)
+ def populate_repositories(self):
+ try:
+ self.repoSelector.currentIndexChanged.disconnect(self.repo_select_action)
+ except TypeError: # raised when signal is not connected
+ pass
+ self.set_repos()
+ self.populate_from_profile()
+ self.repoSelector.currentIndexChanged.connect(self.repo_select_action)
+
def populate_from_profile(self):
profile = self.profile()
if profile.repo:
diff --git a/tests/conftest.py b/tests/conftest.py
index 43b50505..2f39e4f1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,6 +6,7 @@ from datetime import datetime as dt
from unittest.mock import MagicMock
import vorta
+import vorta.application
from vorta.models import (RepoModel, RepoPassword, BackupProfileModel, SourceFileModel,
SettingsModel, ArchiveModel, WifiSettingModel, EventLogModel, SchemaVersion)
from vorta.views.main_window import MainWindow
@@ -44,14 +45,16 @@ def init_db(qapp, qtbot, tmpdir_factory):
mock_db = peewee.SqliteDatabase(str(tmp_db), pragmas={'journal_mode': 'wal', })
vorta.models.init_db(mock_db)
+ default_profile = BackupProfileModel(name='Default')
+ default_profile.save()
+
new_repo = RepoModel(url='i0fi93@i593.repo.borgbase.com:repo')
new_repo.encryption = 'none'
new_repo.save()
- profile = BackupProfileModel.get(id=1)
- profile.repo = new_repo.id
- profile.dont_run_on_metered_networks = False
- profile.save()
+ default_profile.repo = new_repo.id
+ default_profile.dont_run_on_metered_networks = False
+ default_profile.save()
test_archive = ArchiveModel(snapshot_id='99999', name='test-archive', time=dt(2000, 1, 1, 0, 0), repo=1)
test_archive.save()
@@ -59,7 +62,8 @@ def init_db(qapp, qtbot, tmpdir_factory):
test_archive1 = ArchiveModel(snapshot_id='99998', name='test-archive1', time=dt(2000, 1, 1, 0, 0), repo=1)
test_archive1.save()
- source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True)
+ source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18,
+ path_isdir=True)
source_dir.save()
qapp.main_window.deleteLater()
diff --git a/tests/profile_exports/invalid_no_json.json b/tests/profile_exports/invalid_no_json.json
new file mode 100644
index 00000000..ede72e79
--- /dev/null
+++ b/tests/profile_exports/invalid_no_json.json
@@ -0,0 +1 @@
+this is not json
diff --git a/tests/profile_exports/valid.json b/tests/profile_exports/valid.json
new file mode 100644
index 00000000..8e2a9176
--- /dev/null
+++ b/tests/profile_exports/valid.json
@@ -0,0 +1,96 @@
+{
+ "id": 1,
+ "name": "Test Profile Restoration",
+ "added_at": "2020-09-10 01:09:03.402268",
+ "repo": {
+ "url": "/tmp/asdf",
+ "added_at": "2020-09-10 21:48:21.427080",
+ "encryption": "repokey-blake2",
+ "unique_size": null,
+ "unique_csize": null,
+ "total_size": null,
+ "total_unique_chunks": null,
+ "extra_borg_arguments": ""
+ },
+ "ssh_key": null,
+ "compression": "zstd,8",
+ "exclude_patterns": null,
+ "exclude_if_present": ".nobackup",
+ "schedule_mode": "off",
+ "schedule_interval_hours": 1,
+ "schedule_interval_minutes": 26,
+ "schedule_fixed_hour": 16,
+ "schedule_fixed_minute": 0,
+ "validation_on": true,
+ "validation_weeks": 3,
+ "prune_on": true,
+ "prune_hour": 2,
+ "prune_day": 7,
+ "prune_week": 4,
+ "prune_month": 6,
+ "prune_year": 2,
+ "prune_keep_within": "10H",
+ "new_archive_name": "{hostname}-{profile_slug}-{now:%Y-%m-%dT%H:%M:%S}",
+ "prune_prefix": "{hostname}-{profile_slug}-",
+ "pre_backup_cmd": "",
+ "password": "Tr0ub4dor&3",
+ "post_backup_cmd": "",
+ "dont_run_on_metered_networks": true,
+ "SourceFileModel": [
+ {
+ "dir": "/this/is/a/test/file",
+ "profile": 1,
+ "added_at": "2020-07-03 03:39:35.226932"
+ },
+ {
+ "dir": "/this/is/another/test/file",
+ "profile": 1,
+ "added_at": "2020-07-03 04:37:02.367233"
+ },
+ {
+ "dir": "/why/are/you/reading/this",
+ "profile": 1,
+ "added_at": "2020-07-03 04:37:05.106150"
+ }
+ ],
+ "WifiSettingModel": [],
+ "SchemaVersion": {
+ "id": 1,
+ "version": 15,
+ "changed_at": "2020-10-19 19:07:35.305493"
+ },
+ "SettingsModel": [
+ {
+ "id": 1,
+ "key": "enable_notifications",
+ "value": true,
+ "str_value": "",
+ "label": "Display notifications when background tasks fail",
+ "type": "checkbox"
+ },
+ {
+ "id": 2,
+ "key": "enable_notifications_success",
+ "value": false,
+ "str_value": "",
+ "label": "Also notify about successful background tasks",
+ "type": "checkbox"
+ },
+ {
+ "id": 3,
+ "key": "autostart",
+ "value": false,
+ "str_value": "",
+ "label": "Automatically start Vorta at login",
+ "type": "checkbox"
+ },
+ {
+ "id": 4,
+ "key": "foreground",
+ "value": true,
+ "str_value": "",
+ "label": "Open main window on startup",
+ "type": "checkbox"
+ }
+ ]
+}
diff --git a/tests/test_import_export.py b/tests/test_import_export.py
new file mode 100644
index 00000000..ecc515da
--- /dev/null
+++ b/tests/test_import_export.py
@@ -0,0 +1,125 @@
+import os
+from pathlib import Path
+
+import pytest
+from PyQt5 import QtCore
+from PyQt5.QtWidgets import QFileDialog, QDialogButtonBox, QMessageBox
+
+from vorta.models import BackupProfileModel, SourceFileModel
+from vorta.views.import_window import ImportWindow
+
+VALID_IMPORT_FILE = Path(__file__).parent / 'profile_exports' / 'valid.json'
+
+
+def test_import_success(qapp, qtbot, rootdir, monkeypatch):
+ monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args: [VALID_IMPORT_FILE])
+ monkeypatch.setattr(QMessageBox, 'information', lambda *args: None)
+
+ main = qapp.main_window
+ main.profile_import_action()
+ import_dialog: ImportWindow = main.window
+ import_dialog.overwriteExistingSettings.setChecked(True)
+
+ qtbot.mouseClick(import_dialog.buttonBox.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
+ qtbot.waitSignal(import_dialog.profile_imported, **pytest._wait_defaults)
+
+ restored_profile = BackupProfileModel.get_or_none(name="Test Profile Restoration")
+ assert restored_profile is not None
+
+ restored_repo = restored_profile.repo
+ assert restored_repo is not None
+ assert len(SourceFileModel.select().where(SourceFileModel.profile == restored_profile)) == 3
+
+
+def test_import_bootstrap_success(qapp, mocker):
+ mocked_unlink = mocker.MagicMock()
+ mocker.patch.object(Path, 'unlink', mocked_unlink)
+ qapp.bootstrap_profile(Path(VALID_IMPORT_FILE))
+
+ assert mocked_unlink.called
+
+ restored_profile = BackupProfileModel.get_or_none(name="Test Profile Restoration")
+ assert restored_profile is not None
+
+ restored_repo = restored_profile.repo
+ assert restored_repo is not None
+
+ assert len(SourceFileModel.select().where(SourceFileModel.profile == restored_profile)) == 3
+ assert BackupProfileModel.select().count() == 2
+
+
+def test_import_fail_not_json(qapp, rootdir, monkeypatch):
+ BAD_FILE = os.path.join(rootdir, 'profile_exports', 'invalid_no_json.json')
+
+ def getOpenFileName(*args, **kwargs):
+ return [BAD_FILE]
+
+ monkeypatch.setattr(
+ QFileDialog, "getOpenFileName", getOpenFileName
+ )
+
+ alert_message = None
+
+ def critical(widget, title, message):
+ nonlocal alert_message
+ alert_message = message
+
+ monkeypatch.setattr(
+ QMessageBox, "critical", critical
+ )
+
+ main = qapp.main_window
+ main.profile_import_action()
+
+ # assert somehow that an alert is shown
+ assert alert_message == 'This file does not contain valid JSON.'
+
+
+def test_export_success(qapp, qtbot, tmpdir, monkeypatch):
+ FILE_PATH = os.path.join(tmpdir, "testresult.json")
+
+ def getSaveFileName(*args, **kwargs):
+ return [FILE_PATH]
+
+ monkeypatch.setattr(
+ QFileDialog, "getSaveFileName", getSaveFileName
+ )
+
+ main = qapp.main_window
+ main.profile_export_action()
+ export_dialog = main.window
+
+ qtbot.mouseClick(export_dialog.buttonBox.button(QDialogButtonBox.Save), QtCore.Qt.LeftButton)
+ qtbot.waitUntil(lambda: os.path.isfile(FILE_PATH))
+
+ assert os.path.isfile(FILE_PATH)
+
+
+def test_export_fail_unwritable(qapp, qtbot, tmpdir, monkeypatch):
+ FILE_PATH = os.path.join(os.path.abspath(os.sep), "testresult.vortabackup")
+
+ def getSaveFileName(*args, **kwargs):
+ return [FILE_PATH]
+
+ monkeypatch.setattr(
+ QFileDialog, "getSaveFileName", getSaveFileName
+ )
+
+ alert_message = None
+
+ def critical(widget, title, message):
+ nonlocal alert_message
+ alert_message = message
+
+ monkeypatch.setattr(
+ QMessageBox, "critical", critical
+ )
+
+ main = qapp.main_window
+ main.profile_export_action()
+ export_dialog = main.window
+
+ qtbot.mouseClick(export_dialog.buttonBox.button(QDialogButtonBox.Save), QtCore.Qt.LeftButton)
+
+ assert 'could not be created' in alert_message
+ assert not os.path.isfile(FILE_PATH)