...
 
Commits (43)
...@@ -25,8 +25,10 @@ import glob ...@@ -25,8 +25,10 @@ import glob
import json import json
import os import os
import subprocess import subprocess
import sys
import tarfile
REPOSITORY = '/var/lib/freedombox/backups' REPOSITORY = '/var/lib/freedombox/borgbackup'
def parse_arguments(): def parse_arguments():
...@@ -41,7 +43,8 @@ def parse_arguments(): ...@@ -41,7 +43,8 @@ def parse_arguments():
create = subparsers.add_parser('create', help='Create archive') create = subparsers.add_parser('create', help='Create archive')
create.add_argument('--name', help='Archive name', required=True) create.add_argument('--name', help='Archive name', required=True)
create.add_argument('--path', help='Path to archive', required=True) create.add_argument('--paths', help='Paths to include in archive',
nargs='+')
delete = subparsers.add_parser('delete', help='Delete archive') delete = subparsers.add_parser('delete', help='Delete archive')
delete.add_argument('--name', help='Archive name', required=True) delete.add_argument('--name', help='Archive name', required=True)
...@@ -61,6 +64,12 @@ def parse_arguments(): ...@@ -61,6 +64,12 @@ def parse_arguments():
list_exports.add_argument('--location', required=True, list_exports.add_argument('--location', required=True,
help='location to check') help='location to check')
get_export_apps = subparsers.add_parser(
'get-export-apps',
help='Get list of apps included in exported archive file')
get_export_apps.add_argument(
'--filename', help='Tarball file name', required=True)
restore = subparsers.add_parser( restore = subparsers.add_parser(
'restore', help='Restore files from an exported archive') 'restore', help='Restore files from an exported archive')
restore.add_argument('--filename', help='Tarball file name', required=True) restore.add_argument('--filename', help='Tarball file name', required=True)
...@@ -74,6 +83,10 @@ def subcommand_setup(_): ...@@ -74,6 +83,10 @@ def subcommand_setup(_):
try: try:
subprocess.run(['borg', 'info', REPOSITORY], check=True) subprocess.run(['borg', 'info', REPOSITORY], check=True)
except: except:
path = os.path.dirname(REPOSITORY)
if not os.path.exists(path):
os.makedirs(path)
subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY]) subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY])
...@@ -89,10 +102,13 @@ def subcommand_list(_): ...@@ -89,10 +102,13 @@ def subcommand_list(_):
def subcommand_create(arguments): def subcommand_create(arguments):
"""Create archive.""" """Create archive."""
paths = filter(os.path.exists, arguments.paths)
subprocess.run([ subprocess.run([
'borg', 'create', '--json', REPOSITORY + '::' + arguments.name, 'borg',
arguments.path 'create',
], check=True) '--json',
REPOSITORY + '::' + arguments.name,
] + list(paths), check=True)
def subcommand_delete(arguments): def subcommand_delete(arguments):
...@@ -140,14 +156,45 @@ def subcommand_list_exports(arguments): ...@@ -140,14 +156,45 @@ def subcommand_list_exports(arguments):
print(json.dumps(exports)) print(json.dumps(exports))
def subcommand_get_export_apps(arguments):
"""Get list of apps included in exported archive file."""
manifest = None
with tarfile.open(arguments.filename) as t:
filenames = t.getnames()
for name in filenames:
if 'var/lib/plinth/backups-manifests/' in name \
and name.endswith('.json'):
manifest_data = t.extractfile(name).read()
manifest = json.loads(manifest_data)
break
if manifest:
for app in manifest:
print(app['name'])
def subcommand_restore(arguments): def subcommand_restore(arguments):
"""Restore files from an exported archive.""" """Restore files from an exported archive."""
prev_dir = os.getcwd() locations_data = ''.join(sys.stdin)
try: locations = json.loads(locations_data)
os.chdir('/')
subprocess.run(['tar', 'xf', arguments.filename], check=True) found_file = False
finally: with tarfile.open(arguments.filename) as t:
os.chdir(prev_dir) for member in t.getmembers():
path = '/' + member.name
if path in locations['files']:
t.extract(member, '/')
found_file = True
else:
for d in locations['directories']:
if path.startswith(d):
t.extract(member, '/')
found_file = True
break
if not found_file:
print('No matching files or directories found in archive:', locations)
sys.exit(1)
def main(): def main():
......
#
# This file is part of FreedomBox.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
@apps @dynamicdns @backups
Feature: Dynamic DNS Client
Update public IP to a GnuDIP server.
Background:
Given I'm a logged in user
And the dynamicdns application is installed
Scenario: Backup and restore configuration
Given dynamicdns is configured
When I create a backup of the dynamicdns app data
And I change the dynamicdns configuration
And I export the dynamicdns app data backup
And I restore the dynamicdns app data backup
Then dynamicdns should have the original configuration
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
@apps @ejabberd @apps @ejabberd @backups
Feature: Ejabberd Chat Server Feature: Ejabberd Chat Server
Run ejabberd chat server. Run ejabberd chat server.
...@@ -42,3 +42,12 @@ Scenario: Disable message archive management ...@@ -42,3 +42,12 @@ Scenario: Disable message archive management
Given the ejabberd application is enabled Given the ejabberd application is enabled
When I disable message archive management When I disable message archive management
Then the ejabberd service should be running Then the ejabberd service should be running
Scenario: Backup and restore ejabberd
Given the ejabberd application is enabled
And I have added a contact to my roster
When I create a backup of the ejabberd app data
And I delete the contact from my roster
And I export the ejabberd app data backup
And I restore the ejabberd app data backup
Then I should have a contact on my roster
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
@apps @ikiwiki @apps @ikiwiki @backups
Feature: ikiwiki Wiki and Blog Feature: ikiwiki Wiki and Blog
Manage wikis and blogs. Manage wikis and blogs.
...@@ -32,4 +32,12 @@ Scenario: Disable wiki application ...@@ -32,4 +32,12 @@ Scenario: Disable wiki application
Given the wiki application is enabled Given the wiki application is enabled
When I disable the wiki application When I disable the wiki application
Then the wiki site should not be available Then the wiki site should not be available
\ No newline at end of file Scenario: Backup and restore wiki
Given the wiki application is enabled
When there is an ikiwiki wiki
And I create a backup of the ikiwiki app data
And I delete the ikiwiki wiki
And I export the ikiwiki app data backup
And I restore the ikiwiki app data backup
Then the ikiwiki wiki should be restored
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
@apps @mediawiki @apps @mediawiki @backups
Feature: MediaWiki Wiki Engine Feature: MediaWiki Wiki Engine
Manage wikis, multimedia and more. Manage wikis, multimedia and more.
...@@ -82,3 +82,11 @@ Scenario: Upload SVG image ...@@ -82,3 +82,11 @@ Scenario: Upload SVG image
Given the mediawiki application is enabled Given the mediawiki application is enabled
When I upload an image named FreedomBox-logo-grayscale.svg to mediawiki with credentials admin and whatever123 When I upload an image named FreedomBox-logo-grayscale.svg to mediawiki with credentials admin and whatever123
Then there should be FreedomBox-logo-grayscale.svg image Then there should be FreedomBox-logo-grayscale.svg image
Scenario: Backup and restore mediawiki
Given the mediawiki application is enabled
When I create a backup of the mediawiki app data
And I delete the mediawiki main page
And I export the mediawiki app data backup
And I restore the mediawiki app data backup
Then the mediawiki main page should be restored
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
@apps @sip @apps @sip @backups
Feature: Repro SIP Server Feature: Repro SIP Server
Make audio and video calls. Make audio and video calls.
...@@ -32,3 +32,12 @@ Scenario: Disable repro application ...@@ -32,3 +32,12 @@ Scenario: Disable repro application
Given the repro application is enabled Given the repro application is enabled
When I disable the repro application When I disable the repro application
Then the repro service should not be running Then the repro service should not be running
Scenario: Backup and restore repro
Given the repro application is enabled
And repro has been configured
When I create a backup of the repro app data
And I delete the repro configuration
And I export the repro app data backup
And I restore the repro app data backup
Then the repro configuration should be restored
...@@ -199,3 +199,33 @@ def set_mediawiki_admin_password(browser): ...@@ -199,3 +199,33 @@ def set_mediawiki_admin_password(browser):
@when(parsers.parse('I disable message archive management')) @when(parsers.parse('I disable message archive management'))
def set_mediawiki_admin_password(browser): def set_mediawiki_admin_password(browser):
application.disable_ejabberd_message_archive_management(browser) application.disable_ejabberd_message_archive_management(browser)
@when('there is an ikiwiki wiki')
def ikiwiki_create_wiki_if_needed(browser):
application.ikiwiki_create_wiki_if_needed(browser)
@when('I delete the ikiwiki wiki')
def ikiwiki_delete_wiki(browser):
application.ikiwiki_delete_wiki(browser)
@then('the ikiwiki wiki should be restored')
def ikiwiki_should_exist(browser):
assert application.ikiwiki_wiki_exists(browser)
@given('I have added a contact to my roster')
def ejabberd_add_contact(browser):
application.ejabberd_add_contact(browser)
@when('I delete the contact from my roster')
def ejabberd_delete_contact(browser):
application.ejabberd_delete_contact(browser)
@then('I should have a contact on my roster')
def ejabberd_should_have_contact(browser):
assert application.ejabberd_has_contact(browser)
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from pytest_bdd import parsers, then, when from pytest_bdd import given, parsers, then, when
from support import site from support import site
...@@ -86,3 +86,28 @@ def mediawiki_does_not_allow__account_creation_anonymous_reads_edits(browser): ...@@ -86,3 +86,28 @@ def mediawiki_does_not_allow__account_creation_anonymous_reads_edits(browser):
'with credentials {username:w} and {password:w}')) 'with credentials {username:w} and {password:w}'))
def login_to_mediawiki_with_credentials(browser, username, password): def login_to_mediawiki_with_credentials(browser, username, password):
site.login_to_mediawiki_with_credentials(browser, username, password) site.login_to_mediawiki_with_credentials(browser, username, password)
@when('I delete the mediawiki main page')
def mediawiki_delete_main_page(browser):
site.mediawiki_delete_main_page(browser)
@then('the mediawiki main page should be restored')
def mediawiki_verify_text(browser):
assert site.mediawiki_has_main_page(browser)
@given('repro has been configured')
def repro_configure(browser):
site.repro_configure(browser)
@when('I delete the repro configuration')
def repro_delete_config(browser):
site.repro_delete_config(browser)
@then('the repro configuration should be restored')
def repro_is_configured(browser):
assert site.repro_is_configured(browser)
...@@ -100,3 +100,33 @@ def verify_snapshot_count(browser, count): ...@@ -100,3 +100,33 @@ def verify_snapshot_count(browser, count):
@then(parsers.parse('the default app should be {app_name:w}')) @then(parsers.parse('the default app should be {app_name:w}'))
def default_app_should_be(browser, app_name): def default_app_should_be(browser, app_name):
assert system.check_home_page_redirect(browser, app_name) assert system.check_home_page_redirect(browser, app_name)
@given('dynamicdns is configured')
def dynamicdns_configure(browser):
system.dynamicdns_configure(browser)
@when('I change the dynamicdns configuration')
def dynamicdns_change_config(browser):
system.dynamicdns_change_config(browser)
@then('dynamicdns should have the original configuration')
def dynamicdns_has_original_config(browser):
assert system.dynamicdns_has_original_config(browser)
@when(parsers.parse('I create a backup of the {app_name:w} app data'))
def backup_create(browser, app_name):
system.backup_create(browser, app_name)
@when(parsers.parse('I export the {app_name:w} app data backup'))
def backup_export(browser, app_name):
system.backup_export(browser, app_name)
@when(parsers.parse('I restore the {app_name:w} app data backup'))
def backup_restore(browser, app_name):
system.backup_restore(browser, app_name)
...@@ -19,7 +19,7 @@ from time import sleep ...@@ -19,7 +19,7 @@ from time import sleep
import splinter import splinter
from support import config, interface from support import config, interface, site
from support.interface import submit from support.interface import submit
from support.service import eventually, wait_for_page_update from support.service import eventually, wait_for_page_update
...@@ -267,3 +267,50 @@ def disable_ejabberd_message_archive_management(browser): ...@@ -267,3 +267,50 @@ def disable_ejabberd_message_archive_management(browser):
interface.nav_to_module(browser, 'ejabberd') interface.nav_to_module(browser, 'ejabberd')
_change_status(browser, 'ejabberd', 'disabled', _change_status(browser, 'ejabberd', 'disabled',
checkbox_id='id_MAM_enabled') checkbox_id='id_MAM_enabled')
def ejabberd_add_contact(browser):
"""Add a contact to Ejabberd user's roster."""
site.jsxc_add_contact(browser)
def ejabberd_delete_contact(browser):
"""Delete the contact from Ejabberd user's roster."""
site.jsxc_delete_contact(browser)
def ejabberd_has_contact(browser):
"""Check whether the contact is in Ejabberd user's roster."""
return site.jsxc_has_contact(browser)
def ikiwiki_create_wiki_if_needed(browser):
"""Create wiki if it does not exist."""
interface.nav_to_module(browser, 'ikiwiki')
browser.find_link_by_href('/plinth/apps/ikiwiki/manage/').first.click()
wiki = browser.find_link_by_href('/ikiwiki/wiki')
if not wiki:
browser.find_link_by_href('/plinth/apps/ikiwiki/create/').first.click()
browser.find_by_id('id_ikiwiki-name').fill('wiki')
browser.find_by_id('id_ikiwiki-admin_name').fill(
config['DEFAULT']['username'])
browser.find_by_id('id_ikiwiki-admin_password').fill(
config['DEFAULT']['password'])
submit(browser)
def ikiwiki_delete_wiki(browser):
"""Delete wiki."""
interface.nav_to_module(browser, 'ikiwiki')
browser.find_link_by_href('/plinth/apps/ikiwiki/manage/').first.click()
browser.find_link_by_href(
'/plinth/apps/ikiwiki/wiki/delete/').first.click()
submit(browser)
def ikiwiki_wiki_exists(browser):
"""Check whether the wiki exists."""
interface.nav_to_module(browser, 'ikiwiki')
browser.find_link_by_href('/plinth/apps/ikiwiki/manage/').first.click()
wiki = browser.find_link_by_href('/ikiwiki/wiki')
return bool(wiki)
...@@ -20,9 +20,9 @@ from support import config ...@@ -20,9 +20,9 @@ from support import config
from .service import wait_for_page_update from .service import wait_for_page_update
sys_modules = [ sys_modules = [
'avahi', 'cockpit', 'config', 'datetime', 'diagnostics', 'firewall', 'avahi', 'backups', 'cockpit', 'config', 'datetime', 'diagnostics',
'letsencrypt', 'monkeysphere', 'names', 'networks', 'power', 'snapshot', 'dynamicdns', 'firewall', 'letsencrypt', 'monkeysphere', 'names',
'upgrades', 'users' 'networks', 'power', 'snapshot', 'upgrades', 'users'
] ]
default_url = config['DEFAULT']['url'] default_url = config['DEFAULT']['url']
......
...@@ -21,9 +21,10 @@ from time import sleep ...@@ -21,9 +21,10 @@ from time import sleep
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from support import config, interface from support import application, config, interface, system
from support.service import eventually, wait_for_page_update from support.service import eventually, wait_for_page_update
# unlisted sites just use '/' + site_name as url # unlisted sites just use '/' + site_name as url
site_url = { site_url = {
'wiki': '/ikiwiki', 'wiki': '/ikiwiki',
...@@ -123,3 +124,92 @@ def get_uploaded_image_in_mediawiki(browser, image): ...@@ -123,3 +124,92 @@ def get_uploaded_image_in_mediawiki(browser, image):
browser.visit(config['DEFAULT']['url'] + '/mediawiki/Special:ListFiles') browser.visit(config['DEFAULT']['url'] + '/mediawiki/Special:ListFiles')
elements = browser.find_link_by_partial_href(image) elements = browser.find_link_by_partial_href(image)
return elements[0].value return elements[0].value
def mediawiki_delete_main_page(browser):
"""Delete the mediawiki main page."""
_login_to_mediawiki(browser, 'admin', 'whatever123')
browser.visit(
'{}/mediawiki/index.php?title=Main_Page&action=delete'.format(
interface.default_url))
with wait_for_page_update(browser):
browser.find_by_id('wpConfirmB').first.click()
def mediawiki_has_main_page(browser):
"""Check if mediawiki main page exists."""
return eventually(_mediawiki_has_main_page, [browser])
def _mediawiki_has_main_page(browser):
"""Check if mediawiki main page exists."""
browser.visit('{}/mediawiki/Main_Page'.format(interface.default_url))
content = browser.find_by_id('mw-content-text').first
return 'This page has been deleted.' not in content.text
def repro_configure(browser):
"""Configure repro."""
browser.visit(
'{}/repro/domains.html?domainUri=freedombox.local&domainTlsPort='
'&action=Add'.format(interface.default_url))
def repro_delete_config(browser):
"""Delete the repro config."""
browser.visit('{}/repro/domains.html?domainUri=&domainTlsPort='
'&action=Remove&remove.freedombox.local=on'.format(
interface.default_url))
def repro_is_configured(browser):
"""Check whether repro is configured."""
return eventually(_repro_is_configured, [browser])
def _repro_is_configured(browser):
"""Check whether repro is configured."""
browser.visit('{}/repro/domains.html'.format(interface.default_url))
remove = browser.find_by_name('remove.freedombox.local')
return bool(remove)
def jsxc_login(browser):
"""Login to JSXC."""
access_url(browser, 'jsxc')
browser.find_by_id('jsxc-username').fill(config['DEFAULT']['username'])
browser.find_by_id('jsxc-password').fill(config['DEFAULT']['password'])
browser.find_by_id('jsxc-submit').click()
relogin = browser.find_by_text('relogin')
if relogin:
relogin.first.click()
browser.find_by_id('jsxc_username').fill(config['DEFAULT']['username'])
browser.find_by_id('jsxc_password').fill(config['DEFAULT']['password'])
browser.find_by_text('Connect').first.click()
def jsxc_add_contact(browser):
"""Add a contact to JSXC user's roster."""
system.set_domain_name(browser, 'localhost')
application.install(browser, 'jsxc')
jsxc_login(browser)
new = browser.find_by_text('new contact')
if new: # roster is empty
new.first.click()
browser.find_by_id('jsxc_username').fill('alice@localhost')
browser.find_by_text('Add').first.click()
def jsxc_delete_contact(browser):
"""Delete the contact from JSXC user's roster."""
jsxc_login(browser)
browser.find_by_css('div.jsxc_more').first.click()
browser.find_by_text('delete contact').first.click()
browser.find_by_text('Remove').first.click()
def jsxc_has_contact(browser):
"""Check whether the contact is in JSXC user's roster."""
jsxc_login(browser)
contact = browser.find_by_text('alice@localhost')
return bool(contact)
...@@ -15,9 +15,9 @@ ...@@ -15,9 +15,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from support import config from support import application, config
from .interface import nav_to_module, submit from .interface import default_url, nav_to_module, submit
config_page_title_language_map = { config_page_title_language_map = {
'da': 'Generel Konfiguration', 'da': 'Generel Konfiguration',
...@@ -99,3 +99,88 @@ def check_home_page_redirect(browser, app_name): ...@@ -99,3 +99,88 @@ def check_home_page_redirect(browser, app_name):
browser.visit(config['DEFAULT']['url']) browser.visit(config['DEFAULT']['url'])
return browser.find_by_xpath( return browser.find_by_xpath(
"//a[contains(@href, '/plinth/') and @title='FreedomBox']") "//a[contains(@href, '/plinth/') and @title='FreedomBox']")
def dynamicdns_configure(browser):
nav_to_module(browser, 'dynamicdns')
browser.find_link_by_href(
'/plinth/sys/dynamicdns/configure/').first.click()
browser.find_by_id('id_enabled').check()
browser.find_by_id('id_service_type').select('GnuDIP')
browser.find_by_id('id_dynamicdns_server').fill('example.com')
browser.find_by_id('id_dynamicdns_domain').fill('freedombox.example.com')
browser.find_by_id('id_dynamicdns_user').fill('tester')
browser.find_by_id('id_dynamicdns_secret').fill('testingtesting')
browser.find_by_id('id_dynamicdns_ipurl').fill(
'http://myip.datasystems24.de')
submit(browser)
def dynamicdns_has_original_config(browser):
nav_to_module(browser, 'dynamicdns')
browser.find_link_by_href(
'/plinth/sys/dynamicdns/configure/').first.click()
enabled = browser.find_by_id('id_enabled').value
service_type = browser.find_by_id('id_service_type').value
server = browser.find_by_id('id_dynamicdns_server').value
domain = browser.find_by_id('id_dynamicdns_domain').value
user = browser.find_by_id('id_dynamicdns_user').value
ipurl = browser.find_by_id('id_dynamicdns_ipurl').value
if enabled and service_type == 'GnuDIP' and server == 'example.com' \
and domain == 'freedombox.example.com' and user == 'tester' \
and ipurl == 'http://myip.datasystems24.de':
return True
else:
return False
def dynamicdns_change_config(browser):
nav_to_module(browser, 'dynamicdns')
browser.find_link_by_href(
'/plinth/sys/dynamicdns/configure/').first.click()
browser.find_by_id('id_enabled').check()
browser.find_by_id('id_service_type').select('GnuDIP')
browser.find_by_id('id_dynamicdns_server').fill('2.example.com')
browser.find_by_id('id_dynamicdns_domain').fill('freedombox2.example.com')
browser.find_by_id('id_dynamicdns_user').fill('tester2')
browser.find_by_id('id_dynamicdns_secret').fill('testingtesting2')
browser.find_by_id('id_dynamicdns_ipurl').fill(
'http://myip2.datasystems24.de')
submit(browser)
def backup_create(browser, app_name):
browser.visit(default_url)
application.install(browser, 'backups')
delete = browser.find_link_by_href(
'/plinth/sys/backups/delete/_functional_test_' + app_name + '/')
if delete:
delete.first.click()
submit(browser)
browser.find_link_by_href('/plinth/sys/backups/create/').first.click()
browser.find_by_id('id_backups-name').fill('_functional_test_' + app_name)
for app in browser.find_by_css('input[type=checkbox]'):
app.uncheck()
browser.find_by_value(app_name).first.check()
submit(browser)
def backup_export(browser, app_name):
browser.visit(default_url)
nav_to_module(browser, 'backups')
browser.find_link_by_href(
'/plinth/sys/backups/export/_functional_test_'
+ app_name + '/').first.click()
browser.find_by_id('id_backups-disk_0').first.check()
submit(browser)
def backup_restore(browser, app_name):
browser.visit(default_url)
nav_to_module(browser, 'backups')
browser.find_link_by_href(
'/plinth/sys/backups/restore/Root%2520Filesystem/_functional_test_'
+ app_name + '.tar.gz/').first.click()
submit(browser)
...@@ -18,24 +18,33 @@ ...@@ -18,24 +18,33 @@
FreedomBox app to manage backup archives. FreedomBox app to manage backup archives.
""" """
import errno
import json import json
import os
from django.utils.text import get_valid_filename
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth import actions from plinth import actions
from plinth.menu import main_menu from plinth.menu import main_menu
from plinth.modules import udiskie from plinth.modules import udiskie
from .backups import backup_apps, restore_apps
version = 1 version = 1
managed_packages = ['borgbackup'] managed_packages = ['borgbackup']
name = _('Backups') name = _('Backups')
description = [_('Backups allows creating and managing backup archives.'), ] description = [
_('Backups allows creating and managing backup archives.'),
]
service = None service = None
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
def init(): def init():
"""Intialize the module.""" """Intialize the module."""
...@@ -67,9 +76,29 @@ def get_archive(name): ...@@ -67,9 +76,29 @@ def get_archive(name):
return None return None
def create_archive(name, path): def _backup_handler(packet):
actions.superuser_run('backups', """Performs backup operation on packet."""
['create', '--name', name, '--path', path]) if not os.path.exists(MANIFESTS_FOLDER):
os.makedirs(MANIFESTS_FOLDER)
manifest_path = os.path.join(MANIFESTS_FOLDER,
get_valid_filename(packet.label) + '.json')
manifests = [{
'name': manifest[0],
'version': manifest[1].version,
'backup': manifest[2]
} for manifest in packet.manifests]
with open(manifest_path, 'w') as manifest_file:
json.dump(manifests, manifest_file)
paths = packet.directories + packet.files
paths.append(manifest_path)
actions.superuser_run(
'backups', ['create', '--name', packet.label, '--paths'] + paths)
def create_archive(name, app_names):
backup_apps(_backup_handler, app_names, name)
def delete_archive(name): def delete_archive(name):
...@@ -79,7 +108,9 @@ def delete_archive(name): ...@@ -79,7 +108,9 @@ def delete_archive(name):
def export_archive(name, location): def export_archive(name, location):
if location[-1] != '/': if location[-1] != '/':
location += '/' location += '/'
filename = location + 'FreedomBox-backups/' + name + '.tar.gz'
filename = location + 'FreedomBox-backups/' + get_valid_filename(
name) + '.tar.gz'
actions.superuser_run('backups', actions.superuser_run('backups',
['export', '--name', name, '--filename', filename]) ['export', '--name', name, '--filename', filename])
...@@ -103,21 +134,41 @@ def get_export_files(): ...@@ -103,21 +134,41 @@ def get_export_files():
export_files = {} export_files = {}
for location in locations: for location in locations:
output = actions.superuser_run( output = actions.superuser_run(
'backups', ['list-exports', '--location', location[0]]) 'backups', ['list-exports', '--location', location[0]])
export_files[location[1]] = json.loads(output) export_files[location[1]] = json.loads(output)
return export_files return export_files
def restore_exported(label, name): def find_exported_archive(disk_label, archive_name):
"""Restore files from exported backup archive.""" """Return the full path for the exported archive file."""
locations = get_export_locations() locations = get_export_locations()
for location in locations: for location in locations:
if location[1] == label: if location[1] == disk_label:
filename = location[0] return os.path.join(location[0], 'FreedomBox-backups',
if filename[-1] != '/': archive_name)
filename += '/'
filename += 'FreedomBox-backups/' + name raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT),
actions.superuser_run( archive_name)
'backups', ['restore', '--filename', filename])
break
def get_export_apps(filename):
"""Get list of apps included in exported archive file."""
output = actions.superuser_run('backups',
['get-export-apps', '--filename', filename])
return output.splitlines()
def _restore_handler(packet):
"""Perform restore operation on packet."""
locations = {'directories': packet.directories, 'files': packet.files}
locations_data = json.dumps(locations)
actions.superuser_run('backups', ['restore', '--filename', packet.label],
input=locations_data.encode())
def restore_exported(label, archive_name, apps=None):
"""Restore files from exported backup archive."""
filename = find_exported_archive(label, archive_name)
restore_apps(_restore_handler, app_names=apps, create_subvolume=False,
backup_file=filename)
...@@ -30,10 +30,40 @@ import collections ...@@ -30,10 +30,40 @@ import collections
from plinth import actions, action_utils, module_loader from plinth import actions, action_utils, module_loader
def validate(backup):
"""Validate the backup' information schema."""
assert isinstance(backup, dict)
assert 'config' in backup
assert isinstance(backup['config'], dict)
_validate_directories_and_files(backup['config'])
assert 'data' in backup
assert isinstance(backup['data'], dict)
_validate_directories_and_files(backup['data'])
assert 'secrets' in backup
assert isinstance(backup['secrets'], dict)
_validate_directories_and_files(backup['secrets'])
assert 'services' in backup
assert isinstance(backup['services'], list)
return backup
def _validate_directories_and_files(df):
"""Validate directories and files structure."""
assert 'directories' in df
assert isinstance(df['directories'], list)
assert 'files' in df
assert isinstance(df['files'], list)
class Packet: class Packet:
"""Information passed to a handlers for backup/restore operations.""" """Information passed to a handlers for backup/restore operations."""
def __init__(self, operation, scope, root, manifests=None): def __init__(self, operation, scope, root, manifests=None, label=None):
"""Initialize the packet. """Initialize the packet.
operation is either 'backup' or 'restore. operation is either 'backup' or 'restore.
...@@ -50,6 +80,7 @@ class Packet: ...@@ -50,6 +80,7 @@ class Packet:
self.scope = scope self.scope = scope
self.root = root self.root = root
self.manifests = manifests self.manifests = manifests
self.label = label
self.directories = [] self.directories = []
self.files = [] self.files = []
...@@ -58,11 +89,14 @@ class Packet: ...@@ -58,11 +89,14 @@ class Packet:
def _process_manifests(self): def _process_manifests(self):
"""Look at manifests and fill up the list of directories/files.""" """Look at manifests and fill up the list of directories/files."""
# XXX: for manifest in self.manifests:
pass backup = manifest[2]
for section in ['config', 'data', 'secrets']:
self.directories += backup[section]['directories']
self.files += backup[section]['files']
def backup_full(backup_handler): def backup_full(backup_handler, label=None):
"""Backup the entire system.""" """Backup the entire system."""
if not _is_snapshot_available(): if not _is_snapshot_available():
raise Exception('Full backup is not supported without snapshots.') raise Exception('Full backup is not supported without snapshots.')
...@@ -70,7 +104,7 @@ def backup_full(backup_handler): ...@@ -70,7 +104,7 @@ def backup_full(backup_handler):
snapshot = _take_snapshot() snapshot = _take_snapshot()
backup_root = snapshot['mount_path'] backup_root = snapshot['mount_path']
packet = Packet('backup', 'full', backup_root) packet = Packet('backup', 'full', backup_root, label)
_run_operation(backup_handler, packet) _run_operation(backup_handler, packet)
_delete_snapshot(snapshot) _delete_snapshot(snapshot)
...@@ -89,10 +123,10 @@ def restore_full(restore_handler): ...@@ -89,10 +123,10 @@ def restore_full(restore_handler):
_switch_to_subvolume(subvolume) _switch_to_subvolume(subvolume)
def backup_apps(backup_handler, app_names=None): def backup_apps(backup_handler, app_names=None, label=None):
"""Backup data belonging to a set of applications.""" """Backup data belonging to a set of applications."""
if not app_names: if not app_names:
apps = _list_of_all_apps_for_backup() apps = get_all_apps_for_backup()
else: else:
apps = _get_apps_in_order(app_names) apps = _get_apps_in_order(app_names)
...@@ -108,7 +142,7 @@ def backup_apps(backup_handler, app_names=None): ...@@ -108,7 +142,7 @@ def backup_apps(backup_handler, app_names=None):
backup_root = '/' backup_root = '/'
snapshotted = False snapshotted = False
packet = Packet('backup', 'apps', backup_root, manifests) packet = Packet('backup', 'apps', backup_root, manifests, label)
_run_operation(backup_handler, packet) _run_operation(backup_handler, packet)
if snapshotted: if snapshotted:
...@@ -118,10 +152,11 @@ def backup_apps(backup_handler, app_names=None): ...@@ -118,10 +152,11 @@ def backup_apps(backup_handler, app_names=None):
_lockdown_apps(apps, lockdown=False) _lockdown_apps(apps, lockdown=False)
def restore_apps(restore_handler, app_names=None, create_subvolume=True): def restore_apps(restore_handler, app_names=None, create_subvolume=True,
backup_file=None):
"""Restore data belonging to a set of applications.""" """Restore data belonging to a set of applications."""
if not app_names: if not app_names:
apps = _list_of_all_apps_for_backup() apps = get_all_apps_for_backup()
else: else:
apps = _get_apps_in_order(app_names) apps = _get_apps_in_order(app_names)
...@@ -137,7 +172,7 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True): ...@@ -137,7 +172,7 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True):
restore_root = '/' restore_root = '/'
subvolume = False subvolume = False
packet = Packet('restore', 'apps', restore_root, manifests) packet = Packet('restore', 'apps', restore_root, manifests, backup_file)
_run_operation(restore_handler, packet) _run_operation(restore_handler, packet)
if subvolume: if subvolume:
...@@ -147,10 +182,10 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True): ...@@ -147,10 +182,10 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True):
_lockdown_apps(apps, lockdown=False) _lockdown_apps(apps, lockdown=False)
def _list_of_all_apps_for_backup(): def get_all_apps_for_backup():
"""Return a list of all applications that can be backed up.""" """Return a list of all applications that can be backed up."""
apps = [] apps = []
for module_name, module in module_loader.loaded_modules.values(): for module_name, module in module_loader.loaded_modules.items():
# Not installed # Not installed
if module.setup_helper.get_state() == 'needs-setup': if module.setup_helper.get_state() == 'needs-setup':
continue continue
...@@ -167,7 +202,7 @@ def _list_of_all_apps_for_backup(): ...@@ -167,7 +202,7 @@ def _list_of_all_apps_for_backup():
def _get_apps_in_order(app_names): def _get_apps_in_order(app_names):
"""Return a list of app modules in order of dependency.""" """Return a list of app modules in order of dependency."""
apps = [] apps = []
for module_name, module in module_loader.loaded_modules.values(): for module_name, module in module_loader.loaded_modules.items():
if module_name in app_names: if module_name in app_names:
apps.append((module_name, module)) apps.append((module_name, module))
...@@ -261,10 +296,11 @@ def _shutdown_services(manifests): ...@@ -261,10 +296,11 @@ def _shutdown_services(manifests):
state[service] = {'app_name': app_name, 'app': app} state[service] = {'app_name': app_name, 'app': app}
for service in state: for service in state:
state['was_running'] = action_utils.service_is_running('service') state[service]['was_running'] = action_utils.service_is_running(
service)
for service in reversed(state): for service in reversed(state):
if service['was_running']: if state[service]['was_running']:
actions.superuser_run('service', ['stop', service]) actions.superuser_run('service', ['stop', service])
return state return state
...@@ -276,7 +312,7 @@ def _restore_services(original_state): ...@@ -276,7 +312,7 @@ def _restore_services(original_state):
Maintain exact order of services so dependencies are satisfied. Maintain exact order of services so dependencies are satisfied.
""" """
for service in original_state: for service in original_state:
if service['was_running']: if original_state[service]['was_running']:
actions.superuser_run('service', ['start', service]) actions.superuser_run('service', ['start', service])
......
...@@ -22,6 +22,7 @@ from django import forms ...@@ -22,6 +22,7 @@ from django import forms
from django.core import validators from django.core import validators
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from . import backups as backups_api
from . import get_export_locations from . import get_export_locations
...@@ -32,9 +33,18 @@ class CreateArchiveForm(forms.Form): ...@@ -32,9 +33,18 @@ class CreateArchiveForm(forms.Form):
validators.RegexValidator(r'^[^/]+$', _('Invalid archive name')) validators.RegexValidator(r'^[^/]+$', _('Invalid archive name'))
]) ])
path = forms.CharField(label=_('Path'), strip=True, help_text=_( selected_apps = forms.MultipleChoiceField(
'Disk path to a folder on this server that will be archived into ' label=_('Included apps'),
'backup repository.')) help_text=_('Apps to include in the backup'),
widget=forms.CheckboxSelectMultiple)
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
super().__init__(*args, **kwargs)
apps = backups_api.get_all_apps_for_backup()
self.fields['selected_apps'].choices = [
(app[0], app[1].name) for app in apps]
self.fields['selected_apps'].initial = [app[0] for app in apps]
class ExportArchiveForm(forms.Form): class ExportArchiveForm(forms.Form):
...@@ -47,3 +57,18 @@ class ExportArchiveForm(forms.Form): ...@@ -47,3 +57,18 @@ class ExportArchiveForm(forms.Form):
"""Initialize the form with disk choices.""" """Initialize the form with disk choices."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['disk'].choices = get_export_locations() self.fields['disk'].choices = get_export_locations()
class RestoreForm(forms.Form):
selected_apps = forms.MultipleChoiceField(
label=_('Restore apps'),
help_text=_('Apps data to restore from the backup'),
widget=forms.CheckboxSelectMultiple)
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
apps = kwargs.pop('apps')
super().__init__(*args, **kwargs)
self.fields['selected_apps'].choices = [
(app[0], app[1].name) for app in apps]
self.fields['selected_apps'].initial = [app[0] for app in apps]
...@@ -39,13 +39,29 @@ ...@@ -39,13 +39,29 @@
<p>{{ paragraph|safe }}</p> <p>{{ paragraph|safe }}</p>
{% endfor %} {% endfor %}
<p> {% if available_apps %}
<a title="{% trans 'Create archive' %}" <p>
role="button" class="btn btn-primary" <a title="{% trans 'New backup' %}"
href="{% url 'backups:create' %}"> role="button" class="btn btn-primary"
{% trans 'Create archive' %} href="{% url 'backups:create' %}">
</a> {% trans 'New backup' %}
</p> </a>
</p>
{% else %}
<p>
<a title="{% trans 'New backup' %}"
role="button" class="btn btn-primary disabled"
href="{% url 'backups:create' %}">
{% trans 'New backup' %}
</a>
</p>
<p>
{% blocktrans trimmed %}
No apps that support backup are currently installed. Backup can be
created after an app supporting backups is installed.
{% endblocktrans %}
</p>
{% endif %}
<h3>{% trans 'Backup archives' %}</h3> <h3>{% trans 'Backup archives' %}</h3>
{% if not archives %} {% if not archives %}
...@@ -56,7 +72,6 @@ ...@@ -56,7 +72,6 @@
<thead> <thead>
<tr> <tr>
<th>{% trans "Name" %}</th> <th>{% trans "Name" %}</th>
<th>{% trans "Time" %}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
...@@ -65,7 +80,6 @@ ...@@ -65,7 +80,6 @@
{% for archive in archives %} {% for archive in archives %}
<tr id="archive-{{ archive.name }}" class="archive"> <tr id="archive-{{ archive.name }}" class="archive">
<td class="archive-name">{{ archive.name }}</td> <td class="archive-name">{{ archive.name }}</td>
<td class="archive-time">{{ archive.time }}</td>
<td class="archive-operations"> <td class="archive-operations">
<a class="archive-export btn btn-sm btn-default" <a class="archive-export btn btn-sm btn-default"
href="{% url 'backups:export' archive.name %}"> href="{% url 'backups:export' archive.name %}">
......
...@@ -47,6 +47,8 @@ ...@@ -47,6 +47,8 @@
<form class="form" method="post"> <form class="form" method="post">
{% csrf_token %} {% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-danger" <input type="submit" class="btn btn-danger"
value="{% blocktrans trimmed %} value="{% blocktrans trimmed %}
Restore data from {{ name }} Restore data from {{ name }}
......
#
# This file is part of FreedomBox.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Tests for backups module.
"""
import collections
import unittest
from unittest.mock import call, patch, MagicMock
from plinth.module_loader import load_modules
from ..backups import validate, Packet, backup_apps, restore_apps, \
get_all_apps_for_backup, _get_apps_in_order, _get_manifests, \
_lockdown_apps, _shutdown_services, _restore_services
def _get_test_manifest(name):
return validate({
'config': {
'directories': ['/etc/' + name + '/config.d/'],
'files': ['/etc/' + name + '/config'],
},
'data': {
'directories': ['/var/lib/' + name + '/data.d/'],
'files': ['/var/lib/' + name + '/data'],
},
'secrets': {
'directories': ['/etc/' + name + '/secrets.d/'],
'files': ['/etc/' + name + '/secrets'],
},
'services': [name]
})
class TestBackups(unittest.TestCase):
"""Test cases for backups module."""
def test_packet_process_manifests(self):
"""Test that directories/files are collected from manifests."""
manifests = [
('a', None, _get_test_manifest('a')),
('b', None, _get_test_manifest('b')),
]
packet = Packet('backup', 'apps', '/', manifests)
for manifest in manifests:
backup = manifest[2]
for section in ['config', 'data', 'secrets']:
for directory in backup[section]['directories']:
assert directory in packet.directories
for file_path in backup[section]['files']:
assert file_path in packet.files
def test_backup_apps(self):
"""Test that backup_handler is called."""
backup_handler = MagicMock()
backup_apps(backup_handler)
backup_handler.assert_called_once()
def test_restore_apps(self):
"""Test that restore_handler is called."""
restore_handler = MagicMock()
restore_apps(restore_handler)
restore_handler.assert_called_once()
def test_get_all_apps_for_backups(self):
"""Test that apps supporting backup are included in returned list."""
load_modules()
apps = get_all_apps_for_backup()
assert isinstance(apps, list)
# apps may be empty, if no apps supporting backup are installed.
def test__get_apps_in_order(self):
"""Test that apps are listed in correct dependency order."""
load_modules()
app_names = ['config', 'names']
apps = _get_apps_in_order(app_names)
ordered_app_names = [app[0] for app in apps]
names_index = ordered_app_names.index('names')
config_index = ordered_app_names.index('config')
assert names_index < config_index
def test__get_manifests(self):
"""Test that manifests are collected from the apps."""
a = MagicMock(backup=_get_test_manifest('a'))
b = MagicMock(backup=_get_test_manifest('b'))
apps = [
('a', a),
('b', b),
]
manifests = _get_manifests(apps)
assert ('a', a, a.backup) in manifests
assert ('b', b, b.backup) in manifests
def test__lockdown_apps(self):
"""Test that locked flag is set for each app."""
a = MagicMock(locked=False)
b = MagicMock(locked=None)
apps = [
('a', a),
('b', b),
]
_lockdown_apps(apps, True)
assert a.locked is True
assert b.locked is True
@patch('plinth.action_utils.service_is_running')
@patch('plinth.actions.superuser_run')
def test__shutdown_services(self, run, is_running):
"""Test that services are stopped in correct order."""
manifests = [
('a', None, _get_test_manifest('a')),
('b', None, _get_test_manifest('b')),
]
is_running.return_value = True
state = _shutdown_services(manifests)
assert 'a' in state
assert 'b' in state
is_running.assert_any_call('a')
is_running.assert_any_call('b')
calls = [
call('service', ['stop', 'b']),
call('service', ['stop', 'a'])
]
run.assert_has_calls(calls)
@patch('plinth.actions.superuser_run')
def test__restore_services(self, run):
"""Test that services are restored in correct order."""
original_state = collections.OrderedDict()
original_state['a'] = {
'app_name': 'a', 'app': None, 'was_running': True}
original_state['b'] = {
'app_name': 'b', 'app': None, 'was_running': False}
_restore_services(original_state)
run.assert_called_once_with('service', ['start', 'a'])
...@@ -18,6 +18,9 @@ ...@@ -18,6 +18,9 @@
Views for the backups app. Views for the backups app.
""" """
from datetime import datetime
from urllib.parse import unquote
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404 from django.http import Http404
...@@ -25,11 +28,11 @@ from django.shortcuts import redirect ...@@ -25,11 +28,11 @@ from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import FormView, TemplateView from django.views.generic import FormView, TemplateView
from urllib.parse import unquote
from plinth.modules import backups from plinth.modules import backups
from .forms import CreateArchiveForm, ExportArchiveForm from . import backups as backups_api
from .forms import CreateArchiveForm, ExportArchiveForm, RestoreForm
class IndexView(TemplateView): class IndexView(TemplateView):
...@@ -44,6 +47,8 @@ class IndexView(TemplateView): ...@@ -44,6 +47,8 @@ class IndexView(TemplateView):
context['info'] = backups.get_info() context['info'] = backups.get_info()
context['archives'] = backups.list_archives() context['archives'] = backups.list_archives()
context['exports'] = backups.get_export_files() context['exports'] = backups.get_export_files()
apps = backups_api.get_all_apps_for_backup()
context['available_apps'] = [app[0] for app in apps]
return context return context
...@@ -58,13 +63,20 @@ class CreateArchiveView(SuccessMessageMixin, FormView): ...@@ -58,13 +63,20 @@ class CreateArchiveView(SuccessMessageMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Return additional context for rendering the template.""" """Return additional context for rendering the template."""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['title'] = _('Create Archive') context['title'] = _('New Backup')
return context return context
def get_initial(self):
"""Return the initial data to use for forms on this view."""
initial = super().get_initial()
initial['name'] = 'FreedomBox_backup_' + datetime.now().strftime(
'%Y-%m-%d:%H:%M')
return initial
def form_valid(self, form): def form_valid(self, form):
"""Create the archive on valid form submission.""" """Create the archive on valid form submission."""
backups.create_archive(form.cleaned_data['name'], backups.create_archive(form.cleaned_data['name'],
form.cleaned_data['path']) form.cleaned_data['selected_apps'])
return super().form_valid(form) return super().form_valid(form)
...@@ -109,14 +121,34 @@ class ExportArchiveView(SuccessMessageMixin, FormView): ...@@ -109,14 +121,34 @@ class ExportArchiveView(SuccessMessageMixin, FormView):
def form_valid(self, form): def form_valid(self, form):
"""Create the archive on valid form submission.""" """Create the archive on valid form submission."""
backups.export_archive(self.kwargs['name'], backups.export_archive(self.kwargs['name'], form.cleaned_data['disk'])
form.cleaned_data['disk'])
return super().form_valid(form) return super().form_valid(form)
class RestoreView(SuccessMessageMixin, TemplateView): class RestoreView(SuccessMessageMixin, FormView):
"""View to restore files from an exported archive.""" """View to restore files from an exported archive."""
form_class = RestoreForm
prefix = 'backups'
template_name = 'backups_restore.html' template_name = 'backups_restore.html'
success_url = reverse_lazy('backups:index')
success_message = _('Restored files from backup.')
def _get_included_apps(self):
"""Save some data used to instantiate the form."""
label = unquote(self.kwargs['label'