app: Separate app enable/disable form from config form

- Introduce new API to mark an app that it can't be disabled.

- Mark jsxc, storage, config, upgrade and firewall apps as can't be disabled.

- Fixed functional tests

- Replaced AppForm with forms.Form in all modules' forms.py.

- Remove app.template.js.

- Remove unused styles.

- Remove app status checks in form_valid of Deluge, Diaspora, Matrix, Ejabberd,
MediaWiki, Storage, Transmission, Quassel

- Purge unused is_enabled context variables (Ikiwiki)

- ejabberd: Minor cleanup in template

- jsxc: Cleanup unneeded overrides

- tahoe: Cleanup unnecessary overrides

Tests performed:

- For all apps affected, test enable/disable button works and submitting
configuration form works: with changes updates message and without changes
'settings unchanged' message.
  - avahi
  - bind
  - cockpit
  - SKIP: coquelicot
  - datetime
  - deluge
  - SKIP: diaspora
  - ejabberd
  - gitweb
  - i2p
  - infinoted
  - ikiwiki
  - matrixsynapse
  - mediawiki
  - minetest
  - minidlna
  - mldonkey
  - mumble
  - pagekite
  - privoxy
  - quassel
  - radicale
  - roundcube
  - SKIP: samba
  - searx
  - SKIP: shaarli
  - shadowsocks
  - ssh
  - tahoe
  - transmission
  - FAIL: tt-rss (not installable)
  - wireguard
- Deluge test that configuration changes when app is disabled work
- Quassel test that setting the domain works when app is diabled
- Transmission test that setting the domain works when app is diabled
- Ikiwiki create form works properly
- Enable/disable button appears as expected when enabled and when disabled
- Enable/disable button works without Javascript
- Functional tests work for affected apps, Tor and OpenVPN
- AppForm is removed from developer documentation
  - Forms reference
  - Customizing tutorial
- Test all apps using directory select form
  - Transmission
  - Deluge
- Visit each template that overrides block configuration and ensure that it is
loaded properly and the display is as expected.
- All apps that use AppView that are not tested above should not have an
enable/disable button. That is JSXC, update, config, firewall, storage, users.

Signed-off-by: Alice Kile <buoyantair@protonmail.com>
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
This commit is contained in:
Alice Kile 2020-03-13 10:58:54 +05:30 committed by Veiko Aasa
parent bd8aee17d5
commit 0b5b384651
No known key found for this signature in database
GPG Key ID: 478539CAE680674E
47 changed files with 178 additions and 309 deletions

View File

@ -3,9 +3,6 @@
Forms Forms
----- -----
.. autoclass:: plinth.forms.AppForm
:members:
.. autoclass:: plinth.forms.DomainSelectionForm .. autoclass:: plinth.forms.DomainSelectionForm
:members: :members:

View File

