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)