deluge: Allow to set a download directory

- add directory selection form to the app configuration page
- add debian-deluged user to the freedombox-share group
- storage: new validator parameter check-creatable
  (because deluged is able to create subdirectories)

Signed-off-by: Veiko Aasa <veiko17@disroot.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Veiko Aasa 2020-01-17 13:15:27 +02:00 committed by James Valleroy
parent e217a4c87e
commit 8e698987de
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
8 changed files with 195 additions and 17 deletions

View File

@ -20,6 +20,7 @@ Configuration helper for BitTorrent web client.
""" """
import argparse import argparse
import json
import os import os
import shutil import shutil
import subprocess import subprocess
@ -80,6 +81,16 @@ def parse_arguments():
subparsers.add_parser('setup', help='Setup deluge') subparsers.add_parser('setup', help='Setup deluge')
subparsers.add_parser('get-configuration',
help='Return the current configuration')
subparser = subparsers.add_parser('set-configuration',
help='Set the configuration parameter')
subparser.add_argument('parameter',
help='Name of the configuration parameter')
subparser.add_argument('value',
help='Value of the configuration parameter')
subparsers.required = True subparsers.required = True
return parser.parse_args() return parser.parse_args()
@ -141,6 +152,29 @@ def _set_deluged_daemon_options():
aug.save() aug.save()
def subcommand_get_configuration(_):
"""Return the current deluged configuration in JSON format."""
deluged_conf_file = os.path.join(DELUGE_CONF_DIR, 'core.conf')
if config is None:
script = 'from deluge import config;\
conf = config.Config(filename="{0}");\
print(conf["download_location"])'.format(deluged_conf_file)
output = subprocess.check_output(['python2', '-c', script]).decode()
download_location = output.strip()
else:
conf = config.Config(filename=deluged_conf_file)
download_location = conf["download_location"]
print(json.dumps({'download_location': download_location}))
def subcommand_set_configuration(arguments):
"""Set the deluged configuration."""
if arguments.parameter != 'download_location':
return
_set_configuration('core.conf', arguments.parameter, arguments.value)
def subcommand_setup(_): def subcommand_setup(_):
"""Perform initial setup for deluge.""" """Perform initial setup for deluge."""

View File

@ -56,6 +56,9 @@ def parse_arguments():
help='Validate a directory') help='Validate a directory')
subparser.add_argument('--path', help='Path of the directory', subparser.add_argument('--path', help='Path of the directory',
required=True) required=True)
subparser.add_argument('--check-creatable', required=False, default=False,
action='store_true',
help='Check that the directory is creatable')
subparser.add_argument('--check-writable', required=False, default=False, subparser.add_argument('--check-writable', required=False, default=False,
action='store_true', action='store_true',
help='Check that the directory is writable') help='Check that the directory is writable')
@ -329,14 +332,32 @@ def subcommand_usage_info(_):
def subcommand_validate_directory(arguments): def subcommand_validate_directory(arguments):
"""Validate a directory""" """Validate a directory"""
directory = arguments.path directory = arguments.path
if not os.path.exists(directory):
print('ValidationError: 1') def part_exists(path):
"""Returns part of the path that exists."""
if not path or os.path.exists(path):
return path
return part_exists(os.path.dirname(path))
if arguments.check_creatable:
directory = part_exists(directory)
if not directory:
directory = '.'
else:
if not os.path.exists(directory):
# doesn't exist
print('ValidationError: 1')
if not os.path.isdir(directory): if not os.path.isdir(directory):
# is not a directory
print('ValidationError: 2') print('ValidationError: 2')
if not os.access(directory, os.R_OK): if not os.access(directory, os.R_OK):
# is not readable
print('ValidationError: 3') print('ValidationError: 3')
if arguments.check_writable and not os.access(directory, os.W_OK): if arguments.check_writable or arguments.check_creatable:
print('ValidationError: 4') if not os.access(directory, os.W_OK):
# is not writable
print('ValidationError: 4')
def main(): def main():

View File

@ -26,11 +26,11 @@ from plinth import frontpage, menu
from plinth.daemon import Daemon from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group from plinth.modules.users import register_group, add_user_to_share_group
from .manifest import backup, clients # noqa, pylint: disable=unused-import from .manifest import backup, clients # noqa, pylint: disable=unused-import
version = 5 version = 6
managed_services = ['deluged', 'deluge-web'] managed_services = ['deluged', 'deluge-web']
@ -109,4 +109,5 @@ def setup(helper, old_version=None):
"""Install and configure the module.""" """Install and configure the module."""
helper.install(managed_packages) helper.install(managed_packages)
helper.call('post', actions.superuser_run, 'deluge', ['setup']) helper.call('post', actions.superuser_run, 'deluge', ['setup'])
add_user_to_share_group(reserved_usernames[0])
helper.call('post', app.enable) helper.call('post', app.enable)

View File

