mirror of https://github.com/borgbase/vorta
Only schedule backup after a manual run, improve labels. @real-yfprojects (#1302)
This commit is contained in:
parent
03480112e6
commit
182d3fbe0d
|
@ -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()
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue