diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py index 4340587fe..50391624f 100644 --- a/plinth/modules/backups/forms.py +++ b/plinth/modules/backups/forms.py @@ -46,6 +46,54 @@ def _get_repository_choices(): return choices +class ScheduleForm(forms.Form): + """Form to edit backups schedule.""" + + enabled = forms.BooleanField( + label=_('Enable scheduled backups'), required=False, + help_text=_('If enabled, a backup is taken every day, every week and ' + 'every month. Older backups are removed.')) + + daily_to_keep = forms.IntegerField( + label=_('Number of daily backups to keep'), required=True, min_value=0, + help_text=_('This many latest backups are kept and the rest are ' + 'removed. A value of "0" disables backups of this type. ' + 'Triggered at specified hour every day.')) + + weekly_to_keep = forms.IntegerField( + label=_('Number of weekly backups to keep'), required=True, + min_value=0, + help_text=_('This many latest backups are kept and the rest are ' + 'removed. A value of "0" disables backups of this type. ' + 'Triggered at specified hour every Sunday.')) + + monthly_to_keep = forms.IntegerField( + label=_('Number of monthly backups to keep'), required=True, + min_value=0, + help_text=_('This many latest backups are kept and the rest are ' + 'removed. A value of "0" disables backups of this type. ' + 'Triggered at specified hour first day of every month.')) + + run_at_hour = forms.IntegerField( + label=_('Hour of the day to trigger backup operation'), required=True, + min_value=0, max_value=23, help_text=_('In 24 hour format.')) + + selected_apps = forms.MultipleChoiceField( + label=_('Included apps'), help_text=_('Apps to include in the backup'), + widget=forms.CheckboxSelectMultiple(attrs={'class': 'has-select-all'})) + + def __init__(self, *args, **kwargs): + """Initialize the form with selectable apps.""" + super().__init__(*args, **kwargs) + components = api.get_all_components_for_backup() + choices = _get_app_choices(components) + self.fields['selected_apps'].choices = choices + self.fields['selected_apps'].initial = [ + choice[0] for choice in choices + if choice[0] not in self.initial.get('unselected_apps', []) + ] + + class CreateArchiveForm(forms.Form): repository = forms.ChoiceField(label=_('Repository')) name = forms.RegexField( diff --git a/plinth/modules/backups/templates/backups_repository.html b/plinth/modules/backups/templates/backups_repository.html index 6dee0d181..e7e3e7ce8 100644 --- a/plinth/modules/backups/templates/backups_repository.html +++ b/plinth/modules/backups/templates/backups_repository.html @@ -23,6 +23,12 @@ {{ repository.name }} + + + {% trans "Schedule" %} + + {% if repository.flags.mountable %} {% if repository.mounted %} diff --git a/plinth/modules/backups/templates/backups_schedule.html b/plinth/modules/backups/templates/backups_schedule.html new file mode 100644 index 000000000..4cf3f6281 --- /dev/null +++ b/plinth/modules/backups/templates/backups_schedule.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} +{% load static %} + +{% block content %} +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+{% endblock %} diff --git a/plinth/modules/backups/tests/backups.feature b/plinth/modules/backups/tests/backups.feature index 0db1336f3..f27910145 100644 --- a/plinth/modules/backups/tests/backups.feature +++ b/plinth/modules/backups/tests/backups.feature @@ -24,3 +24,8 @@ Scenario: Download, upload and restore a backup And I download the app data backup with name test_backups And I restore the downloaded app data backup Then bind forwarders should be 1.1.1.1 + +Scenario: Set a schedule for a repository + Given the backup schedule is set to disable for 1 daily, 2 weekly and 3 monthly at 2:00 without app names + When I set the backup schedule to enable for 10 daily, 20 weekly and 30 monthly at 15:00 without app firewall + Then the schedule should be set to enable for 10 daily, 20 weekly and 30 monthly at 15:00 without app firewall diff --git a/plinth/modules/backups/tests/test_functional.py b/plinth/modules/backups/tests/test_functional.py index 535c387bc..fd79a2c73 100644 --- a/plinth/modules/backups/tests/test_functional.py +++ b/plinth/modules/backups/tests/test_functional.py @@ -9,7 +9,7 @@ import urllib.parse import requests from pytest import fixture -from pytest_bdd import parsers, scenarios, then, when +from pytest_bdd import given, parsers, scenarios, then, when from plinth.tests import functional @@ -50,6 +50,42 @@ def backup_restore_from_upload(session_browser, app_name, os.remove(path) +@given( + parsers.parse('the backup schedule is set to {enable:w} for {daily:d} ' + 'daily, {weekly:d} weekly and {monthly:d} monthly at ' + '{run_at:d}:00 without app {without_app:w}')) +def backup_schedule_set(session_browser, enable, daily, weekly, monthly, + run_at, without_app): + _backup_schedule_set(session_browser, enable == 'enable', daily, weekly, + monthly, run_at, without_app) + + +@when( + parsers.parse('I set the backup schedule to {enable:w} for {daily:d} ' + 'daily, {weekly:d} weekly and {monthly:d} monthly at ' + '{run_at:d}:00 without app {without_app:w}')) +def backup_schedule_set2(session_browser, enable, daily, weekly, monthly, + run_at, without_app): + _backup_schedule_set(session_browser, enable == 'enable', daily, weekly, + monthly, run_at, without_app) + + +@then( + parsers.parse('the schedule should be set to {enable:w} for {daily:d} ' + 'daily, {weekly:d} weekly and {monthly:d} monthly at ' + '{run_at:d}:00 without app {without_app:w}')) +def backup_schedule_assert(session_browser, enable, daily, weekly, monthly, + run_at, without_app): + schedule = _backup_schedule_get(session_browser) + assert schedule['enable'] == (enable == 'enable') + assert schedule['daily'] == daily + assert schedule['weekly'] == weekly + assert schedule['monthly'] == monthly + assert schedule['run_at'] == run_at + assert len(schedule['without_apps']) == 1 + assert schedule['without_apps'][0] == without_app + + def _open_main_page(browser): with functional.wait_for_page_update(browser): browser.find_link_by_href('/plinth/').first.click() @@ -88,3 +124,53 @@ def _upload_and_restore(browser, app_name, downloaded_file_path): with functional.wait_for_page_update(browser, expected_url='/plinth/sys/backups/'): functional.submit(browser) + + +def _backup_schedule_set(browser, enable, daily, weekly, monthly, run_at, + without_app): + """Set the schedule for root repository.""" + functional.nav_to_module(browser, 'backups') + browser.find_link_by_href( + '/plinth/sys/backups/root/schedule/').first.click() + if enable: + browser.find_by_name('backups_schedule-enabled').check() + else: + browser.find_by_name('backups_schedule-enabled').uncheck() + + browser.fill('backups_schedule-daily_to_keep', daily) + browser.fill('backups_schedule-weekly_to_keep', weekly) + browser.fill('backups_schedule-monthly_to_keep', monthly) + browser.fill('backups_schedule-run_at_hour', run_at) + functional.eventually(browser.find_by_css, args=['.select-all']) + browser.find_by_css('.select-all').first.check() + browser.find_by_css(f'input[value="{without_app}"]').first.uncheck() + functional.submit(browser) + + +def _backup_schedule_get(browser): + """Return the current schedule set for the root repository.""" + functional.nav_to_module(browser, 'backups') + browser.find_link_by_href( + '/plinth/sys/backups/root/schedule/').first.click() + without_apps = [] + elements = browser.find_by_name('backups_schedule-selected_apps') + for element in elements: + if not element.checked: + without_apps.append(element.value) + + return { + 'enable': + browser.find_by_name('backups_schedule-enabled').checked, + 'daily': + int(browser.find_by_name('backups_schedule-daily_to_keep').value), + 'weekly': + int(browser.find_by_name('backups_schedule-weekly_to_keep').value), + 'monthly': + int( + browser.find_by_name('backups_schedule-monthly_to_keep').value + ), + 'run_at': + int(browser.find_by_name('backups_schedule-run_at_hour').value), + 'without_apps': + without_apps + } diff --git a/plinth/modules/backups/urls.py b/plinth/modules/backups/urls.py index 098c6697d..86f43e9d0 100644 --- a/plinth/modules/backups/urls.py +++ b/plinth/modules/backups/urls.py @@ -8,11 +8,13 @@ from django.conf.urls import url from .views import (AddRemoteRepositoryView, AddRepositoryView, CreateArchiveView, DeleteArchiveView, DownloadArchiveView, IndexView, RemoveRepositoryView, RestoreArchiveView, - RestoreFromUploadView, UploadArchiveView, + RestoreFromUploadView, ScheduleView, UploadArchiveView, VerifySshHostkeyView, mount_repository, umount_repository) urlpatterns = [ url(r'^sys/backups/$', IndexView.as_view(), name='index'), + url(r'^sys/backups/(?P[^/]+)/schedule/$', ScheduleView.as_view(), + name='schedule'), url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'), url(r'^sys/backups/(?P[^/]+)/download/(?P[^/]+)/$', DownloadArchiveView.as_view(), name='download'), diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py index e1f90a525..63e9a566f 100644 --- a/plinth/modules/backups/views.py +++ b/plinth/modules/backups/views.py @@ -47,6 +47,55 @@ class IndexView(TemplateView): return context +class ScheduleView(SuccessMessageMixin, FormView): + form_class = forms.ScheduleForm + prefix = 'backups_schedule' + template_name = 'backups_schedule.html' + success_url = reverse_lazy('backups:index') + success_message = ugettext_lazy('Backup schedule updated.') + + def get_initial(self): + """Return the values to fill in the form.""" + initial = super().get_initial() + schedule = get_instance(self.kwargs['uuid']).schedule + initial.update({ + 'enabled': schedule.enabled, + 'daily_to_keep': schedule.daily_to_keep, + 'weekly_to_keep': schedule.weekly_to_keep, + 'monthly_to_keep': schedule.monthly_to_keep, + 'run_at_hour': schedule.run_at_hour, + 'unselected_apps': schedule.unselected_apps, + }) + return initial + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Schedule Backups') + return context + + def form_valid(self, form): + """Update backup schedule.""" + repository = get_instance(self.kwargs['uuid']) + schedule = repository.schedule + data = form.cleaned_data + schedule.enabled = data['enabled'] + schedule.daily_to_keep = data['daily_to_keep'] + schedule.weekly_to_keep = data['weekly_to_keep'] + schedule.monthly_to_keep = data['monthly_to_keep'] + schedule.run_at_hour = data['run_at_hour'] + + components = api.get_all_components_for_backup() + unselected_apps = [ + component.app_id for component in components + if component.app_id not in data['selected_apps'] + ] + schedule.unselected_apps = unselected_apps + + repository.save() + return super().form_valid(form) + + class CreateArchiveView(SuccessMessageMixin, FormView): """View to create a new archive.""" form_class = forms.CreateArchiveForm