Commit 23e27e0d authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '206-settings-admin' into 'develop'

Resolve "Add a dedicated front-end to manage instance preferences"

Closes #206

See merge request funkwhale/funkwhale!199
parents 6608a315 ed9971cf
......@@ -16,5 +16,5 @@ class APIAutenticationRequired(
help_text = (
'If disabled, anonymous users will be able to query the API'
'and access music data (as well as other data exposed in the API '
'without specific permissions)'
'without specific permissions).'
)
......@@ -19,6 +19,9 @@ class MusicCacheDuration(types.IntPreference):
'locally? Federated files that were not listened in this interval '
'will be erased and refetched from the remote on the next listening.'
)
field_kwargs = {
'required': False,
}
@global_preferences_registry.register
......@@ -29,7 +32,7 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
verbose_name = 'Federation enabled'
help_text = (
'Use this setting to enable or disable federation logic and API'
' globally'
' globally.'
)
......@@ -41,8 +44,11 @@ class CollectionPageSize(
setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
verbose_name = 'Federation collection page size'
help_text = (
'How much items to display in ActivityPub collections'
'How much items to display in ActivityPub collections.'
)
field_kwargs = {
'required': False,
}
@global_preferences_registry.register
......@@ -54,8 +60,11 @@ class ActorFetchDelay(
verbose_name = 'Federation actor fetch delay'
help_text = (
'How much minutes to wait before refetching actors on '
'request authentication'
'request authentication.'
)
field_kwargs = {
'required': False,
}
@global_preferences_registry.register
......@@ -66,6 +75,6 @@ class MusicNeedsApproval(
setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
verbose_name = 'Federation music needs approval'
help_text = (
'When true, other federation actors will require your approval'
'When true, other federation actors will need your approval'
' before being able to browse your library.'
)
......@@ -13,8 +13,11 @@ class InstanceName(types.StringPreference):
section = instance
name = 'name'
default = ''
help_text = 'Instance public name'
verbose_name = 'The public name of your instance'
verbose_name = 'Public name'
help_text = 'The public name of your instance, displayed in the about page.'
field_kwargs = {
'required': False,
}
@global_preferences_registry.register
......@@ -23,7 +26,11 @@ class InstanceShortDescription(types.StringPreference):
section = instance
name = 'short_description'
default = ''
verbose_name = 'Instance succinct description'
verbose_name = 'Short description'
help_text = 'Instance succinct description, displayed in the about page.'
field_kwargs = {
'required': False,
}
@global_preferences_registry.register
......@@ -31,31 +38,31 @@ class InstanceLongDescription(types.StringPreference):
show_in_api = True
section = instance
name = 'long_description'
verbose_name = 'Long description'
default = ''
help_text = 'Instance long description (markdown allowed)'
help_text = 'Instance long description, displayed in the about page (markdown allowed).'
widget = widgets.Textarea
field_kwargs = {
'widget': widgets.Textarea
'required': False,
}
@global_preferences_registry.register
class RavenDSN(types.StringPreference):
show_in_api = True
section = raven
name = 'front_dsn'
default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4'
verbose_name = (
'A raven DSN key used to report front-ent errors to '
'a sentry instance'
)
verbose_name = 'Raven DSN key (front-end)'
help_text = (
'Keeping the default one will report errors to funkwhale developers'
'A Raven DSN key used to report front-ent errors to '
'a sentry instance. Keeping the default one will report errors to '
'Funkwhale developers.'
)
SENTRY_HELP_TEXT = (
'Error reporting is disabled by default but you can enable it if'
' you want to help us improve funkwhale'
)
field_kwargs = {
'required': False,
}
@global_preferences_registry.register
......@@ -65,8 +72,7 @@ class RavenEnabled(types.BooleanPreference):
name = 'front_enabled'
default = False
verbose_name = (
'Wether error reporting to a Sentry instance using raven is enabled'
' for front-end errors'
'Report front-end errors with Raven'
)
......@@ -78,7 +84,7 @@ class InstanceNodeinfoEnabled(types.BooleanPreference):
default = True
verbose_name = 'Enable nodeinfo endpoint'
help_text = (
'This endpoint is needed for your about page to work.'
'This endpoint is needed for your about page to work. '
'It\'s also helpful for the various monitoring '
'tools that map and analyzize the fediverse, '
'but you can disable it completely if needed.'
......@@ -91,10 +97,10 @@ class InstanceNodeinfoPrivate(types.BooleanPreference):
section = instance
name = 'nodeinfo_private'
default = False
verbose_name = 'Enable nodeinfo endpoint'
verbose_name = 'Private mode in nodeinfo'
help_text = (
'Indicate in the nodeinfo endpoint that you do not want your instance'
'to be tracked by third-party services.'
'Indicate in the nodeinfo endpoint that you do not want your instance '
'to be tracked by third-party services. '
'There is no guarantee these tools will honor this setting though.'
)
......@@ -107,6 +113,6 @@ class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
default = True
verbose_name = 'Enable usage and library stats in nodeinfo endpoint'
help_text = (
'Disable this f you don\'t want to share usage and library statistics'
'Disable this if you don\'t want to share usage and library statistics '
'in the nodeinfo endpoint but don\'t want to disable it completely.'
)
from django.conf.urls import url
from rest_framework import routers
from . import views
admin_router = routers.SimpleRouter()
admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings')
urlpatterns = [
url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'),
url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
]
] + admin_router.urls
......@@ -2,6 +2,7 @@ from rest_framework import views
from rest_framework.response import Response
from dynamic_preferences.api import serializers
from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
......@@ -15,6 +16,10 @@ NODEINFO_2_CONTENT_TYPE = (
)
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None
class InstanceSettings(views.APIView):
permission_classes = []
authentication_classes = []
......
......@@ -13,3 +13,6 @@ class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference):
name = 'max_tracks'
verbose_name = 'Max tracks per playlist'
setting = 'PLAYLISTS_MAX_TRACKS'
field_kwargs = {
'required': False,
}
from django import forms
from dynamic_preferences.types import StringPreference, Section
from dynamic_preferences.registries import global_preferences_registry
......@@ -11,3 +13,7 @@ class APIKey(StringPreference):
default = ''
verbose_name = 'Acoustid API key'
help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.'
widget = forms.PasswordInput
field_kwargs = {
'required': False,
}
from django import forms
from dynamic_preferences.types import StringPreference, Section
from dynamic_preferences.registries import global_preferences_registry
......@@ -11,3 +13,7 @@ class APIKey(StringPreference):
default = 'CHANGEME'
verbose_name = 'YouTube API key'
help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.'
widget = forms.PasswordInput
field_kwargs = {
'required': False,
}
......@@ -10,6 +10,7 @@ class RegistrationEnabled(types.BooleanPreference):
section = users
name = 'registration_enabled'
default = False
verbose_name = (
'Can visitors open a new account on this instance?'
verbose_name = 'Open registrations to new users'
help_text = (
'When enabled, new users will be able to register on this instance.'
)
......@@ -6,7 +6,7 @@ import os
import uuid
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import AbstractUser, Permission
from django.urls import reverse
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
......@@ -55,6 +55,10 @@ class User(AbstractUser):
def __str__(self):
return self.username
def add_permission(self, codename):
p = Permission.objects.get(codename=codename)
self.user_permissions.add(p)
def get_absolute_url(self):
return reverse('users:detail', kwargs={'username': self.username})
......
......@@ -21,3 +21,31 @@ def test_nodeinfo_endpoint_disabled(db, api_client, preferences):
response = api_client.get(url)
assert response.status_code == 404
def test_settings_only_list_public_settings(db, api_client, preferences):
url = reverse('api:v1:instance:settings')
response = api_client.get(url)
for conf in response.data:
p = preferences.model.objects.get(
section=conf['section'], name=conf['name'])
assert p.preference.show_in_api is True
def test_admin_settings_restrict_access(db, logged_in_api_client, preferences):
url = reverse('api:v1:instance:admin-settings-list')
response = logged_in_api_client.get(url)
assert response.status_code == 403
def test_admin_settings_correct_permission(
db, logged_in_api_client, preferences):
user = logged_in_api_client.user
user.add_permission('change_globalpreferencemodel')
url = reverse('api:v1:instance:admin-settings-list')
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert len(response.data) == len(preferences.all())
We now have a brand new instance settings interface in the front-end (#206)
Instance settings interface
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Prior to this release, the only way to update instance settings (such as
instance description, signup policy, federation configuration, etc.) was using
the admin interface provided by Django (the back-end framework which power the API).
This interface worked, but was not really-user friendly and intuitive.
Starting from this release, we now offer a dedicated interface directly
in the front-end. You can view and edit all your instance settings from here,
assuming you have the required permissions.
This interface is available at ``/manage/settings` and via link in the sidebar.
......@@ -27,15 +27,24 @@ Those settings are stored in database and do not require a restart of your
instance after modification. They typically relate to higher level configuration,
such your instance description, signup policy and so on.
There is no polished interface for those settings, yet, but you can view update
them using the administration interface provided by Django (the framework funkwhale is built on).
The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course).
You can edit those settings directly from the web application, assuming
you have the required permissions. The URL is ``/manage/settings``, and
you will also find a link to this page in the sidebar.
If you plan to use acoustid and external imports
(e.g. with the youtube backends), you should edit the corresponding
settings in this interface.
.. note::
If you have any issue with the web application, a management interface is also
available for those settings from Django's administration interface. It's
less user friendly, though, and we recommend you use the web app interface
whenever possible.
The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course).
Configuration reference
-----------------------
......
......@@ -13,6 +13,12 @@
<p v-if="!instance.short_description.value && !instance.long_description.value">
{{ $t('Unfortunately, owners of this instance did not yet take the time to complete this page.') }}
</p>
<router-link
class="ui button"
v-if="$store.state.auth.availablePermissions['settings.change']"
:to="{path: '/manage/settings', hash: 'instance'}">
<i class="pencil icon"></i>{{ $t('Edit instance info') }}
</router-link>
<div
v-if="instance.short_description.value"
class="ui middle aligned stackable text container">
......
......@@ -78,6 +78,12 @@
:title="$t('Pending follow requests')">
{{ notifications.federation }}</div>
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['settings.change']"
:to="{path: '/manage/settings'}">
<i class="settings icon"></i>{{ $t('Settings') }}
</router-link>
</div>
</div>
</div>
......@@ -217,7 +223,6 @@ export default {
}
let self = this
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
console.log('YOLo')
self.notifications.importRequests = response.data.count
})
},
......@@ -256,7 +261,6 @@ export default {
},
'$store.state.availablePermissions': {
handler () {
console.log('YOLO')
this.fetchNotificationsCount()
},
deep: true
......
<template>
<form :id="group.id" class="ui form" @submit.prevent="save">
<div class="ui divider" />
<h3 class="ui header">{{ group.label }}</h3>
<div v-if="errors.length > 0" class="ui negative message">
<div class="header">{{ $t('Error while saving settings') }}</div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div v-if="result" class="ui positive message">
{{ $t('Settings updated successfully.') }}
</div>
<p v-if="group.help">{{ group.help }}</p>
<div v-for="setting in settings" class="ui field">
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text">{{ setting.help_text }}</p>
</template>
<input
:id="setting.identifier"
v-if="setting.field.widget.class === 'PasswordInput'"
type="password"
class="ui input"
v-model="values[setting.identifier]" />
<input
:id="setting.identifier"
v-if="setting.field.widget.class === 'TextInput'"
type="text"
class="ui input"
v-model="values[setting.identifier]" />
<input
:id="setting.identifier"
v-if="setting.field.class === 'IntegerField'"
type="number"
class="ui input"
v-model.number="values[setting.identifier]" />
<textarea
:id="setting.identifier"
v-else-if="setting.field.widget.class === 'Textarea'"
type="text"
class="ui input"
v-model="values[setting.identifier]" />
<div v-else-if="setting.field.widget.class === 'CheckboxInput'" class="ui toggle checkbox">
<input
:id="setting.identifier"
:name="setting.identifier"
v-model="values[setting.identifier]"
type="checkbox" />
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text">{{ setting.help_text }}</p>
</div>
</div>
<button
type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']">
{{ $t('Save') }}
</button>
</form>
</template>
<script>
import axios from 'axios'
export default {
props: {
group: {type: Object, required: true},
settingsData: {type: Array, required: true}
},
data () {
return {
values: {},
result: null,
errors: [],
isLoading: false
}
},
created () {
let self = this
this.settings.forEach(e => {
self.values[e.identifier] = e.value
})
},
methods: {
save () {
let self = this
this.isLoading = true
self.errors = []
self.result = null
axios.post('instance/admin/settings/bulk/', self.values).then((response) => {
self.result = true
self.isLoading = false
self.$store.dispatch('instance/fetchSettings')
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
},
computed: {
settings () {
let byIdentifier = {}
this.settingsData.forEach(e => {
byIdentifier[e.identifier] = e
})
return this.group.settings.map(e => {
return byIdentifier[e]
})
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.ui.checkbox p {
margin-top: 1rem;
}
</style>
......@@ -35,8 +35,26 @@ Vue.use(VueMasonryPlugin)
Vue.use(VueLazyload)
Vue.config.productionTip = false
Vue.directive('title', {
inserted: (el, binding) => { document.title = binding.value + ' - Funkwhale' },
updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' }
inserted: (el, binding) => {
let parts = []
let instanceName = store.state.instance.settings.instance.name.value
if (instanceName.length === 0) {
instanceName = 'Funkwhale'
}
parts.unshift(instanceName)
parts.unshift(binding.value)
document.title = parts.join(' - ')
},
updated: (el, binding) => {
let parts = []
let instanceName = store.state.instance.settings.instance.name.value
if (instanceName.length === 0) {
instanceName = 'Funkwhale'
}
parts.unshift(instanceName)
parts.unshift(binding.value)
document.title = parts.join(' - ')
}
})
axios.defaults.baseURL = config.API_URL
......
......@@ -28,6 +28,7 @@ import RequestsList from '@/components/requests/RequestsList'
import PlaylistDetail from '@/views/playlists/Detail'
import PlaylistList from '@/views/playlists/List'
import Favorites from '@/components/favorites/List'
import AdminSettings from '@/views/admin/Settings'
import FederationBase from '@/views/federation/Base'
import FederationScan from '@/views/federation/Scan'
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
......@@ -117,6 +118,11 @@ export default new Router({
defaultPaginateBy: route.query.paginateBy
})
},
{
path: '/manage/settings',
name: 'manage.settings',
component: AdminSettings
},
{
path: '/manage/federation',
component: FederationBase,
......
<template>
<div class="main pusher" v-title="$t('Instance settings')">
<div class="ui vertical stripe segment">
<div class="ui text container">
<div :class="['ui', {'loading': isLoading}, 'form']"></div>
<div id="settings-grid" v-if="settingsData" class="ui grid">
<div class="twelve wide stretched column">
<settings-group
:settings-data="settingsData"
:group="group"
:key="group.title"
v-for="group in groups" />
</div>
<div class="four wide column">
<div class="ui sticky vertical secondary menu">
<div class="header item">{{ $t('Sections') }}</div>
<a :class="['menu', {active: group.id === current}, 'item']"
@click.prevent="scrollTo(group.id)"
:href="'#' + group.id"
v-for="group in groups">{{ group.label }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import $ from 'jquery'
import SettingsGroup from '@/components/admin/SettingsGroup'
export default {
components: {
SettingsGroup
},
data () {
re