mirror of https://github.com/borgbase/vorta
Import/export settings (or bootstrap with default config). By @phihos (#955)
This commit is contained in:
parent
0862c827d8
commit
5192bd132c
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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 |
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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()
|
|
@ -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()
|
|
@ -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():
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
this is not 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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)
|
Loading…
Reference in New Issue