Only schedule backup after a manual run, improve labels. @real-yfprojects (#1302)

This commit is contained in:
yfprojects 2022-05-21 11:06:10 +00:00 committed by GitHub
parent 03480112e6
commit 182d3fbe0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 148 additions and 94 deletions

View File

@ -4,7 +4,7 @@ internationalisation (i18n) support code
import logging
import os
from PyQt5.QtCore import QTranslator, QLocale
from PyQt5.QtCore import QLocale, QTranslator
logger = logging.getLogger(__name__)
@ -69,6 +69,12 @@ def init_translations(app):
logger.debug('Loading translation %s for %r.' % ('succeeded' if succeeded else 'failed', ui_langs))
def get_locale():
"""Get the locale used for translation."""
global translator
return QLocale(translator.language())
def translate(*args, **kwargs):
"""
small wrapper around QCoreApplication.translate()

View File

@ -1,12 +1,15 @@
import enum
import logging
import threading
from datetime import datetime as dt
from datetime import timedelta
from typing import Dict, Union
from typing import Dict, NamedTuple, Optional, Union
from PyQt5 import QtCore
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QApplication
from vorta import application
from vorta.borg.check import BorgCheckJob
from vorta.borg.create import BorgCreateJob
from vorta.borg.list_repo import BorgListRepoJob
@ -18,6 +21,18 @@ from vorta.store.models import BackupProfileModel, EventLogModel
logger = logging.getLogger(__name__)
class ScheduleStatusType(enum.Enum):
SCHEDULED = enum.auto() # date provided
UNSCHEDULED = enum.auto() # Unknown
TOO_FAR_AHEAD = enum.auto() # QTimer range exceeded, date provided
NO_PREVIOUS_BACKUP = enum.auto() # run a manual backup first
class ScheduleStatus(NamedTuple):
type: ScheduleStatusType
time: Optional[dt] = None
class VortaScheduler(QtCore.QObject):
#: The schedule for the profile with the given id changed.
@ -27,18 +42,23 @@ class VortaScheduler(QtCore.QObject):
super().__init__()
#: mapping of profiles to timers
self.timers: Dict[int, Dict[str, Union[QtCore.QTimer, dt]]] = dict()
self.timers: Dict[int, Dict[str, Union[
Optional[QTimer], Optional[dt], ScheduleStatusType]]] = dict()
self.app = QApplication.instance()
self.app: application.VortaApp = QApplication.instance()
self.lock = threading.Lock()
# Set additional timer to make sure background tasks stay scheduled.
# E.g. after hibernation
self.qt_timer = QtCore.QTimer()
self.qt_timer = QTimer()
self.qt_timer.timeout.connect(self.reload_all_timers)
self.qt_timer.setInterval(15 * 60 * 1000)
self.qt_timer.start()
# connect signals
self.app.backup_finished_event.connect(
lambda res: self.set_timer_for_profile(res['params']['profile_id']))
def tr(self, *args, **kwargs):
scope = self.__class__.__name__
return translate(scope, *args, **kwargs)
@ -61,17 +81,20 @@ class VortaScheduler(QtCore.QObject):
with self.lock: # Acquire lock
if profile_id in self.timers:
self.remove_job(profile_id) # reset schedule
self.remove_job(profile_id) # reset schedule
if profile.repo is None: # No backups without repo set
logger.debug(
'Nothing scheduled for profile %s because of unset repo.',
profile_id)
# Emit signal so that e.g. the GUI can react to the new schedule
self.schedule_changed.emit(profile_id)
return
if profile.schedule_mode == 'off':
logger.debug('Scheduler for profile %s is disabled.', profile_id)
# Emit signal so that e.g. the GUI can react to the new schedule
self.schedule_changed.emit(profile_id)
return
logger.info('Setting timer for profile %s', profile_id)
@ -92,21 +115,26 @@ class VortaScheduler(QtCore.QObject):
0 <= EventLogModel.returncode <= 1,
).order_by(EventLogModel.end_time.desc()).first()
if last_run_log is None:
logger.info(f"Nothing scheduled for profile {profile_id} " +
"because it would be the first backup " +
"for this profile.")
self.timers[profile_id] = {
'type': ScheduleStatusType.NO_PREVIOUS_BACKUP
}
# Emit signal so that e.g. the GUI can react to the new schedule
self.schedule_changed.emit(profile_id)
return
# calculate next scheduled time
if profile.schedule_mode == 'interval':
if last_run_log is None:
last_time = dt.now()
else:
last_time = last_run_log.end_time
last_time = last_run_log.end_time
interval = {profile.schedule_interval_unit: profile.schedule_interval_count}
next_time = last_time + timedelta(**interval)
elif profile.schedule_mode == 'fixed':
if last_run_log is None:
last_time = dt.now()
else:
last_time = last_run_log.end_time + timedelta(days=1)
last_time = last_run_log.end_time + timedelta(days=1)
next_time = last_time.replace(
hour=profile.schedule_fixed_hour,
@ -155,13 +183,17 @@ class VortaScheduler(QtCore.QObject):
if timer_ms < 2**31 - 1:
logger.debug('Scheduling next run for %s', next_time)
timer = QtCore.QTimer()
timer = QTimer()
timer.setSingleShot(True)
timer.setInterval(int(timer_ms))
timer.timeout.connect(lambda: self.create_backup(profile_id))
timer.start()
self.timers[profile_id] = {'qtt': timer, 'dt': next_time}
self.timers[profile_id] = {
'qtt': timer,
'dt': next_time,
'type': ScheduleStatusType.SCHEDULED
}
else:
# int to big to pass it to qt which expects a c++ int
# wait 15 min for regular reschedule
@ -169,6 +201,11 @@ class VortaScheduler(QtCore.QObject):
f"Couldn't schedule for {next_time} because "
f"timer value {timer_ms} too large.")
self.timers[profile_id] = {
'dt': next_time,
'type': ScheduleStatusType.TOO_FAR_AHEAD
}
# Emit signal so that e.g. the GUI can react to the new schedule
self.schedule_changed.emit(profile_id)
@ -181,6 +218,9 @@ class VortaScheduler(QtCore.QObject):
next_job = now = dt.now()
next_profile = None
for profile_id, timer in self.timers.items():
if timer['type'] != ScheduleStatusType.SCHEDULED:
continue
if next_job == now and timer['dt'] > next_job and timer['qtt'].isActive():
next_job = timer['dt']
next_profile = profile_id
@ -194,12 +234,11 @@ class VortaScheduler(QtCore.QObject):
else:
return self.tr('None scheduled')
def next_job_for_profile(self, profile_id):
def next_job_for_profile(self, profile_id: int) -> ScheduleStatus:
job = self.timers.get(profile_id)
if job is None:
return self.tr('None scheduled')
else:
return job['dt'].strftime('%Y-%m-%d %H:%M')
return ScheduleStatus(ScheduleStatusType.UNSCHEDULED)
return ScheduleStatus(job['type'], time=job.get('dt'))
def create_backup(self, profile_id):
notifier = VortaNotifications.pick()
@ -287,5 +326,8 @@ class VortaScheduler(QtCore.QObject):
def remove_job(self, profile_id):
if profile_id in self.timers:
self.timers[profile_id]['qtt'].stop()
qtimer = self.timers[profile_id].get('qtt')
if qtimer is not None:
qtimer.stop()
del self.timers[profile_id]

View File

@ -1,7 +1,11 @@
from PyQt5 import QtCore, uic
from PyQt5.QtCore import QDateTime, QLocale
from PyQt5.QtWidgets import (QApplication, QHeaderView, QListWidgetItem,
QTableView, QTableWidgetItem)
from vorta import application
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
@ -24,7 +28,7 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(parent)
self.app = QApplication.instance()
self.app: application.VortaApp = QApplication.instance()
self.toolBox.setCurrentIndex(0)
self.schedulerRadioMapping = {
@ -149,9 +153,21 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
self.populate_wifi()
self.populate_logs()
self.draw_next_scheduled_backup()
def draw_next_scheduled_backup(self):
self.nextBackupDateTimeLabel.setText(self.app.scheduler.next_job_for_profile(self.profile().id))
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):

View File

@ -78,6 +78,8 @@ def init_db(qapp, qtbot, tmpdir_factory):
yield
qapp.jobs_manager.cancel_all_jobs()
qapp.backup_finished_event.disconnect()
qapp.scheduler.schedule_changed.disconnect()
qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults)
mock_db.close()

View File

@ -1,17 +1,35 @@
from datetime import date
from datetime import datetime as dt
from datetime import time
from datetime import timedelta
from unittest.mock import MagicMock
import pytest
from PyQt5 import QtCore
import vorta.scheduler
from vorta.application import VortaApp
from vorta.store.models import BackupProfileModel, EventLogModel
def test_schedule_tab(qapp, qtbot):
PROFILE_NAME = 'Default'
@pytest.fixture
def clockmock(monkeypatch):
datetime_mock = MagicMock(wraps=dt)
monkeypatch.setattr(vorta.scheduler, "dt", datetime_mock)
return datetime_mock
def test_schedule_tab(qapp: VortaApp, qtbot, clockmock):
main = qapp.main_window
tab = main.scheduleTab
# setup
time_now = dt(2020, 5, 6, 4, 30)
clockmock.now.return_value = time_now
# Work around
# because already 'deleted' scheduletabs are still connected to the signal
qapp.scheduler.schedule_changed.disconnect()
qapp.scheduler.schedule_changed.connect(lambda *args: tab.draw_next_scheduled_backup())
# Test
@ -31,5 +49,24 @@ def test_schedule_tab(qapp, qtbot):
tab.scheduleFixedRadio.setChecked(True)
tab.scheduleFixedRadio.clicked.emit()
next_backup = dt.combine(date.today(), time(23, 59))
assert tab.nextBackupDateTimeLabel.text() == next_backup.strftime('%Y-%m-%d %H:%M')
assert tab.nextBackupDateTimeLabel.text() == 'Run a manual backup first'
next_backup = time_now.replace(hour=23, minute=59)
last_time = time_now - timedelta(days=2)
# setup model
profile = BackupProfileModel.get(name=PROFILE_NAME)
profile.schedule_make_up_missed = False
profile.save()
event = EventLogModel(subcommand='create',
profile=profile.id,
returncode=0,
category='scheduled',
start_time=last_time,
end_time=last_time)
event.save()
qapp.scheduler.set_timer_for_profile(profile.id)
tab.draw_next_scheduled_backup()
assert next_backup.strftime('%B %Y %H:%M:%S') in tab.nextBackupDateTimeLabel.text()
qapp.scheduler.remove_job(profile.id)

View File

@ -8,7 +8,7 @@ from pytest import mark
import vorta.borg
import vorta.scheduler
from vorta.scheduler import VortaScheduler
from vorta.scheduler import ScheduleStatus, ScheduleStatusType, VortaScheduler
from vorta.store.models import BackupProfileModel, EventLogModel
PROFILE_NAME = 'Default'
@ -50,8 +50,7 @@ def test_scheduler_create_backup(qapp, qtbot, mocker, borg_json_output):
with qtbot.waitSignal(qapp.backup_finished_event, **pytest._wait_defaults):
qapp.scheduler.create_backup(1)
assert EventLogModel.select().where(
EventLogModel.returncode == 0).count() == events_before + 1
assert EventLogModel.select().count() == events_before + 1
def test_manual_mode():
@ -74,7 +73,8 @@ def test_simple_schedule(clockmock):
scheduler = VortaScheduler()
# setup
clockmock.now.return_value = dt(2020, 5, 6, 4, 30)
time = dt(2020, 5, 6, 4, 30)
clockmock.now.return_value = time
profile = BackupProfileModel.get(name=PROFILE_NAME)
profile.schedule_make_up_missed = False
@ -83,17 +83,26 @@ def test_simple_schedule(clockmock):
profile.schedule_interval_count = 3
profile.save()
event = EventLogModel(subcommand='create',
profile=profile.id,
returncode=0,
category='scheduled',
start_time=time,
end_time=time)
event.save()
# test set timer and next_job
scheduler.set_timer_for_profile(profile.id)
assert len(scheduler.timers) == 1
assert scheduler.next_job() == '07:30 ({})'.format(PROFILE_NAME)
assert scheduler.next_job_for_profile(profile.id) == '2020-05-06 07:30'
assert scheduler.next_job_for_profile(profile.id) == ScheduleStatus(
ScheduleStatusType.SCHEDULED, dt(2020, 5, 6, 7, 30))
# test remove_job and next_job
scheduler.remove_job(profile.id)
assert len(scheduler.timers) == 0
assert scheduler.next_job() == 'None scheduled'
assert scheduler.next_job_for_profile(profile.id) == 'None scheduled'
assert scheduler.next_job_for_profile(profile.id) == ScheduleStatus(ScheduleStatusType.UNSCHEDULED)
@mark.parametrize("scheduled", [True, False])
@ -144,35 +153,6 @@ def test_interval(clockmock, passed_time, scheduled, now, unit, count,
assert scheduler.timers[profile.id]['dt'] == time + added_time
@mark.parametrize(
"now, unit, count, added_time",
[
# simple
(td(hours=4, minutes=30), 'hours', 3, td(hours=3)),
# next day
(td(hours=4, minutes=30), 'hours', 20, td(hours=20)),
])
def test_first_interval(clockmock, now, unit, count, added_time):
"""Test scheduling in interval mode without a previous backup."""
# setup
scheduler = VortaScheduler()
time = dt(2020, 5, 4, 0, 0) + now
clockmock.now.return_value = time
profile = BackupProfileModel.get(name=PROFILE_NAME)
profile.schedule_make_up_missed = False
profile.schedule_mode = INTERVAL_SCHEDULE
profile.schedule_interval_unit = unit
profile.schedule_interval_count = count
profile.save()
# run test
scheduler.set_timer_for_profile(profile.id)
assert scheduler.timers[profile.id]['dt'] == time + added_time
@mark.parametrize("scheduled", [True, False])
@mark.parametrize(
"passed_time",
@ -219,32 +199,3 @@ def test_fixed(clockmock, passed_time, scheduled, now, hour, minute):
scheduler.set_timer_for_profile(profile.id)
assert scheduler.timers[profile.id]['dt'] == expected
@mark.parametrize(
"now, hour, minute, added_time",
[
# same day
(td(hours=4, minutes=30), 15, 00, td(hours=10, minutes=30)),
# next day
(td(hours=4, minutes=30), 3, 30, td(hours=23)),
])
def test_first_fixed(clockmock, now, hour, minute, added_time):
"""Test scheduling in fixed mode without a previous backup."""
# setup
scheduler = VortaScheduler()
time = dt(2020, 5, 4, 0, 0) + now
clockmock.now.return_value = time
profile = BackupProfileModel.get(name=PROFILE_NAME)
profile.schedule_make_up_missed = False
profile.schedule_mode = FIXED_SCHEDULE
profile.schedule_fixed_hour = hour
profile.schedule_fixed_minute = minute
profile.save()
# run test
scheduler.set_timer_for_profile(profile.id)
assert scheduler.timers[profile.id]['dt'] == time + added_time