backups: Add UI to edit schedules

Closes: #1529.

Tests performed:

- Functional tests for backups app work.

- Functional tests for backup of several apps work.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2021-01-05 16:30:36 -08:00 committed by James Valleroy
parent b33362cb7a
commit eb526275c7
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
7 changed files with 219 additions and 2 deletions

View File

@ -46,6 +46,54 @@ def _get_repository_choices():
return 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): class CreateArchiveForm(forms.Form):
repository = forms.ChoiceField(label=_('Repository')) repository = forms.ChoiceField(label=_('Repository'))
name = forms.RegexField( name = forms.RegexField(

View File

@ -23,6 +23,12 @@
{{ repository.name }} {{ repository.name }}
<span class="pull-right"> <span class="pull-right">
<a class="repository-schedule btn btn-sm btn-primary"
href="{% url 'backups:schedule' uuid %}">
<span class="fa fa-clock-o" aria-hidden="true"></span>
{% trans "Schedule" %}
</a>
{% if repository.flags.mountable %} {% if repository.flags.mountable %}
{% if repository.mounted %} {% if repository.mounted %}

View File

@ -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 %}
<h2>{{ title }}</h2>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans 'Update' %}" />
</form>
{% endblock %}

View File

@ -24,3 +24,8 @@ Scenario: Download, upload and restore a backup
And I download the app data backup with name test_backups And I download the app data backup with name test_backups
And I restore the downloaded app data backup And I restore the downloaded app data backup
Then bind forwarders should be 1.1.1.1 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

View File

@ -9,7 +9,7 @@ import urllib.parse
import requests import requests
from pytest import fixture 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 from plinth.tests import functional
@ -50,6 +50,42 @@ def backup_restore_from_upload(session_browser, app_name,
os.remove(path) 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): def _open_main_page(browser):
with functional.wait_for_page_update(browser): with functional.wait_for_page_update(browser):
browser.find_link_by_href('/plinth/').first.click() 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, with functional.wait_for_page_update(browser,
expected_url='/plinth/sys/backups/'): expected_url='/plinth/sys/backups/'):
functional.submit(browser) 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
}

View File

@ -8,11 +8,13 @@ from django.conf.urls import url
from .views import (AddRemoteRepositoryView, AddRepositoryView, from .views import (AddRemoteRepositoryView, AddRepositoryView,
CreateArchiveView, DeleteArchiveView, DownloadArchiveView, CreateArchiveView, DeleteArchiveView, DownloadArchiveView,
IndexView, RemoveRepositoryView, RestoreArchiveView, IndexView, RemoveRepositoryView, RestoreArchiveView,
RestoreFromUploadView, UploadArchiveView, RestoreFromUploadView, ScheduleView, UploadArchiveView,
VerifySshHostkeyView, mount_repository, umount_repository) VerifySshHostkeyView, mount_repository, umount_repository)
urlpatterns = [ urlpatterns = [
url(r'^sys/backups/$', IndexView.as_view(), name='index'), url(r'^sys/backups/$', IndexView.as_view(), name='index'),
url(r'^sys/backups/(?P<uuid>[^/]+)/schedule/$', ScheduleView.as_view(),
name='schedule'),
url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'), url(r'^sys/backups/create/$', CreateArchiveView.as_view(), name='create'),
url(r'^sys/backups/(?P<uuid>[^/]+)/download/(?P<name>[^/]+)/$', url(r'^sys/backups/(?P<uuid>[^/]+)/download/(?P<name>[^/]+)/$',
DownloadArchiveView.as_view(), name='download'), DownloadArchiveView.as_view(), name='download'),

View File

@ -47,6 +47,55 @@ class IndexView(TemplateView):
return context 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): class CreateArchiveView(SuccessMessageMixin, FormView):
"""View to create a new archive.""" """View to create a new archive."""
form_class = forms.CreateArchiveForm form_class = forms.CreateArchiveForm