Skip to content
Snippets Groups Projects
Verified Commit 7b18e46f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'release/0.13'

parents 107cca7b d299964c
Branches
Tags 0.13
No related merge requests found
Showing
with 525 additions and 53 deletions
import pytest
from rest_framework.views import APIView
from funkwhale_api.users import permissions
def test_has_user_permission_no_user(api_request):
view = APIView.as_view()
permission = permissions.HasUserPermission()
request = api_request.get('/')
assert permission.has_permission(request, view) is False
def test_has_user_permission_anonymous(anonymous_user, api_request):
view = APIView.as_view()
permission = permissions.HasUserPermission()
request = api_request.get('/')
setattr(request, 'user', anonymous_user)
assert permission.has_permission(request, view) is False
@pytest.mark.parametrize('value', [True, False])
def test_has_user_permission_logged_in_single(value, factories, api_request):
user = factories['users.User'](permission_federation=value)
class View(APIView):
required_permissions = ['federation']
view = View()
permission = permissions.HasUserPermission()
request = api_request.get('/')
setattr(request, 'user', user)
result = permission.has_permission(request, view)
assert result == user.has_permissions('federation') == value
@pytest.mark.parametrize('federation,library,expected', [
(True, False, False),
(False, True, False),
(False, False, False),
(True, True, True),
])
def test_has_user_permission_logged_in_single(
federation, library, expected, factories, api_request):
user = factories['users.User'](
permission_federation=federation,
permission_library=library,
)
class View(APIView):
required_permissions = ['federation', 'library']
view = View()
permission = permissions.HasUserPermission()
request = api_request.get('/')
setattr(request, 'user', user)
result = permission.has_permission(request, view)
assert result == user.has_permissions('federation', 'library') == expected
......@@ -53,33 +53,24 @@ def test_can_disable_registration_view(preferences, client, db):
assert response.status_code == 403
def test_can_fetch_data_from_api(client, factories):
def test_can_fetch_data_from_api(api_client, factories):
url = reverse('api:v1:users:users-me')
response = client.get(url)
response = api_client.get(url)
# login required
assert response.status_code == 401
user = factories['users.User'](
is_staff=True,
perms=[
'music.add_importbatch',
'dynamic_preferences.change_globalpreferencemodel',
]
permission_library=True
)
assert user.has_perm('music.add_importbatch')
client.login(username=user.username, password='test')
response = client.get(url)
api_client.login(username=user.username, password='test')
response = api_client.get(url)
assert response.status_code == 200
payload = json.loads(response.content.decode('utf-8'))
assert payload['username'] == user.username
assert payload['is_staff'] == user.is_staff
assert payload['is_superuser'] == user.is_superuser
assert payload['email'] == user.email
assert payload['name'] == user.name
assert payload['permissions']['import.launch']['status']
assert payload['permissions']['settings.change']['status']
assert response.data['username'] == user.username
assert response.data['is_staff'] == user.is_staff
assert response.data['is_superuser'] == user.is_superuser
assert response.data['email'] == user.email
assert response.data['name'] == user.name
assert response.data['permissions'] == user.get_permissions()
def test_can_get_token_via_api(client, factories):
......@@ -202,6 +193,8 @@ def test_user_can_get_new_subsonic_token(logged_in_api_client):
assert response.data == {
'subsonic_api_token': 'test'
}
def test_user_can_request_new_subsonic_token(logged_in_api_client):
user = logged_in_api_client.user
user.subsonic_api_token = 'test'
......
......@@ -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
-----------------------
......@@ -108,3 +117,28 @@ Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be
On non-docker setup, you don't need to configure this setting.
.. note:: This path should not include any trailing slash
User permissions
----------------
Funkwhale's permission model works as follows:
- Anonymous users cannot do anything unless configured specifically
- Logged-in users can use the application, but cannot do things that affect
the whole instance
- Superusers can do anything
To make things more granular and allow some delegation of responsability,
superusers can grant specific permissions to specific users. Available
permissions are:
- **Manage instance-level settings**: users with this permission can edit instance
settings as described in :ref:`instance-settings`
- **Manage library**: users with this permission can import new music in the
instance
- **Manage library federation**: users with this permission can ask to federate with
other instances, and accept/deny federation requests from other intances
There is no dedicated interface to manage users permissions, but superusers
can login on the Django's admin at ``/api/admin/`` and grant permissions
to users at ``/api/admin/users/user/``.
......@@ -5,6 +5,7 @@ export default {
],
formatsMap: {
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3'
'audio/mpeg': 'mp3',
'audio/x-flac': 'flac'
}
}
......@@ -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']"
: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">
......
......@@ -60,7 +60,7 @@
<div class="menu">
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['import.launch']"
v-if="$store.state.auth.availablePermissions['library']"
:to="{name: 'library.requests', query: {status: 'pending' }}">
<i class="download icon"></i>{{ $t('Import requests') }}
<div
......@@ -70,7 +70,7 @@
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['federation.manage']"
v-if="$store.state.auth.availablePermissions['federation']"
:to="{path: '/manage/federation/libraries'}">
<i class="sitemap icon"></i>{{ $t('Federation') }}
<div
......@@ -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']"
:to="{path: '/manage/settings'}">
<i class="settings icon"></i>{{ $t('Settings') }}
</router-link>
</div>
</div>
</div>
......@@ -186,8 +192,8 @@ export default {
}),
showAdmin () {
let adminPermissions = [
this.$store.state.auth.availablePermissions['federation.manage'],
this.$store.state.auth.availablePermissions['import.launch']
this.$store.state.auth.availablePermissions['federation'],
this.$store.state.auth.availablePermissions['library']
]
return adminPermissions.filter(e => {
return e
......@@ -203,7 +209,7 @@ export default {
this.fetchFederationImportRequestsCount()
},
fetchFederationNotificationsCount () {
if (!this.$store.state.auth.availablePermissions['federation.manage']) {
if (!this.$store.state.auth.availablePermissions['federation']) {
return
}
let self = this
......@@ -212,12 +218,11 @@ export default {
})
},
fetchFederationImportRequestsCount () {
if (!this.$store.state.auth.availablePermissions['import.launch']) {
if (!this.$store.state.auth.availablePermissions['library']) {
return
}
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>
<template>
<div :class="['ui', {'tiny': discrete}, 'buttons']">
<div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']">
<button
:title="$t('Add to current queue')"
@click="addNext(true)"
:disabled="!playable"
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
<i class="ui play icon"></i>
<template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
......@@ -10,9 +11,9 @@
<div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
<i class="dropdown icon"></i>
<div class="menu">
<div class="item"@click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div>
<div class="item"@click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div>
<div class="item"@click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div>
<div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div>
<div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div>
<div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div>
</div>
</div>
</div>
......@@ -45,9 +46,18 @@ export default {
jQuery(this.$el).find('.ui.dropdown').dropdown()
},
computed: {
title () {
if (this.playable) {
return this.$t('Play immediatly')
} else {
if (this.track) {
return this.$t('This track is not imported and cannot be played')
}
}
},
playable () {
if (this.track) {
return true
return this.track.files.length > 0
} else if (this.tracks) {
return this.tracks.length > 0
} else if (this.playlist) {
......
......@@ -55,7 +55,7 @@
<p slot="modal-confirm"><i18next path="Deny"/></p>
</dangerous-button>
<dangerous-button v-if="follow.approved !== true" class="tiny basic labeled icon" color='green' @confirm="updateFollow(follow, true)">
<i class="x icon"></i> <i18next path="Approve"/>
<i class="check icon"></i> <i18next path="Approve"/>
<p slot="modal-header"><i18next path="Approve access?"/></p>
<p slot="modal-content">
<i18next path="By confirming, {%0%}@{%1%} will be granted access to your library.">
......
......@@ -13,10 +13,10 @@
exact>
<i18next path="Requests"/>
</router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>
<router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/launch" exact>
<i18next path="Import"/>
</router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">
<router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/batches">
<i18next path="Import batches"/>
</router-link>
</div>
......
......@@ -44,6 +44,46 @@
</a>
</div>
</div>
<div v-if="file" class="ui vertical stripe center aligned segment">
<h2 class="ui header">{{ $t('Track information') }}</h2>
<table class="ui very basic collapsing celled center aligned table">
<tbody>
<tr>
<td>
{{ $t('Duration') }}
</td>
<td v-if="file.duration">
{{ time.parse(file.duration) }}
</td>
<td v-else>
{{ $t('N/A') }}
</td>
</tr>
<tr>
<td>
{{ $t('Size') }}
</td>
<td v-if="file.size">
{{ file.size | humanSize }}
</td>
<td v-else>
{{ $t('N/A') }}
</td>
</tr>
<tr>
<td>
{{ $t('Bitrate') }}
</td>
<td v-if="file.bitrate">
{{ file.bitrate | humanSize }}/s
</td>
<td v-else>
{{ $t('N/A') }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="ui vertical stripe center aligned segment">
<h2><i18next path="Lyrics"/></h2>
<div v-if="isLoadingLyrics" class="ui vertical segment">
......@@ -64,6 +104,8 @@
</template>
<script>
import time from '@/utils/time'
import axios from 'axios'
import url from '@/utils/url'
import logger from '@/logging'
......@@ -83,6 +125,7 @@ export default {
},
data () {
return {
time,
isLoadingTrack: true,
isLoadingLyrics: true,
track: null,
......@@ -134,6 +177,9 @@ export default {
return u
}
},
file () {
return this.track.files[0]
},
lyricsSearchUrl () {
let base = 'http://lyrics.wikia.com/wiki/Special:Search?query='
let query = this.track.artist.name + ' ' + this.track.title
......@@ -159,5 +205,8 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.table.center.aligned {
margin-left: auto;
margin-right: auto;
}
</style>
......@@ -7,9 +7,7 @@
<div class="description">
<template v-if="track">
<h4 class="ui header">{{ $t('Current track') }}</h4>
<div>
{{ $t('"{%title%}" by {%artist%}', { title: track.title, artist: track.artist.name }) }}
</div>
<div v-html='trackDisplay'></div>
<div class="ui divider"></div>
</template>
......@@ -112,6 +110,12 @@ export default {
let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
p.reverse()
return p
},
trackDisplay () {
return this.$t('"{%title%}" by {%artist%}', {
title: this.track.title,
artist: this.track.artist.name }
)
}
},
watch: {
......
......@@ -22,7 +22,7 @@
</span>
<button
@click="createImport"
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']"
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['library']"
class="ui mini basic green right floated button">{{ $t('Create import') }}</button>
</div>
......
......@@ -47,4 +47,23 @@ export function capitalize (str) {
Vue.filter('capitalize', capitalize)
export function humanSize (bytes) {
let si = true
var thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh) {
return bytes + ' B'
}
var units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
var u = -1
do {
bytes /= thresh
++u
} while (Math.abs(bytes) >= thresh && u < units.length - 1)
return bytes.toFixed(1) + ' ' + units[u]
}
Vue.filter('humanSize', humanSize)
export default {}
......@@ -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,
......
......@@ -112,7 +112,7 @@ export default {
dispatch('playlists/fetchOwn', null, {root: true})
Object.keys(data.permissions).forEach(function (key) {
// this makes it easier to check for permissions in templates
commit('permission', {key, status: data.permissions[String(key)].status})
commit('permission', {key, status: data.permissions[String(key)]})
})
return response.data
}, (response) => {
......
<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 () {
return {
isLoading: false,
settingsData: null,
current: null
}
},
created () {
let self = this
this.fetchSettings().then(r => {
self.$nextTick(() => {
if (self.$store.state.route.hash) {
self.scrollTo(self.$store.state.route.hash.substr(1))
}
})
})
},
methods: {
scrollTo (id) {
console.log(id, 'hello')
this.current = id
document.getElementById(id).scrollIntoView()
},
fetchSettings () {
let self = this
self.isLoading = true
return axios.get('instance/admin/settings/').then((response) => {
self.settingsData = response.data
self.isLoading = false
})
}
},
computed: {
groups () {
return [
{
label: this.$t('Instance information'),
id: 'instance',
settings: [
'instance__name',
'instance__short_description',
'instance__long_description'
]
},
{
label: this.$t('Users'),
id: 'users',
settings: [
'users__registration_enabled',
'common__api_authentication_required'
]
},
{
label: this.$t('Imports'),
id: 'imports',
settings: [
'providers_youtube__api_key',
'providers_acoustid__api_key'
]
},
{
label: this.$t('Playlists'),
id: 'playlists',
settings: [
'playlists__max_tracks'
]
},
{
label: this.$t('Federation'),
id: 'federation',
settings: [
'federation__enabled',
'federation__music_needs_approval',
'federation__collection_page_size',
'federation__music_cache_duration',
'federation__actor_fetch_delay'
]
},
{
label: this.$t('Subsonic'),
id: 'subsonic',
settings: [
'subsonic__enabled'
]
},
{
label: this.$t('Statistics'),
id: 'statistics',
settings: [
'instance__nodeinfo_enabled',
'instance__nodeinfo_stats_enabled',
'instance__nodeinfo_private'
]
},
{
label: this.$t('Error reporting'),
id: 'reporting',
settings: [
'raven__front_enabled',
'raven__front_dsn'
]
}
]
}
},
watch: {
settingsData () {
let self = this
this.$nextTick(() => {
$(self.$el).find('.sticky').sticky({context: '#settings-grid'})
})
}
}
}
</script>
......@@ -76,7 +76,6 @@ export default {
Pagination
},
data () {
console.log('YOLO', this.$t)
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
isLoading: true,
......
......@@ -164,9 +164,7 @@ describe('store/auth', () => {
const profile = {
username: 'bob',
permissions: {
admin: {
status: true
}
admin: true
}
}
moxios.stubRequest('users/users/me/', {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment