Import/export settings (or bootstrap with default config). By @phihos (#955)

This commit is contained in:
Philipp Hossner 2021-06-05 13:15:38 +02:00 committed by GitHub
parent 0862c827d8
commit 5192bd132c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 834 additions and 28 deletions

View File

@ -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()

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>555</width>
<height>78</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="storePassword">
<property name="toolTip">
<string>If set, sharing this file with others will disclose your borg passphrase. Use with caution!</string>
</property>
<property name="text">
<string>Include borg passphrase in export</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Save</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>551</width>
<height>134</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Borg passphrase:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="repoPassword">
<property name="inputMask">
<string/>
</property>
<property name="text">
<string/>
</property>
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
<property name="placeholderText">
<string/>
</property>
<property name="clearButtonEnabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="overwriteExistingProfile">
<property name="text">
<string>Overwrite existing profile</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="overwriteExistingSettings">
<property name="text">
<string>Overwrite existing settings</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -73,6 +73,9 @@
<height>16</height>
</size>
</property>
<property name="popupMode">
<enum>QToolButton::MenuButtonPopup</enum>
</property>
</widget>
</item>
<item>
@ -88,6 +91,16 @@
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="profileExportButton">
<property name="toolTip">
<string>Export Profile</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="profileDeleteButton">
<property name="toolTip">

View File

@ -0,0 +1 @@
<svg aria-hidden="true" fill="#000000" focusable="false" data-prefix="fas" data-icon="file-import" class="svg-inline--fa fa-file-import fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M16 288c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16h112v-64zm489-183L407.1 7c-4.5-4.5-10.6-7-17-7H384v128h128v-6.1c0-6.3-2.5-12.4-7-16.9zm-153 31V0H152c-13.3 0-24 10.7-24 24v264h128v-65.2c0-14.3 17.3-21.4 27.4-11.3L379 308c6.6 6.7 6.6 17.4 0 24l-95.7 96.4c-10.1 10.1-27.4 3-27.4-11.3V352H128v136c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H376c-13.2 0-24-10.8-24-24z"></path></svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@ -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)

View File

@ -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)

163
src/vorta/profile_export.py Normal file
View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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():

View File

@ -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'<a href="file://{LOG_DIR}"><span style="text-decoration:'
'underline; color:#0984e3;">Log</span></a>')
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.

View File

@ -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:

View File

@ -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()

View File

@ -0,0 +1 @@
this is not json

View File

@ -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"
}
]
}

125
tests/test_import_export.py Normal file
View File

@ -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)