@ -63,10 +63,8 @@ provide options to the user. Add the following to ``forms.py``.
from django import forms from django import forms
from plinth.forms import AppForm
class TransmissionForm(forms.Form): # pylint: disable=W0232
class TransmissionForm(AppForm): # pylint: disable=W0232
"""Transmission configuration form""" """Transmission configuration form"""
download_dir = forms.CharField( download_dir = forms.CharField(
label='Download directory', label='Download directory',

View File

@ -91,14 +91,17 @@ def is_installed(browser, app_name):
def _change_app_status(browser, app_name, change_status_to='enabled'): def _change_app_status(browser, app_name, change_status_to='enabled'):
"""Enable or disable application.""" """Enable or disable application."""
button = browser.find_by_id('app-toggle-button') button = browser.find_by_css('button[name="app_enable_disable_button"]')
checkbox_id = get_app_checkbox_id(app_name)
checkbox = browser.find_by_id(checkbox_id)
if button: if button:
if checkbox.checked and change_status_to == 'disabled' or ( should_enable_field = browser.find_by_id('id_should_enable')
not checkbox.checked and change_status_to == 'enabled'): if (should_enable_field.value == 'False'
and change_status_to == 'disabled') or (
should_enable_field.value == 'True'
and change_status_to == 'enabled'):
interface.submit(browser, element=button) interface.submit(browser, element=button)
else: else:
checkbox_id = get_app_checkbox_id(app_name)
_change_status(browser, app_name, checkbox_id, change_status_to) _change_status(browser, app_name, checkbox_id, change_status_to)
if app_name in apps_with_loaders: if app_name in apps_with_loaders:

View File

@ -14,14 +14,21 @@ class App:
variation their behavior by choosing which components to have and by variation their behavior by choosing which components to have and by
customizing the components themselves. customizing the components themselves.
'app_id' property of the app must string that is a globally unique ID. This 'app_id' property of the app must be a string that is a globally unique ID.
is typically also the name of the python module handling the app. So, it This is typically also the name of the python module handling the app. So,
should be all lower-case English alphabet and digits without any special it should be all lower-case English alphabet and digits without any special
characters. characters.
'can_be_disabled' is a boolean indicating whether an app can be disabled by
the user. Enable/disable button for this app will not be shown. Default
value is True, so the app can be disabled.
""" """
app_id = None app_id = None
can_be_disabled = True
_all_apps = collections.OrderedDict() _all_apps = collections.OrderedDict()
def __init__(self): def __init__(self):

View File

@ -17,11 +17,10 @@ from django.utils.translation import ugettext_lazy as _
import plinth import plinth
class AppForm(forms.Form): class AppEnableDisableForm(forms.Form):
"""Generic configuration form for an app.""" """Form to enable / disable an app."""
is_enabled = forms.BooleanField( should_enable = forms.BooleanField(widget=forms.HiddenInput,
widget=CheckboxInput(attrs={'id': 'app-toggle-input'}), required=False)
label=_('Enable application'), required=False)
class DomainSelectionForm(forms.Form): class DomainSelectionForm(forms.Form):

View File

@ -7,8 +7,6 @@ from django import forms
from django.core.validators import validate_ipv46_address from django.core.validators import validate_ipv46_address
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
def validate_ips(ips): def validate_ips(ips):
"""Validate that ips is a list of IP addresses, separated by space.""" """Validate that ips is a list of IP addresses, separated by space."""
@ -16,7 +14,7 @@ def validate_ips(ips):
validate_ipv46_address(ip_addr) validate_ipv46_address(ip_addr)
class BindForm(AppForm): class BindForm(forms.Form):
"""BIND configuration form""" """BIND configuration form"""
forwarders = forms.CharField( forwarders = forms.CharField(
label=_('Forwarders'), required=False, validators=[validate_ips], label=_('Forwarders'), required=False, validators=[validate_ips],

View File

@ -42,6 +42,8 @@ class ConfigApp(app_module.App):
app_id = 'config' app_id = 'config'
can_be_disabled = False
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
super().__init__() super().__init__()

View File

@ -6,10 +6,8 @@ Plinth form for configuring Coquelicot.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class CoquelicotForm(forms.Form): # pylint: disable=W0232
class CoquelicotForm(AppForm): # pylint: disable=W0232
"""Coquelicot configuration form.""" """Coquelicot configuration form."""
upload_password = forms.CharField( upload_password = forms.CharField(
label=_('Upload Password'), label=_('Upload Password'),

View File

@ -9,12 +9,10 @@ import subprocess
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DateTimeForm(AppForm): class DateTimeForm(forms.Form):
"""Date/time configuration form.""" """Date/time configuration form."""
time_zone = forms.ChoiceField( time_zone = forms.ChoiceField(
label=_('Time Zone'), label=_('Time Zone'),

View File

@ -32,15 +32,13 @@ class DelugeAppView(views.AppView):
old_status = form.initial old_status = form.initial
new_status = form.cleaned_data new_status = form.cleaned_data
# don't change the configuration if the application was disabled if old_status['storage_path'] != new_status['storage_path']:
if new_status['is_enabled'] or not old_status['is_enabled']: new_configuration = [
if old_status['storage_path'] != new_status['storage_path']: 'download_location', new_status['storage_path']
new_configuration = [ ]
'download_location', new_status['storage_path']
]
actions.superuser_run('deluge', ['set-configuration'] + actions.superuser_run('deluge',
new_configuration) ['set-configuration'] + new_configuration)
messages.success(self.request, _('Configuration updated')) messages.success(self.request, _('Configuration updated'))
return super().form_valid(form) return super().form_valid(form)

View File

@ -6,10 +6,8 @@ Forms for configuring diaspora*
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class DiasporaAppForm(forms.Form):
class DiasporaAppForm(AppForm):
"""Service Form with additional fields for diaspora*""" """Service Form with additional fields for diaspora*"""
is_user_registrations_enabled = forms.BooleanField( is_user_registrations_enabled = forms.BooleanField(
label=_('Enable new user registrations'), required=False) label=_('Enable new user registrations'), required=False)

View File

@ -65,16 +65,10 @@ class DiasporaAppView(AppView):
def form_valid(self, form): def form_valid(self, form):
"""Enable/disable user registrations""" """Enable/disable user registrations"""
old_enabled = form.initial['is_enabled']
new_enabled = form.cleaned_data['is_enabled']
old_registration = form.initial['is_user_registrations_enabled'] old_registration = form.initial['is_user_registrations_enabled']
new_registration = form.cleaned_data['is_user_registrations_enabled'] new_registration = form.cleaned_data['is_user_registrations_enabled']
if old_registration == new_registration: if old_registration != new_registration:
if old_enabled == new_enabled:
if not self.request._messages._queued_messages:
messages.info(self.request, _('Setting unchanged'))
else:
if new_registration: if new_registration:
diaspora.enable_user_registrations() diaspora.enable_user_registrations()
messages.success(self.request, _('User registrations enabled')) messages.success(self.request, _('User registrations enabled'))

View File

@ -7,11 +7,10 @@ from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth import cfg from plinth import cfg
from plinth.forms import AppForm
from plinth.utils import format_lazy from plinth.utils import format_lazy
class EjabberdForm(AppForm): class EjabberdForm(forms.Form):
"""Ejabberd configuration form.""" """Ejabberd configuration form."""
MAM_enabled = forms.BooleanField( MAM_enabled = forms.BooleanField(
label=_('Enable Message Archive Management'), required=False, label=_('Enable Message Archive Management'), required=False,

View File

@ -28,18 +28,3 @@
</p> </p>
{% endblock %} {% endblock %}
{% block configuration %}
<h3>{% trans "Configuration" %}</h3>
<form id="app-form" class="form form-configuration" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Update setup" %}"/>
</form>
{% endblock %}

View File

@ -36,21 +36,8 @@ class EjabberdAppView(AppView):
"""Enable/disable a service and set messages.""" """Enable/disable a service and set messages."""
old_status = form.initial old_status = form.initial
new_status = form.cleaned_data new_status = form.cleaned_data
app_same = old_status['is_enabled'] == new_status['is_enabled']
mam_same = old_status['MAM_enabled'] == new_status['MAM_enabled']
if app_same and mam_same: if old_status['MAM_enabled'] != new_status['MAM_enabled']:
# TODO: find a more reliable/official way to check whether the
# request has messages attached.
if not self.request._messages._queued_messages:
messages.info(self.request, _('Setting unchanged'))
elif not app_same:
if new_status['is_enabled']:
self.app.enable()
else:
self.app.disable()
if not mam_same:
# note ejabberd action "enable" or "disable" restarts, if running # note ejabberd action "enable" or "disable" restarts, if running
if new_status['MAM_enabled']: if new_status['MAM_enabled']:
actions.superuser_run('ejabberd', ['mam', 'enable']) actions.superuser_run('ejabberd', ['mam', 'enable'])

View File

@ -56,6 +56,8 @@ class FirewallApp(app_module.App):
app_id = 'firewall' app_id = 'firewall'
can_be_disabled = False
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
super().__init__() super().__init__()

View File

@ -54,12 +54,10 @@ def create(request):
else: else:
form = IkiwikiCreateForm(prefix='ikiwiki') form = IkiwikiCreateForm(prefix='ikiwiki')
return TemplateResponse( return TemplateResponse(request, 'ikiwiki_create.html', {
request, 'ikiwiki_create.html', { 'title': ikiwiki.app.info.name,
'title': ikiwiki.app.info.name, 'form': form
'form': form, })
'is_enabled': ikiwiki.app.is_enabled(),
})
def _create_wiki(request, name, admin_name, admin_password): def _create_wiki(request, name, admin_name, admin_password):

View File

@ -34,6 +34,8 @@ class JSXCApp(app_module.App):
app_id = 'jsxc' app_id = 'jsxc'
can_be_disabled = False
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
super().__init__() super().__init__()

View File

@ -1,9 +0,0 @@
{% extends "app.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block configuration %}
{% endblock %}

View File

@ -6,9 +6,11 @@ URLs for the JSXC module
from django.conf.urls import url from django.conf.urls import url
from stronghold.decorators import public from stronghold.decorators import public
from .views import JSXCAppView, JsxcView from plinth.views import AppView
from .views import JsxcView
urlpatterns = [ urlpatterns = [
url(r'^apps/jsxc/$', JSXCAppView.as_view(), name='index'), url(r'^apps/jsxc/$', AppView.as_view(app_id='jsxc'), name='index'),
url(r'^apps/jsxc/jsxc/$', public(JsxcView.as_view()), name='jsxc') url(r'^apps/jsxc/jsxc/$', public(JsxcView.as_view()), name='jsxc')
] ]

View File

@ -6,13 +6,6 @@ Views for the JSXC module
from django.views.generic import TemplateView from django.views.generic import TemplateView
from plinth.modules import config from plinth.modules import config
from plinth.views import AppView
class JSXCAppView(AppView):
"""Show ejabberd as an app."""
app_id = 'jsxc'
template_name = 'jsxc.html'
class JsxcView(TemplateView): class JsxcView(TemplateView):

View File

@ -6,10 +6,8 @@ Forms for the Matrix Synapse module.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class MatrixSynapseForm(forms.Form):
class MatrixSynapseForm(AppForm):
enable_public_registration = forms.BooleanField( enable_public_registration = forms.BooleanField(
label=_('Enable Public Registration'), required=False, help_text=_( label=_('Enable Public Registration'), required=False, help_text=_(
'Enabling public registration means that anyone on the Internet ' 'Enabling public registration means that anyone on the Internet '

View File

@ -74,21 +74,9 @@ class MatrixSynapseAppView(AppView):
"""Handle valid form submission.""" """Handle valid form submission."""
old_config = self.get_initial() old_config = self.get_initial()
new_config = form.cleaned_data new_config = form.cleaned_data
app_same = old_config['is_enabled'] == new_config['is_enabled']
pubreg_same = old_config['enable_public_registration'] == \ pubreg_same = old_config['enable_public_registration'] == \
new_config['enable_public_registration'] new_config['enable_public_registration']
if app_same and pubreg_same:
# TODO: find a more reliable/official way to check whether the
# request has messages attached.
if not self.request._messages._queued_messages:
messages.info(self.request, _('Setting unchanged'))
elif not app_same:
if new_config['is_enabled']:
self.app.enable()
else:
self.app.disable()
if not pubreg_same: if not pubreg_same:
# note action public-registration restarts, if running now # note action public-registration restarts, if running now
if new_config['enable_public_registration']: if new_config['enable_public_registration']:

View File

@ -8,8 +8,6 @@ import pathlib
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
def get_skins(): def get_skins():
"""Return a list of available skins as choice field values.""" """Return a list of available skins as choice field values."""
@ -21,7 +19,7 @@ def get_skins():
if skin.is_dir()] if skin.is_dir()]
class MediaWikiForm(AppForm): # pylint: disable=W0232 class MediaWikiForm(forms.Form): # pylint: disable=W0232
"""MediaWiki configuration form.""" """MediaWiki configuration form."""
password = forms.CharField( password = forms.CharField(
label=_('Administrator Password'), help_text=_( label=_('Administrator Password'), help_text=_(

View File

@ -42,27 +42,12 @@ class MediaWikiAppView(views.AppView):
def is_unchanged(key): def is_unchanged(key):
return old_config[key] == new_config[key] return old_config[key] == new_config[key]
app_same = is_unchanged('is_enabled')
pub_reg_same = is_unchanged('enable_public_registrations')
private_mode_same = is_unchanged('enable_private_mode')
default_skin_same = is_unchanged('default_skin')
if new_config['password']: if new_config['password']:
actions.superuser_run('mediawiki', ['change-password'], actions.superuser_run('mediawiki', ['change-password'],
input=new_config['password'].encode()) input=new_config['password'].encode())
messages.success(self.request, _('Password updated')) messages.success(self.request, _('Password updated'))
if (app_same and pub_reg_same and private_mode_same if not is_unchanged('enable_public_registrations'):
and default_skin_same):
if not self.request._messages._queued_messages:
messages.info(self.request, _('Setting unchanged'))
elif not app_same:
if new_config['is_enabled']:
self.app.enable()
else:
self.app.disable()
if not pub_reg_same:
# note action public-registration restarts, if running now # note action public-registration restarts, if running now
if new_config['enable_public_registrations']: if new_config['enable_public_registrations']:
if not new_config['enable_private_mode']: if not new_config['enable_private_mode']:
@ -80,7 +65,7 @@ class MediaWikiAppView(views.AppView):
messages.success(self.request, messages.success(self.request,
_('Public registrations disabled')) _('Public registrations disabled'))
if not private_mode_same: if not is_unchanged('enable_private_mode'):
if new_config['enable_private_mode']: if new_config['enable_private_mode']:
actions.superuser_run('mediawiki', ['private-mode', 'enable']) actions.superuser_run('mediawiki', ['private-mode', 'enable'])
messages.success(self.request, _('Private mode enabled')) messages.success(self.request, _('Private mode enabled'))
@ -95,7 +80,7 @@ class MediaWikiAppView(views.AppView):
shortcut = mediawiki.app.get_component('shortcut-mediawiki') shortcut = mediawiki.app.get_component('shortcut-mediawiki')
shortcut.login_required = new_config['enable_private_mode'] shortcut.login_required = new_config['enable_private_mode']
if not default_skin_same: if not is_unchanged('default_skin'):
actions.superuser_run( actions.superuser_run(
'mediawiki', ['set-default-skin', new_config['default_skin']]) 'mediawiki', ['set-default-skin', new_config['default_skin']])
messages.success(self.request, _('Default skin changed')) messages.success(self.request, _('Default skin changed'))

View File

@ -6,10 +6,8 @@ Forms for minetest module.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class MinetestForm(forms.Form):
class MinetestForm(AppForm):
"""Minetest configuration form""" """Minetest configuration form"""
max_players = forms.IntegerField( max_players = forms.IntegerField(
label=_('Maximum number of players'), required=True, min_value=1, label=_('Maximum number of players'), required=True, min_value=1,

View File

@ -6,10 +6,8 @@ FreedomBox configuration form for MiniDLNA server.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class MiniDLNAServerForm(forms.Form):
class MiniDLNAServerForm(AppForm):
"""MiniDLNA server configuration form.""" """MiniDLNA server configuration form."""
media_dir = forms.CharField( media_dir = forms.CharField(
label=_('Media Files Directory'), label=_('Media Files Directory'),

View File

@ -6,10 +6,8 @@ Mumble server configuration form
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class MumbleForm(forms.Form):
class MumbleForm(AppForm):
"""Mumble server configuration""" """Mumble server configuration"""
super_user_password = forms.CharField( super_user_password = forms.CharField(
max_length=20, max_length=20,
@ -17,7 +15,6 @@ class MumbleForm(AppForm):
widget=forms.PasswordInput, widget=forms.PasswordInput,
help_text=_( help_text=_(
'Optional. Leave this field blank to keep the current password. ' 'Optional. Leave this field blank to keep the current password. '
'SuperUser password can be used to manage permissions in Mumble.' 'SuperUser password can be used to manage permissions in Mumble.'),
),
required=False, required=False,
) )

View File

@ -10,7 +10,6 @@ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy from django.utils.translation import ugettext_lazy
from plinth.errors import ActionError from plinth.errors import ActionError
from plinth.forms import AppForm
from . import utils from . import utils
@ -41,7 +40,7 @@ class SubdomainWidget(forms.widgets.TextInput):
</div>""".format(inputfield, self.domain) </div>""".format(inputfield, self.domain)
class ConfigurationForm(AppForm): class ConfigurationForm(forms.Form):
"""Configure PageKite credentials and frontend""" """Configure PageKite credentials and frontend"""
server_domain = forms.CharField( server_domain = forms.CharField(

View File

@ -6,7 +6,6 @@ Forms for Quassel app.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
from plinth.modules import quassel from plinth.modules import quassel
@ -15,7 +14,7 @@ def get_domain_choices():
return ((domain, domain) for domain in quassel.get_available_domains()) return ((domain, domain) for domain in quassel.get_available_domains())
class QuasselForm(AppForm): class QuasselForm(forms.Form):
"""Form to select a TLS domain for Quassel.""" """Form to select a TLS domain for Quassel."""
domain = forms.ChoiceField( domain = forms.ChoiceField(

View File

@ -1,5 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from plinth.modules import quassel from plinth.modules import quassel
from plinth.views import AppView from plinth.views import AppView
@ -20,11 +23,10 @@ class QuasselAppView(AppView):
def form_valid(self, form): def form_valid(self, form):
"""Change the access control of Radicale service.""" """Change the access control of Radicale service."""
data = form.cleaned_data data = form.cleaned_data
app_disable = form.initial['is_enabled'] and not data['is_enabled'] if quassel.get_domain() != data['domain']:
if not app_disable and quassel.get_domain() != data['domain']:
quassel.set_domain(data['domain']) quassel.set_domain(data['domain'])
quassel.app.get_component( quassel.app.get_component(
'letsencrypt-quassel').setup_certificates() 'letsencrypt-quassel').setup_certificates()
messages.success(self.request, _('Configuration updated'))
return super().form_valid(form) return super().form_valid(form)

View File

@ -7,7 +7,6 @@ from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth import cfg from plinth import cfg
from plinth.forms import AppForm
from plinth.utils import format_lazy from plinth.utils import format_lazy
CHOICES = [ CHOICES = [
@ -26,7 +25,7 @@ CHOICES = [
] ]
class RadicaleForm(AppForm): class RadicaleForm(forms.Form):
"""Specialized configuration form for radicale service.""" """Specialized configuration form for radicale service."""
access_rights = forms.ChoiceField(choices=CHOICES, required=True, access_rights = forms.ChoiceField(choices=CHOICES, required=True,
widget=forms.RadioSelect()) widget=forms.RadioSelect())

View File

@ -6,10 +6,8 @@ Django form for configuring Searx.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class SearxForm(forms.Form):
class SearxForm(AppForm):
"""Searx configuration form.""" """Searx configuration form."""
safe_search = forms.ChoiceField( safe_search = forms.ChoiceField(
label=_('Safe Search'), help_text=_( label=_('Safe Search'), help_text=_(

View File

@ -6,7 +6,6 @@ FreedomBox app for configuring Shadowsocks.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
from plinth.utils import format_lazy from plinth.utils import format_lazy
METHODS = [('chacha20-ietf-poly1305', METHODS = [('chacha20-ietf-poly1305',
@ -32,7 +31,7 @@ class TrimmedCharField(forms.CharField):
return super(TrimmedCharField, self).clean(value) return super(TrimmedCharField, self).clean(value)
class ShadowsocksForm(AppForm): class ShadowsocksForm(forms.Form):
"""Shadowsocks configuration form""" """Shadowsocks configuration form"""
server = TrimmedCharField(label=_('Server'), server = TrimmedCharField(label=_('Server'),
help_text=_('Server hostname or IP address')) help_text=_('Server hostname or IP address'))

View File

@ -6,10 +6,8 @@ FreedomBox configuration form for OpenSSH server.
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth.forms import AppForm
class SSHServerForm(forms.Form):
class SSHServerForm(AppForm):
"""SSH server configuration form.""" """SSH server configuration form."""
password_auth_disabled = forms.BooleanField( password_auth_disabled = forms.BooleanField(
label=_('Disable password authentication'), label=_('Disable password authentication'),

View File

@ -45,6 +45,8 @@ class StorageApp(app_module.App):
app_id = 'storage' app_id = 'storage'
can_be_disabled = False
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
super().__init__() super().__init__()

View File

@ -11,7 +11,6 @@ from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth import actions, module_loader from plinth import actions, module_loader
from plinth.forms import AppForm
from plinth.modules import storage from plinth.modules import storage
@ -78,8 +77,8 @@ class DirectoryValidator:
if 'ValidationError' in output: if 'ValidationError' in output:
error_nr = int(output.strip().split()[1]) error_nr = int(output.strip().split()[1])
if error_nr == 1: if error_nr == 1:
raise ValidationError( raise ValidationError(_('Directory does not exist.'),
_('Directory does not exist.'), 'invalid') 'invalid')
elif error_nr == 2: elif error_nr == 2:
raise ValidationError(_('Path is not a directory.'), 'invalid') raise ValidationError(_('Path is not a directory.'), 'invalid')
elif error_nr == 3: elif error_nr == 3:
@ -90,12 +89,12 @@ class DirectoryValidator:
_('Directory is not writable by the user.'), 'invalid') _('Directory is not writable by the user.'), 'invalid')
class DirectorySelectForm(AppForm): class DirectorySelectForm(forms.Form):
"""Directory selection form.""" """Directory selection form."""
storage_dir = forms.ChoiceField(choices=[], label=_('Directory'), storage_dir = forms.ChoiceField(choices=[], label=_('Directory'),
required=True) required=True)
storage_subdir = forms.CharField( storage_subdir = forms.CharField(label=_('Subdirectory (optional)'),
label=_('Subdirectory (optional)'), required=False) required=False)
def __init__(self, title=None, default='/', validator=DirectoryValidator, def __init__(self, title=None, default='/', validator=DirectoryValidator,
*args, **kwargs): *args, **kwargs):
@ -108,16 +107,15 @@ class DirectorySelectForm(AppForm):
def clean(self): def clean(self):
"""Clean and validate form data.""" """Clean and validate form data."""
if self.cleaned_data['is_enabled'] or not self.initial['is_enabled']: storage_dir = self.cleaned_data['storage_dir']
storage_dir = self.cleaned_data['storage_dir'] storage_subdir = self.cleaned_data['storage_subdir']
storage_subdir = self.cleaned_data['storage_subdir'] if storage_dir != '/':
if storage_dir != '/': storage_subdir = storage_subdir.lstrip('/')
storage_subdir = storage_subdir.lstrip('/') storage_path = os.path.realpath(
storage_path = os.path.realpath( os.path.join(storage_dir, storage_subdir))
os.path.join(storage_dir, storage_subdir)) if self.validator:
if self.validator: self.validator(storage_path)
self.validator(storage_path) self.cleaned_data.update({'storage_path': storage_path})
self.cleaned_data.update({'storage_path': storage_path})
def get_initial(self, choices): def get_initial(self, choices):
"""Get initial form data.""" """Get initial form data."""

View File

@ -24,17 +24,7 @@
{% endblock %} {% endblock %}
{% block configuration %} {% block configuration %}
<h3>{% trans "Configuration" %}</h3> {{ block.super }}
<form class="form form-configuration" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Update setup" %}"/>
</form>
<br/>
<h4>{% trans "Local introducer" %}</h4> <h4>{% trans "Local introducer" %}</h4>
<table class="table table-bordered local-introducers"> <table class="table table-bordered local-introducers">

View File

@ -37,16 +37,13 @@ class TransmissionAppView(views.AppView):
"""Apply the changes submitted in the form.""" """Apply the changes submitted in the form."""
old_status = form.initial old_status = form.initial
new_status = form.cleaned_data new_status = form.cleaned_data
if old_status['storage_path'] != new_status['storage_path']:
new_configuration = {
'download-dir': new_status['storage_path'],
}
if new_status['is_enabled'] or not old_status['is_enabled']: actions.superuser_run('transmission', ['merge-configuration'],
if old_status['storage_path'] != new_status['storage_path']: input=json.dumps(new_configuration).encode())
new_configuration = { messages.success(self.request, _('Configuration updated'))
'download-dir': new_status['storage_path'],
}
actions.superuser_run(
'transmission', ['merge-configuration'],
input=json.dumps(new_configuration).encode())
messages.success(self.request, _('Configuration updated'))
return super().form_valid(form) return super().form_valid(form)

View File

@ -31,6 +31,8 @@ class UpgradesApp(app_module.App):
app_id = 'upgrades' app_id = 'upgrades'
can_be_disabled = False
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
super().__init__() super().__init__()

View File

@ -7,7 +7,6 @@ from django.contrib import messages
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
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.edit import FormView
from plinth import actions from plinth import actions
from plinth.errors import ActionError from plinth.errors import ActionError
@ -52,10 +51,8 @@ class UpgradesConfigurationView(AppView):
else: else:
messages.success(self.request, messages.success(self.request,
_('Automatic upgrades disabled')) _('Automatic upgrades disabled'))
else:
messages.info(self.request, _('Settings unchanged'))
return FormView.form_valid(self, form) return super().form_valid(form)
def is_package_manager_busy(): def is_package_manager_busy():

View File

@ -56,6 +56,8 @@ class UsersApp(app_module.App):
app_id = 'users' app_id = 'users'
can_be_disabled = False
def __init__(self): def __init__(self):
"""Create components for the app.""" """Create components for the app."""
super().__init__() super().__init__()

View File

@ -28,15 +28,17 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
<div id='app-toggle-container' class="app-toggle-container"> {% if app_enable_disable_form %}
{% if is_enabled %} <form class="form form-app-enable-disable" method="post">
<button id="app-toggle-button" value="False" {% csrf_token %}
class="btn toggle-button toggle-button--toggled"></button>
{% else %} {{ app_enable_disable_form }}
<button id="app-toggle-button" value="True"
class="btn toggle-button"></button> <button name="app_enable_disable_button" type="submit"
{% endif %} class="btn toggle-button {{ is_enabled|yesno:'toggle-button--toggled,' }}">
</div> </button>
</form>
{% endif %}
</section> </section>
{% block description %} {% block description %}

View File

@ -42,16 +42,18 @@
{% endblock %} {% endblock %}
{% block configuration %} {% block configuration %}
<h3>{% trans "Configuration" %}</h3> {% if form %}
<form id="app-form" class="form form-configuration" method="post"> <h3>{% trans "Configuration" %}</h3>
{% csrf_token %}
{{ form|bootstrap }} <form id="app-form" class="form form-configuration" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-primary" {{ form|bootstrap }}
value="{% trans "Update setup" %}"/>
</form> <input type="submit" class="btn btn-primary"
value="{% trans "Update setup" %}"/>
</form>
{% endif %}
{% endblock %} {% endblock %}
<script src="{% static 'theme/js/app.template.js' %}"></script>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@ import time
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import reverse from django.urls import reverse
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
@ -114,8 +114,8 @@ class AppView(FormView):
retrieve the App instance for the app that is needed for basic information retrieve the App instance for the app that is needed for basic information
and operations such as enabling/disabling the app. and operations such as enabling/disabling the app.
'form_class' is the Django form class that is used by this view. By default 'form_class' is the Django form class that is used by this view. It may be
the AppForm class is used. None if the app does not have a configuration form. Default is None.
'template_name' is the template used to render this view. By default it is 'template_name' is the template used to render this view. By default it is
app.html. It may be overridden with a template that derives from app.html app.html. It may be overridden with a template that derives from app.html
@ -127,7 +127,7 @@ class AppView(FormView):
forward ports on their router for this app to work properly. forward ports on their router for this app to work properly.
""" """
form_class = forms.AppForm form_class = None
app_id = None app_id = None
template_name = 'app.html' template_name = 'app.html'
port_forwarding_info = None port_forwarding_info = None
@ -137,6 +137,17 @@ class AppView(FormView):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._common_status = None self._common_status = None
def post(self, request, *args, **kwargs):
"""Handle app enable/disable button separately."""
if 'app_enable_disable_button' not in request.POST:
return super().post(request, *args, **kwargs)
form = forms.AppEnableDisableForm(data=self.request.POST)
if form.is_valid():
return self.enable_disable_form_valid(form)
return HttpResponseBadRequest('Invalid form submission')
@property @property
def success_url(self): def success_url(self):
return self.request.path return self.request.path
@ -149,6 +160,17 @@ class AppView(FormView):
return app.App.get(self.app_id) return app.App.get(self.app_id)
def get_form(self, *args, **kwargs):
"""Return an instance of this view's form.
Also the form_class for this view to be None.
"""
if not self.form_class:
return None
return super().get_form(*args, **kwargs)
def _get_common_status(self): def _get_common_status(self):
"""Return the status needed for form and template. """Return the status needed for form and template.
@ -170,24 +192,36 @@ class AppView(FormView):
def form_valid(self, form): def form_valid(self, form):
"""Enable/disable a service and set messages.""" """Enable/disable a service and set messages."""
old_status = form.initial if not self.request._messages._queued_messages:
new_status = form.cleaned_data messages.info(self.request, _('Setting unchanged'))
if old_status['is_enabled'] == new_status['is_enabled']:
# TODO: find a more reliable/official way to check whether the
# request has messages attached.
if not self.request._messages._queued_messages:
messages.info(self.request, _('Setting unchanged'))
else:
if new_status['is_enabled']:
self.app.enable()
messages.success(self.request, _('Application enabled'))
else:
self.app.disable()
messages.success(self.request, _('Application disabled'))
return super().form_valid(form) return super().form_valid(form)
def get_enable_disable_form(self):
"""Return an instance of the app enable/disable form.
If the app can't be disabled by the user, return None.
"""
if not self.app.can_be_disabled:
return None
initial = {
'should_enable': not self._get_common_status()['is_enabled']
}
return forms.AppEnableDisableForm(initial=initial)
def enable_disable_form_valid(self, form):
"""Form handling for enabling / disabling apps."""
should_enable = form.cleaned_data['should_enable']
if should_enable != self.app.is_enabled():
if should_enable:
self.app.enable()
else:
self.app.disable()
return HttpResponseRedirect(self.request.path)
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
"""Add service to the context data.""" """Add service to the context data."""
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
@ -197,6 +231,7 @@ class AppView(FormView):
context['app_info'] = self.app.info context['app_info'] = self.app.info
context['has_diagnostics'] = self.app.has_diagnostics() context['has_diagnostics'] = self.app.has_diagnostics()
context['port_forwarding_info'] = self.port_forwarding_info context['port_forwarding_info'] = self.port_forwarding_info
context['app_enable_disable_form'] = self.get_enable_disable_form()
from plinth.modules.firewall.components import Firewall from plinth.modules.firewall.components import Firewall
context['firewall'] = self.app.get_components_of_type(Firewall) context['firewall'] = self.app.get_components_of_type(Firewall)

View File

@ -571,10 +571,6 @@ a.menu_link_active {
justify-content: space-between; justify-content: space-between;
} }
.app-titles .app-toggle-container {
margin: auto 0;
}
.app-titles h3 { .app-titles h3 {
margin-top: -0.5rem; margin-top: -0.5rem;
margin-bottom: 1.6rem; margin-bottom: 1.6rem;
@ -583,10 +579,8 @@ a.menu_link_active {
font-style: italic; font-style: italic;
} }
.app-toggle-container { .form-app-enable-disable {
display: none; margin: auto 0;
flex-flow: row;
justify-content: flex-end;
} }
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
@ -612,7 +606,7 @@ a.menu_link_active {
margin: 0 0 15px 0; margin: 0 0 15px 0;
} }
.app-titles .app-toggle-container { .app-titles .form-app-enable-disable {
margin: 30px auto; margin: 30px auto;
} }

View File

@ -1,57 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* 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/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
var appForm = document.querySelector('#app-form');
var appToggleContainer = document.querySelector('#app-toggle-container');
var appToggleButton = document.querySelector('#app-toggle-button');
var appToggleInput = document.querySelector('#app-toggle-input');
if (appForm && appToggleButton && appToggleInput && appToggleContainer) {
var onSubmit = (e) => {
e.preventDefault;
appToggleInput.checked = !appToggleInput.checked;
appForm.submit();
};
appToggleButton.addEventListener('click', onSubmit);
/**
* if javascript is enabled, this script will run and show the toggle button
*/
appToggleInput.parentElement.style.display = 'none';
appToggleContainer.style.display = 'flex';
/* A basic form has only three elements:
* 1. An input tag with CSRF token
* 2. A div with form elements
* 3. A Submit button
*
* This kind of form can be completely hidden.
*/
if (appForm.children.length === 3) {
appForm.style.display = 'none';
appForm.previousElementSibling.style.display = 'none';
}
}