From d0a996c1ca08d75b76b9492b8f3734047990249d Mon Sep 17 00:00:00 2001 From: Shivansh Singh <89853707+shivansh02@users.noreply.github.com> Date: Fri, 19 Jul 2024 19:51:58 +0530 Subject: [PATCH] Schedule tab refactoring. By @shivansh02 (#2035) --- src/vorta/assets/UI/logpage.ui | 75 ++ src/vorta/assets/UI/networkspage.ui | 62 ++ src/vorta/assets/UI/schedulepage.ui | 597 ++++++++++++++++ src/vorta/assets/UI/scheduletab.ui | 862 +---------------------- src/vorta/assets/UI/shellcommandspage.ui | 191 +++++ src/vorta/views/log_page.py | 59 ++ src/vorta/views/main_window.py | 14 +- src/vorta/views/networks_page.py | 60 ++ src/vorta/views/schedule_page.py | 137 ++++ src/vorta/views/schedule_tab.py | 258 +------ src/vorta/views/shell_commands_page.py | 45 ++ tests/integration/test_repo.py | 2 +- tests/unit/test_repo.py | 2 +- tests/unit/test_schedule.py | 2 +- 14 files changed, 1276 insertions(+), 1090 deletions(-) create mode 100644 src/vorta/assets/UI/logpage.ui create mode 100644 src/vorta/assets/UI/networkspage.ui create mode 100644 src/vorta/assets/UI/schedulepage.ui create mode 100644 src/vorta/assets/UI/shellcommandspage.ui create mode 100644 src/vorta/views/log_page.py create mode 100644 src/vorta/views/networks_page.py create mode 100644 src/vorta/views/schedule_page.py create mode 100644 src/vorta/views/shell_commands_page.py diff --git a/src/vorta/assets/UI/logpage.ui b/src/vorta/assets/UI/logpage.ui new file mode 100644 index 00000000..f1690ce3 --- /dev/null +++ b/src/vorta/assets/UI/logpage.ui @@ -0,0 +1,75 @@ + + + LogPage + + + + 0 + 0 + 687 + 384 + + + + + + + + 11 + false + + + + false + + + true + + + false + + + + Time + + + + + Category + + + + + Subcommand + + + + + Repository + + + + + Returncode + + + + + + + + <html><head/><body><p><a href="file:///"><span style=" text-decoration: underline; color:#0984e3;">View the logs</span></a></p></body></html> + + + 0 + + + true + + + + + + + + diff --git a/src/vorta/assets/UI/networkspage.ui b/src/vorta/assets/UI/networkspage.ui new file mode 100644 index 00000000..088b6dc6 --- /dev/null +++ b/src/vorta/assets/UI/networkspage.ui @@ -0,0 +1,62 @@ + + + NetworksPage + + + + 0 + 0 + 687 + 384 + + + + Networks + + + + 12 + + + + + 4 + + + + + true + + + Allowed Networks: + + + + + + + + 0 + 1 + + + + + + + + + + Run backups over metered networks + + + false + + + + + + + + + diff --git a/src/vorta/assets/UI/schedulepage.ui b/src/vorta/assets/UI/schedulepage.ui new file mode 100644 index 00000000..cd334313 --- /dev/null +++ b/src/vorta/assets/UI/schedulepage.ui @@ -0,0 +1,597 @@ + + + SchedulePage + + + + 0 + 0 + 687 + 384 + + + + + false + + + + Schedule + + + + 6 + + + 6 + + + + + + + Qt::AlignHCenter|Qt::AlignTop + + + 8 + + + 4 + + + + + Backup schedule: + + + + + + + Manual only + + + + + + + 4 + + + QLayout::SetMinimumSize + + + + + Backup periodically + + + + + + + + 0 + 0 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Interval: + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 1 + + + + + + + + 1 + + + 740 + + + + + + + + + + + + + + + 4 + + + + + Backup daily + + + + + + + + 0 + 0 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Time: + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 1 + + + + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 1 + 4 + + + + + + + + Missed backups: + + + + + + + + 16777215 + 16777215 + + + + Run missed backups on startup or wakeup + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 1 + 4 + + + + + + + + + + + + Autopruning: + + + + + + + Prune after each backup + + + + + + + Validation: + + + + + + + 4 + + + 4 + + + + + Validate repository data + + + + + + + + 0 + 0 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Interval: + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 1 + + + + + + + + 1 + + + 52 + + + 3 + + + + + + + weeks + + + + + + + + + + + + Compaction: + + + + + + + 4 + + + 4 + + + + + Compact repository + + + + + + + + 0 + 0 + + + + + 4 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Interval: + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 1 + + + + + + + + 1 + + + 52 + + + 3 + + + + + + + weeks + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 1 + 4 + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + true + + + + Next Backup: + + + + + + + None scheduled + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/src/vorta/assets/UI/scheduletab.ui b/src/vorta/assets/UI/scheduletab.ui index 062c0771..3f497572 100644 --- a/src/vorta/assets/UI/scheduletab.ui +++ b/src/vorta/assets/UI/scheduletab.ui @@ -33,7 +33,7 @@ false - + 0 @@ -42,588 +42,12 @@ 384 - - - false - - Schedule - - - 6 - - - 6 - - - - - - - Qt::AlignHCenter|Qt::AlignTop - - - 8 - - - 4 - - - - - Backup schedule: - - - - - - - Manual only - - - - - - - 4 - - - QLayout::SetMinimumSize - - - - - Backup periodically - - - - - - - - 0 - 0 - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - Interval: - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 4 - 1 - - - - - - - - 1 - - - 740 - - - - - - - - - - - - - - - 4 - - - - - Backup daily - - - - - - - - 0 - 0 - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - Time: - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 4 - 1 - - - - - - - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 1 - 4 - - - - - - - - Missed backups: - - - - - - - - 16777215 - 16777215 - - - - Run missed backups on startup or wakeup - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 1 - 4 - - - - - - - - - - - - Autopruning: - - - - - - - Prune after each backup - - - - - - - Validation: - - - - - - - 4 - - - 4 - - - - - Validate repository data - - - - - - - - 0 - 0 - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - Interval: - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 4 - 1 - - - - - - - - 1 - - - 52 - - - 3 - - - - - - - weeks - - - - - - - - - - - - Compaction: - - - - - - - 4 - - - 4 - - - - - Compact repository - - - - - - - - 0 - 0 - - - - - 4 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - Interval: - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 4 - 1 - - - - - - - - 1 - - - 52 - - - 3 - - - - - - - weeks - - - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 1 - 4 - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - true - - - - Next Backup: - - - - - - - None scheduled - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - + - + @@ -636,49 +60,9 @@ Networks - - - 12 - - - - - 4 - - - - - true - - - Allowed Networks: - - - - - - - - 0 - 1 - - - - - - - - - - Run backups over metered networks - - - false - - - + - + @@ -691,67 +75,10 @@ Log - - - - - - 11 - false - - - - false - - - true - - - false - - - - Time - - - - - Category - - - - - Subcommand - - - - - Repository - - - - - Returncode - - - - - - - - <html><head/><body><p><a href="file:///"><span style=" text-decoration: underline; color:#0984e3;">View the logs</span></a></p></body></html> - - - 0 - - - true - - - + - - + + 0 @@ -763,178 +90,9 @@ Shell Commands - - - 12 - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - <html><head/><body><p>Run custom shell commands before and after each backup. The actual backup and post-backup command will only run, if the pre-backup command exits without error (return code 0). Available variables: <span style=" font-family:'Courier';">$repo_url, $profile_name, $profile_slug, $returncode</span></p></body></html> - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - - - - - - - 0 - - - - - - 100 - 0 - - - - Pre-backup: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - font-family:'Courier'; - - - - - - echo "Before backup of $repo_url" - - - - - - - - - 0 - - - - - - 100 - 0 - - - - Post-backup: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - font-family:'Courier'; - - - echo "Backup of $repo_url ended with $returncode." - - - - - - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - <html><head/><body><p>Extra arguments for <span style=" font-style:italic;">borg create</span>. Possible options are listed in the <a href="https://borgbackup.readthedocs.io/en/stable/usage/create.html"><span style=" text-decoration: underline; color:#0984e3;">the borg documentation</span></a>.</p></body></html> - - - true - - - true - - - - - - - - 0 - 0 - - - - font-family:'Courier'; - - - --dry-run --noflags - - - - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - + - + diff --git a/src/vorta/assets/UI/shellcommandspage.ui b/src/vorta/assets/UI/shellcommandspage.ui new file mode 100644 index 00000000..32651c75 --- /dev/null +++ b/src/vorta/assets/UI/shellcommandspage.ui @@ -0,0 +1,191 @@ + + + ShellCommandsPage + + + + 0 + 0 + 687 + 384 + + + + Shell Commands + + + + 12 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + <html><head/><body><p>Run custom shell commands before and after each backup. The actual backup and post-backup command will only run, if the pre-backup command exits without error (return code 0). Available variables: <span style=" font-family:'Courier';">$repo_url, $profile_name, $profile_slug, $returncode</span></p></body></html> + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + 0 + + + + + + 100 + 0 + + + + Pre-backup: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + font-family:'Courier'; + + + + + + echo "Before backup of $repo_url" + + + + + + + + + 0 + + + + + + 100 + 0 + + + + Post-backup: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + font-family:'Courier'; + + + echo "Backup of $repo_url ended with $returncode." + + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + <html><head/><body><p>Extra arguments for <span style=" font-style:italic;">borg create</span>. Possible options are listed in the <a href="https://borgbackup.readthedocs.io/en/stable/usage/create.html"><span style=" text-decoration: underline; color:#0984e3;">the borg documentation</span></a>.</p></body></html> + + + true + + + true + + + + + + + + 0 + 0 + + + + font-family:'Courier'; + + + --dry-run --noflags + + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + diff --git a/src/vorta/views/log_page.py b/src/vorta/views/log_page.py new file mode 100644 index 00000000..d2b2377d --- /dev/null +++ b/src/vorta/views/log_page.py @@ -0,0 +1,59 @@ +from PyQt6 import uic +from PyQt6.QtWidgets import ( + QAbstractItemView, + QHeaderView, + QTableWidgetItem, +) + +from vorta import config +from vorta.store.models import EventLogModel +from vorta.utils import get_asset + +uifile = get_asset('UI/logpage.ui') +LogTableUI, LogTableBase = uic.loadUiType(uifile) + + +class LogTableColumn: + Time = 0 + Category = 1 + Subcommand = 2 + Repository = 3 + ReturnCode = 4 + + +class LogPage(LogTableBase, LogTableUI): + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + self.init_ui() + + def init_ui(self): + self.logPage.setAlternatingRowColors(True) + header = self.logPage.horizontalHeader() + header.setVisible(True) + [header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) for i in range(5)] + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + self.logPage.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.logPage.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + + self.logLink.setText( + f'Click here for complete logs.' + ) + + self.populate_logs() + + def populate_logs(self): + event_logs = [s for s in EventLogModel.select().order_by(EventLogModel.start_time.desc())] + + sorting = self.logPage.isSortingEnabled() + self.logPage.setSortingEnabled(False) + self.logPage.setRowCount(len(event_logs)) + for row, log_line in enumerate(event_logs): + formatted_time = log_line.start_time.strftime('%Y-%m-%d %H:%M') + self.logPage.setItem(row, LogTableColumn.Time, QTableWidgetItem(formatted_time)) + self.logPage.setItem(row, LogTableColumn.Category, QTableWidgetItem(log_line.category)) + self.logPage.setItem(row, LogTableColumn.Subcommand, QTableWidgetItem(log_line.subcommand)) + self.logPage.setItem(row, LogTableColumn.Repository, QTableWidgetItem(log_line.repo_url)) + self.logPage.setItem(row, LogTableColumn.ReturnCode, QTableWidgetItem(str(log_line.returncode))) + self.logPage.setSortingEnabled(sorting) diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index f42e04c4..e7b59041 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -80,7 +80,7 @@ def __init__(self, parent=None): self.tabWidget.setCurrentIndex(0) self.repoTab.repo_changed.connect(self.archiveTab.populate_from_profile) - self.repoTab.repo_changed.connect(self.scheduleTab.populate_from_profile) + self.repoTab.repo_changed.connect(self.scheduleTab.schedulePage.populate_from_profile) self.repoTab.repo_added.connect(self.archiveTab.refresh_archive_list) self.miscTab.refresh_archive.connect(self.archiveTab.populate_from_profile) @@ -110,8 +110,8 @@ def __init__(self, parent=None): # OS-specific startup options: if not get_network_status_monitor().is_network_status_available(): # Hide Wifi-rule section in schedule tab. - self.scheduleTab.wifiListLabel.hide() - self.scheduleTab.wifiListWidget.hide() + self.scheduleTab.networksPage.wifiListLabel.hide() + self.scheduleTab.networksPage.wifiListWidget.hide() self.scheduleTab.page_2.hide() self.scheduleTab.toolBox.removeItem(1) @@ -185,7 +185,7 @@ def profile_selection_changed_action(self, index): self.archiveTab.populate_from_profile() self.repoTab.populate_from_profile() self.sourceTab.populate_from_profile() - self.scheduleTab.populate_from_profile() + self.scheduleTab.schedulePage.populate_from_profile() SettingsModel.update({SettingsModel.str_value: self.current_profile.id}).where( SettingsModel.key == 'previous_profile_id' ).execute() @@ -260,8 +260,8 @@ def profile_imported_event(profile): self.tr('Profile {} imported.').format(profile.name), ) self.repoTab.populate_from_profile() - self.scheduleTab.populate_logs() - self.scheduleTab.populate_wifi() + self.scheduleTab.logPage.populate_logs() + self.scheduleTab.networksPage.populate_wifi() self.miscTab.populate() self.populate_profile_selector() @@ -323,7 +323,7 @@ def backup_started_event(self): def backup_finished_event(self): self.archiveTab.populate_from_profile() self.repoTab.init_repo_stats() - self.scheduleTab.populate_logs() + self.scheduleTab.logPage.populate_logs() if not self.app.jobs_manager.is_worker_running() and ( self.archiveTab.remaining_refresh_archives == 0 or self.archiveTab.remaining_refresh_archives == 1 diff --git a/src/vorta/views/networks_page.py b/src/vorta/views/networks_page.py new file mode 100644 index 00000000..8ecec3e8 --- /dev/null +++ b/src/vorta/views/networks_page.py @@ -0,0 +1,60 @@ +from PyQt6 import uic +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QCheckBox, QLabel, QListWidget, QListWidgetItem + +from vorta.store.models import BackupProfileMixin, WifiSettingModel +from vorta.utils import get_asset, get_sorted_wifis + +uifile = get_asset('UI/networkspage.ui') +NetworksUI, NetworksBase = uic.loadUiType(uifile) + + +class NetworksPage(NetworksBase, NetworksUI, BackupProfileMixin): + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + + self.wifiListLabel: QLabel = self.findChild(QLabel, 'wifiListLabel') + self.meteredNetworksCheckBox: QCheckBox = self.findChild(QCheckBox, 'meteredNetworksCheckBox') + self.wifiListWidget: QListWidget = self.findChild(QListWidget, 'wifiListWidget') + + self.populate_wifi() + self.setup_connections() + + def setup_connections(self): + self.meteredNetworksCheckBox.stateChanged.connect(self.on_metered_networks_state_changed) + self.wifiListWidget.itemChanged.connect(self.save_wifi_item) + + def on_metered_networks_state_changed(self, state): + profile = self.profile() + attr = 'dont_run_on_metered_networks' + new_value = state != Qt.CheckState.Checked + self.save_profile_attr(attr, new_value) + self.meteredNetworksCheckBox.setChecked(False if profile.dont_run_on_metered_networks else True) + + def populate_wifi(self): + self.wifiListWidget.clear() + profile = self.profile() + if profile: + for wifi in get_sorted_wifis(profile): + item = QListWidgetItem() + item.setText(wifi.ssid) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if wifi.allowed: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + self.wifiListWidget.addItem(item) + + def save_wifi_item(self, item): + profile = self.profile() + if profile: + db_item = WifiSettingModel.get(ssid=item.text(), profile=profile.id) + db_item.allowed = item.checkState() == Qt.CheckState.Checked + db_item.save() + + def save_profile_attr(self, attr, new_value): + profile = self.profile() + if profile: + setattr(profile, attr, new_value) + profile.save() diff --git a/src/vorta/views/schedule_page.py b/src/vorta/views/schedule_page.py new file mode 100644 index 00000000..5d875ee8 --- /dev/null +++ b/src/vorta/views/schedule_page.py @@ -0,0 +1,137 @@ +from PyQt6 import QtCore, uic +from PyQt6.QtCore import QDateTime, QLocale +from PyQt6.QtWidgets import QApplication + +from vorta.i18n import get_locale +from vorta.scheduler import ScheduleStatusType +from vorta.store.models import BackupProfileMixin +from vorta.utils import get_asset + +uifile = get_asset('UI/schedulepage.ui') +SchedulePageUI, SchedulePageBase = uic.loadUiType(uifile) + + +class SchedulePage(SchedulePageBase, SchedulePageUI, BackupProfileMixin): + def __init__(self, parent=None): + super().__init__(parent) + self.app = QApplication.instance() + self.setupUi(self) + + self.schedulerRadioMapping = { + 'off': self.scheduleOffRadio, + 'interval': self.scheduleIntervalRadio, + 'fixed': self.scheduleFixedRadio, + } + + self.populate_from_profile() + + self.scheduleIntervalUnit.addItem(self.tr('Minutes'), 'minutes') + self.scheduleIntervalUnit.addItem(self.tr('Hours'), 'hours') + self.scheduleIntervalUnit.addItem(self.tr('Days'), 'days') + self.scheduleIntervalUnit.addItem(self.tr('Weeks'), 'weeks') + + self.framePeriodic.setEnabled(False) + self.frameDaily.setEnabled(False) + self.frameValidation.setEnabled(False) + self.frameCompaction.setEnabled(False) + + self.scheduleIntervalRadio.toggled.connect(self.framePeriodic.setEnabled) + self.scheduleFixedRadio.toggled.connect(self.frameDaily.setEnabled) + self.validationCheckBox.toggled.connect(self.frameValidation.setEnabled) + self.compactionCheckBox.toggled.connect(self.frameCompaction.setEnabled) + + for label, obj in self.schedulerRadioMapping.items(): + obj.clicked.connect(self.on_scheduler_change) + self.scheduleIntervalCount.valueChanged.connect(self.on_scheduler_change) + self.scheduleIntervalUnit.currentIndexChanged.connect(self.on_scheduler_change) + self.scheduleFixedTime.timeChanged.connect(self.on_scheduler_change) + + self.missedBackupsCheckBox.stateChanged.connect( + lambda new_val, attr='schedule_make_up_missed': self.save_profile_attr(attr, new_val) + ) + self.pruneCheckBox.stateChanged.connect(lambda new_val, attr='prune_on': self.save_profile_attr(attr, new_val)) + self.validationCheckBox.stateChanged.connect( + lambda new_val, attr='validation_on': self.save_profile_attr(attr, new_val) + ) + self.validationWeeksCount.valueChanged.connect( + lambda new_val, attr='validation_weeks': self.save_profile_attr(attr, new_val) + ) + self.compactionCheckBox.stateChanged.connect( + lambda new_val, attr='compaction_on': self.save_profile_attr(attr, new_val) + ) + self.compactionWeeksCount.valueChanged.connect( + lambda new_val, attr='compaction_weeks': self.save_profile_attr(attr, new_val) + ) + + self.app.scheduler.schedule_changed.connect(lambda pid: self.draw_next_scheduled_backup()) + + self.app.paletteChanged.connect(lambda p: self.set_icons()) + + def on_scheduler_change(self, _): + profile = self.profile() + for label, obj in self.schedulerRadioMapping.items(): + if obj.isChecked(): + profile.schedule_mode = label + profile.schedule_interval_unit = self.scheduleIntervalUnit.currentData() + profile.schedule_interval_count = self.scheduleIntervalCount.value() + qtime = self.scheduleFixedTime.time() + profile.schedule_fixed_hour, profile.schedule_fixed_minute = ( + qtime.hour(), + qtime.minute(), + ) + profile.save() + + self.app.scheduler.set_timer_for_profile(profile.id) + self.draw_next_scheduled_backup() + + def populate_from_profile(self): + profile = self.profile() + self.schedulerRadioMapping[profile.schedule_mode].setChecked(True) + self.scheduleIntervalUnit.setCurrentIndex(self.scheduleIntervalUnit.findData(profile.schedule_interval_unit)) + self.scheduleIntervalCount.setValue(profile.schedule_interval_count) + self.scheduleFixedTime.setTime(QtCore.QTime(profile.schedule_fixed_hour, profile.schedule_fixed_minute)) + + self.validationCheckBox.setCheckState( + QtCore.Qt.CheckState.Checked if profile.validation_on else QtCore.Qt.CheckState.Unchecked + ) + self.validationWeeksCount.setValue(profile.validation_weeks) + + self.compactionCheckBox.setCheckState( + QtCore.Qt.CheckState.Checked if profile.compaction_on else QtCore.Qt.CheckState.Unchecked + ) + self.compactionWeeksCount.setValue(profile.compaction_weeks) + + self.pruneCheckBox.setCheckState( + QtCore.Qt.CheckState.Checked if profile.prune_on else QtCore.Qt.CheckState.Unchecked + ) + self.missedBackupsCheckBox.setCheckState( + QtCore.Qt.CheckState.Checked if profile.schedule_make_up_missed else QtCore.Qt.CheckState.Unchecked + ) + + self.draw_next_scheduled_backup() + + def draw_next_scheduled_backup(self): + status = self.app.scheduler.next_job_for_profile(self.profile().id) + if status.type in ( + ScheduleStatusType.SCHEDULED, + ScheduleStatusType.TOO_FAR_AHEAD, + ): + time = QDateTime.fromMSecsSinceEpoch(int(status.time.timestamp() * 1000)) + text = get_locale().toString(time, QLocale.FormatType.LongFormat) + elif status.type == ScheduleStatusType.NO_PREVIOUS_BACKUP: + text = self.tr('Run a manual backup first') + else: + text = self.tr('None scheduled') + + self.nextBackupDateTimeLabel.setText(text) + self.nextBackupDateTimeLabel.repaint() + + def save_profile_attr(self, attr, new_value): + profile = self.profile() + setattr(profile, attr, new_value) + profile.save() + + def save_repo_attr(self, attr, new_value): + repo = self.profile().repo + setattr(repo, attr, new_value) + repo.save() diff --git a/src/vorta/views/schedule_tab.py b/src/vorta/views/schedule_tab.py index 2eba60fc..3477a9c4 100644 --- a/src/vorta/views/schedule_tab.py +++ b/src/vorta/views/schedule_tab.py @@ -1,253 +1,55 @@ -from PyQt6 import QtCore, uic -from PyQt6.QtCore import QDateTime, QLocale, Qt -from PyQt6.QtWidgets import ( - QAbstractItemView, - QApplication, - QHeaderView, - QListWidgetItem, - QTableWidgetItem, -) +from PyQt6 import uic +from PyQt6.QtWidgets import QApplication -from vorta import application, config -from vorta.i18n import get_locale -from vorta.scheduler import ScheduleStatusType -from vorta.store.models import BackupProfileMixin, EventLogModel, WifiSettingModel -from vorta.utils import get_asset, get_sorted_wifis +from vorta import application +from vorta.store.models import BackupProfileMixin +from vorta.utils import get_asset +from vorta.views.log_page import LogPage +from vorta.views.networks_page import NetworksPage +from vorta.views.schedule_page import SchedulePage +from vorta.views.shell_commands_page import ShellCommandsPage from vorta.views.utils import get_colored_icon uifile = get_asset('UI/scheduletab.ui') ScheduleUI, ScheduleBase = uic.loadUiType(uifile) -class LogTableColumn: - Time = 0 - Category = 1 - Subcommand = 2 - Repository = 3 - ReturnCode = 4 - - class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin): def __init__(self, parent=None): super().__init__(parent) self.setupUi(parent) self.app: application.VortaApp = QApplication.instance() self.toolBox.setCurrentIndex(0) - - self.schedulerRadioMapping = { - 'off': self.scheduleOffRadio, - 'interval': self.scheduleIntervalRadio, - 'fixed': self.scheduleFixedRadio, - } - - # Set up log table - self.logTableWidget.setAlternatingRowColors(True) - header = self.logTableWidget.horizontalHeader() - self.logLink.setText( - f'Click here for complete logs.' - ) - header.setVisible(True) - [header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents) for i in range(5)] - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - self.logTableWidget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.logTableWidget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - - # Scheduler intervals we know - self.scheduleIntervalUnit.addItem(self.tr('Minutes'), 'minutes') - self.scheduleIntervalUnit.addItem(self.tr('Hours'), 'hours') - self.scheduleIntervalUnit.addItem(self.tr('Days'), 'days') - self.scheduleIntervalUnit.addItem(self.tr('Weeks'), 'weeks') - - # Enable/Disable entries on button state changed - self.framePeriodic.setEnabled(False) - self.frameDaily.setEnabled(False) - self.frameValidation.setEnabled(False) - self.frameCompaction.setEnabled(False) - - self.scheduleIntervalRadio.toggled.connect(self.framePeriodic.setEnabled) - self.scheduleFixedRadio.toggled.connect(self.frameDaily.setEnabled) - self.validationCheckBox.toggled.connect(self.frameValidation.setEnabled) - self.compactionCheckBox.toggled.connect(self.frameCompaction.setEnabled) - - # POPULATE with data - self.populate_from_profile() self.set_icons() + self.init_log_page() + self.init_shell_commands_page() + self.init_networks_page() + self.init_schedule_page() - # Connect events - self.app.backup_finished_event.connect(self.populate_logs) + self.app.backup_finished_event.connect(self.logPage.populate_logs) - # Scheduler events - for label, obj in self.schedulerRadioMapping.items(): - obj.clicked.connect(self.on_scheduler_change) - self.scheduleIntervalCount.valueChanged.connect(self.on_scheduler_change) - self.scheduleIntervalUnit.currentIndexChanged.connect(self.on_scheduler_change) - self.scheduleFixedTime.timeChanged.connect(self.on_scheduler_change) + def init_log_page(self): + self.logPage = LogPage(self) + self.logLayout.addWidget(self.logPage) + self.logPage.show() - # Network and shell commands events - self.meteredNetworksCheckBox.stateChanged.connect( - lambda new_val, attr='dont_run_on_metered_networks': self.save_profile_attr(attr, not new_val) - ) - self.postBackupCmdLineEdit.textEdited.connect( - lambda new_val, attr='post_backup_cmd': self.save_profile_attr(attr, new_val) - ) - self.preBackupCmdLineEdit.textEdited.connect( - lambda new_val, attr='pre_backup_cmd': self.save_profile_attr(attr, new_val) - ) - self.createCmdLineEdit.textEdited.connect( - lambda new_val, attr='create_backup_cmd': self.save_repo_attr(attr, new_val) - ) - self.missedBackupsCheckBox.stateChanged.connect( - lambda new_val, attr='schedule_make_up_missed': self.save_profile_attr(attr, new_val) - ) - self.pruneCheckBox.stateChanged.connect(lambda new_val, attr='prune_on': self.save_profile_attr(attr, new_val)) - self.validationCheckBox.stateChanged.connect( - lambda new_val, attr='validation_on': self.save_profile_attr(attr, new_val) - ) - self.validationWeeksCount.valueChanged.connect( - lambda new_val, attr='validation_weeks': self.save_profile_attr(attr, new_val) - ) - self.compactionCheckBox.stateChanged.connect( - lambda new_val, attr='compaction_on': self.save_profile_attr(attr, new_val) - ) - self.compactionWeeksCount.valueChanged.connect( - lambda new_val, attr='compaction_weeks': self.save_profile_attr(attr, new_val) - ) + def init_shell_commands_page(self): + self.shellCommandsPage = ShellCommandsPage(self) + self.shellCommandsLayout.addWidget(self.shellCommandsPage) + self.shellCommandsPage.show() - # Connect to schedule update - self.app.scheduler.schedule_changed.connect(lambda pid: self.draw_next_scheduled_backup()) + def init_networks_page(self): + self.networksPage = NetworksPage(self) + self.networksLayout.addWidget(self.networksPage) + self.networksPage.show() - # Connect to palette change - self.app.paletteChanged.connect(lambda p: self.set_icons()) - - def on_scheduler_change(self, _): - profile = self.profile() - # Save scheduler settings, apply new scheduler and display next task for profile. - for label, obj in self.schedulerRadioMapping.items(): - if obj.isChecked(): - profile.schedule_mode = label - profile.schedule_interval_unit = self.scheduleIntervalUnit.currentData() - profile.schedule_interval_count = self.scheduleIntervalCount.value() - qtime = self.scheduleFixedTime.time() - profile.schedule_fixed_hour, profile.schedule_fixed_minute = ( - qtime.hour(), - qtime.minute(), - ) - profile.save() - - self.app.scheduler.set_timer_for_profile(profile.id) - self.draw_next_scheduled_backup() + def init_schedule_page(self): + self.schedulePage = SchedulePage(self) + self.scheduleLayout.addWidget(self.schedulePage) + self.schedulePage.show() def set_icons(self): self.toolBox.setItemIcon(0, get_colored_icon('clock-o')) self.toolBox.setItemIcon(1, get_colored_icon('wifi')) self.toolBox.setItemIcon(2, get_colored_icon('tasks')) self.toolBox.setItemIcon(3, get_colored_icon('terminal')) - - def populate_from_profile(self): - """Populate current view with data from selected profile.""" - profile = self.profile() - self.schedulerRadioMapping[profile.schedule_mode].setChecked(True) - - # Set interval scheduler options - self.scheduleIntervalUnit.setCurrentIndex(self.scheduleIntervalUnit.findData(profile.schedule_interval_unit)) - self.scheduleIntervalCount.setValue(profile.schedule_interval_count) - - # Set fixed daily time scheduler options - self.scheduleFixedTime.setTime(QtCore.QTime(profile.schedule_fixed_hour, profile.schedule_fixed_minute)) - - # Set borg-check options - self.validationCheckBox.setCheckState( - QtCore.Qt.CheckState.Checked if profile.validation_on else QtCore.Qt.CheckState.Unchecked - ) - self.validationWeeksCount.setValue(profile.validation_weeks) - - # set borg compact options - self.compactionCheckBox.setCheckState( - QtCore.Qt.CheckState.Checked if profile.compaction_on else QtCore.Qt.CheckState.Unchecked - ) - self.compactionWeeksCount.setValue(profile.compaction_weeks) - - # Other checkbox options - self.pruneCheckBox.setCheckState( - QtCore.Qt.CheckState.Checked if profile.prune_on else QtCore.Qt.CheckState.Unchecked - ) - self.missedBackupsCheckBox.setCheckState( - QtCore.Qt.CheckState.Checked if profile.schedule_make_up_missed else QtCore.Qt.CheckState.Unchecked - ) - self.meteredNetworksCheckBox.setChecked(False if profile.dont_run_on_metered_networks else True) - - self.preBackupCmdLineEdit.setText(profile.pre_backup_cmd) - self.postBackupCmdLineEdit.setText(profile.post_backup_cmd) - if profile.repo: - self.createCmdLineEdit.setText(profile.repo.create_backup_cmd) - self.createCmdLineEdit.setEnabled(True) - else: - self.createCmdLineEdit.setEnabled(False) - - self.populate_wifi() - self.populate_logs() - self.draw_next_scheduled_backup() - - def draw_next_scheduled_backup(self): - status = self.app.scheduler.next_job_for_profile(self.profile().id) - if status.type in ( - ScheduleStatusType.SCHEDULED, - ScheduleStatusType.TOO_FAR_AHEAD, - ): - time = QDateTime.fromMSecsSinceEpoch(int(status.time.timestamp() * 1000)) - text = get_locale().toString(time, QLocale.FormatType.LongFormat) - elif status.type == ScheduleStatusType.NO_PREVIOUS_BACKUP: - text = self.tr('Run a manual backup first') - else: - text = self.tr('None scheduled') - - self.nextBackupDateTimeLabel.setText(text) - self.nextBackupDateTimeLabel.repaint() - - def populate_wifi(self): - self.wifiListWidget.clear() - for wifi in get_sorted_wifis(self.profile()): - item = QListWidgetItem() - item.setText(wifi.ssid) - item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) - if wifi.allowed: - item.setCheckState(QtCore.Qt.CheckState.Checked) - else: - item.setCheckState(QtCore.Qt.CheckState.Unchecked) - self.wifiListWidget.addItem(item) - self.wifiListWidget.itemChanged.connect(self.save_wifi_item) - - def save_wifi_item(self, item): - db_item = WifiSettingModel.get(ssid=item.text(), profile=self.profile().id) - db_item.allowed = item.checkState() == Qt.CheckState.Checked - db_item.save() - - def save_profile_attr(self, attr, new_value): - profile = self.profile() - setattr(profile, attr, new_value) - profile.save() - - def save_repo_attr(self, attr, new_value): - repo = self.profile().repo - setattr(repo, attr, new_value) - repo.save() - - def populate_logs(self): - event_logs = [s for s in EventLogModel.select().order_by(EventLogModel.start_time.desc())] - - sorting = self.logTableWidget.isSortingEnabled() - self.logTableWidget.setSortingEnabled(False) # disable sorting while modifying the table. - self.logTableWidget.setRowCount(len(event_logs)) # go ahead and set table length and then update the rows - for row, log_line in enumerate(event_logs): - formatted_time = log_line.start_time.strftime('%Y-%m-%d %H:%M') - self.logTableWidget.setItem(row, LogTableColumn.Time, QTableWidgetItem(formatted_time)) - self.logTableWidget.setItem(row, LogTableColumn.Category, QTableWidgetItem(log_line.category)) - self.logTableWidget.setItem(row, LogTableColumn.Subcommand, QTableWidgetItem(log_line.subcommand)) - self.logTableWidget.setItem(row, LogTableColumn.Repository, QTableWidgetItem(log_line.repo_url)) - self.logTableWidget.setItem( - row, - LogTableColumn.ReturnCode, - QTableWidgetItem(str(log_line.returncode)), - ) - self.logTableWidget.setSortingEnabled(sorting) # restore sorting now that modifications are done diff --git a/src/vorta/views/shell_commands_page.py b/src/vorta/views/shell_commands_page.py new file mode 100644 index 00000000..ce468d91 --- /dev/null +++ b/src/vorta/views/shell_commands_page.py @@ -0,0 +1,45 @@ +from PyQt6 import uic +from PyQt6.QtWidgets import QLineEdit, QWidget + +from vorta.store.models import BackupProfileMixin +from vorta.utils import get_asset + + +class ShellCommandsPage(QWidget, BackupProfileMixin): + def __init__(self, parent=None): + super().__init__(parent) + uifile = get_asset('UI/shellcommandspage.ui') + uic.loadUi(uifile, self) + + self.preBackupCmdLineEdit: QLineEdit = self.findChild(QLineEdit, 'preBackupCmdLineEdit') + self.postBackupCmdLineEdit: QLineEdit = self.findChild(QLineEdit, 'postBackupCmdLineEdit') + self.createCmdLineEdit: QLineEdit = self.findChild(QLineEdit, 'createCmdLineEdit') + profile = self.profile() + if profile.repo: + self.createCmdLineEdit.setText(profile.repo.create_backup_cmd) + self.createCmdLineEdit.setEnabled(True) + else: + self.createCmdLineEdit.setEnabled(False) + + self.setup_connections() + + def setup_connections(self): + self.preBackupCmdLineEdit.textEdited.connect( + lambda new_val, attr='pre_backup_cmd': self.save_profile_attr(attr, new_val) + ) + self.postBackupCmdLineEdit.textEdited.connect( + lambda new_val, attr='post_backup_cmd': self.save_profile_attr(attr, new_val) + ) + self.createCmdLineEdit.textEdited.connect( + lambda new_val, attr='create_backup_cmd': self.save_repo_attr(attr, new_val) + ) + + def save_profile_attr(self, attr, new_value): + profile = self.profile() + setattr(profile, attr, new_value) + profile.save() + + def save_repo_attr(self, attr, new_value): + repo = self.profile().repo + setattr(repo, attr, new_value) + repo.save() diff --git a/tests/integration/test_repo.py b/tests/integration/test_repo.py index e4751b91..277aace5 100644 --- a/tests/integration/test_repo.py +++ b/tests/integration/test_repo.py @@ -19,4 +19,4 @@ def test_create(qapp, qtbot, archive_env): assert ArchiveModel.select().count() == 7 assert main.createStartBtn.isEnabled() assert main.archiveTab.archiveTable.rowCount() == 7 - assert main.scheduleTab.logTableWidget.rowCount() == 2 + assert main.scheduleTab.logPage.logPage.rowCount() == 2 diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index e072119d..053834e9 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -251,7 +251,7 @@ def test_create(qapp, borg_json_output, mocker, qtbot): assert RepoModel.get(id=1).unique_size == 15520474 assert main.createStartBtn.isEnabled() assert main.archiveTab.archiveTable.rowCount() == 3 - assert main.scheduleTab.logTableWidget.rowCount() == 1 + assert main.scheduleTab.logPage.logPage.rowCount() == 1 @pytest.mark.parametrize( diff --git a/tests/unit/test_schedule.py b/tests/unit/test_schedule.py index 0222a255..1f030a5a 100644 --- a/tests/unit/test_schedule.py +++ b/tests/unit/test_schedule.py @@ -21,7 +21,7 @@ def clockmock(monkeypatch): def test_schedule_tab(qapp: VortaApp, qtbot, clockmock): main = qapp.main_window - tab = main.scheduleTab + tab = main.scheduleTab.schedulePage # setup time_now = dt(2020, 5, 6, 4, 30)