@ -0,0 +1,37 @@
#
# 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/>.
#
"""
Forms for Deluge app.
"""
from django.utils.translation import ugettext_lazy as _
from plinth.modules.deluge import reserved_usernames
from plinth.modules.storage.forms import (DirectorySelectForm,
DirectoryValidator)
class DelugeForm(DirectorySelectForm):
"""Deluge configuration form"""
def __init__(self, *args, **kw):
validator = DirectoryValidator(username=reserved_usernames[0],
check_creatable=True)
super(DelugeForm, self).__init__(
title=_('Download directory'),
default='/var/lib/deluged/Downloads/', validator=validator, *args,
**kw)

View File

@ -20,14 +20,8 @@ URLs for the Deluge module.
from django.conf.urls import url from django.conf.urls import url
from plinth.modules import deluge from .views import DelugeAppView
from plinth.views import AppView
urlpatterns = [ urlpatterns = [
url( url(r'^apps/deluge/$', DelugeAppView.as_view(), name='index')
r'^apps/deluge/$',
AppView.as_view(name=deluge.name, description=deluge.description,
clients=deluge.clients, app_id='deluge',
manual_page=deluge.manual_page,
icon_filename=deluge.icon_filename), name='index'),
] ]

View File

@ -0,0 +1,69 @@
#
# 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/>.
#
"""
Django views for Deluge.
"""
import json
import os
from django.contrib import messages
from django.utils.translation import ugettext as _
from plinth import actions, views
from plinth.modules import deluge
from .forms import DelugeForm
class DelugeAppView(views.AppView):
"""Serve configuration page."""
clients = deluge.clients
name = deluge.name
description = deluge.description
diagnostics_module_name = 'deluge'
form_class = DelugeForm
app_id = 'deluge'
manual_page = deluge.manual_page
icon_filename = deluge.icon_filename
def get_initial(self):
"""Get current Deluge server settings."""
status = super().get_initial()
configuration = json.loads(
actions.superuser_run('deluge', ['get-configuration']))
status['storage_path'] = os.path.normpath(
configuration['download_location'])
return status
def form_valid(self, form):
"""Apply the changes submitted in the form."""
old_status = form.initial
new_status = form.cleaned_data
# don't change the configuration if the application was disabled
if new_status['is_enabled'] or not old_status['is_enabled']:
if old_status['storage_path'] != new_status['storage_path']:
new_configuration = [
'download_location', new_status['storage_path']
]
actions.superuser_run(
'deluge', ['set-configuration'] + new_configuration)
messages.success(self.request, _('Configuration updated'))
return super().form_valid(form)

View File

@ -60,14 +60,18 @@ def is_module_enabled(name):
class DirectoryValidator: class DirectoryValidator:
username = None username = None
check_writable = False check_writable = False
check_creatable = False
add_user_to_share_group = False add_user_to_share_group = False
service_to_restart = None service_to_restart = None
def __init__(self, username=None, check_writable=None): def __init__(self, username=None, check_writable=None,
check_creatable=None):
if username is not None: if username is not None:
self.username = username self.username = username
if check_writable is not None: if check_writable is not None:
self.check_writable = check_writable self.check_writable = check_writable
if check_creatable is not None:
self.check_creatable = check_creatable
def __call__(self, value): def __call__(self, value):
"""Validate a directory.""" """Validate a directory."""
@ -75,7 +79,9 @@ class DirectoryValidator:
raise ValidationError(_('Invalid directory name.'), 'invalid') raise ValidationError(_('Invalid directory name.'), 'invalid')
command = ['validate-directory', '--path', value] command = ['validate-directory', '--path', value]
if self.check_writable: if self.check_creatable:
command.append('--check-creatable')
elif self.check_writable:
command.append('--check-writable') command.append('--check-writable')
if self.username: if self.username:

View File

@ -247,11 +247,14 @@ class TestActions:
subprocess.run(command, stdout=subprocess.DEVNULL, subprocess.run(command, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=True) stderr=subprocess.DEVNULL, check=True)
def assert_validate_directory(self, path, error, check_writable=False): def assert_validate_directory(self, path, error, check_writable=False,
check_creatable=False):
"""Perform directory validation checks.""" """Perform directory validation checks."""
action_command = ['storage', 'validate-directory', '--path', path] action_command = ['storage', 'validate-directory', '--path', path]
if check_writable: if check_writable:
action_command += ['--check-writable'] action_command += ['--check-writable']
if check_creatable:
action_command += ['--check-creatable']
proc = self.call_action(action_command, stderr=subprocess.PIPE, proc = self.call_action(action_command, stderr=subprocess.PIPE,
stdout=subprocess.PIPE) stdout=subprocess.PIPE)
output = proc.stdout.decode() output = proc.stdout.decode()
@ -291,3 +294,16 @@ class TestActions:
"""Test that directory writable validation returns expected output.""" """Test that directory writable validation returns expected output."""
self.assert_validate_directory(directory['path'], directory['error'], self.assert_validate_directory(directory['path'], directory['error'],
check_writable=True) check_writable=True)
@pytest.mark.usefixtures('needs_not_root')
@pytest.mark.parametrize('directory', [{
'path': '/var/lib/plinth_storage_test_not_exists',
'error': '4'
}, {
'path': '/tmp/plint_storage_test_not_exists',
'error': ''
}])
def test_validate_directory_creatable(self, directory):
"""Test that directory creatable validation returns expected output."""
self.assert_validate_directory(directory['path'], directory['error'],
check_creatable=True)