Merge branch '223-management-interface' into 'develop'

Resolve "Add a management interface for artists/albums/tracks"

Closes #223 and #241

See merge request funkwhale/funkwhale!216
merge-requests/237/head
Eliot Berriot 2018-05-29 21:28:32 +00:00
commit 218a92547e
23 zmienionych plików z 550 dodań i 3 usunięć

Wyświetl plik

@ -38,6 +38,10 @@ v1_patterns += [
include(
('funkwhale_api.instance.urls', 'instance'),
namespace='instance')),
url(r'^manage/',
include(
('funkwhale_api.manage.urls', 'manage'),
namespace='manage')),
url(r'^federation/',
include(
('funkwhale_api.federation.api_urls', 'federation'),

Wyświetl plik

@ -97,6 +97,7 @@ THIRD_PARTY_APPS = (
'dynamic_preferences',
'django_filters',
'cacheops',
'django_cleanup',
)

Wyświetl plik

@ -12,6 +12,9 @@ class ActionSerializer(serializers.Serializer):
filters = serializers.DictField(required=False)
actions = None
filterset_class = None
# those are actions identifier where we don't want to allow the "all"
# selector because it's to dangerous. Like object deletion.
dangerous_actions = []
def __init__(self, *args, **kwargs):
self.queryset = kwargs.pop('queryset')
@ -49,6 +52,10 @@ class ActionSerializer(serializers.Serializer):
'list of identifiers or the string "all".'.format(value))
def validate(self, data):
dangerous = data['action'] in self.dangerous_actions
if dangerous and self.initial_data['objects'] == 'all':
raise serializers.ValidationError(
'This action is to dangerous to be applied to all objects')
if self.filterset_class and 'filters' in data:
qs_filterset = self.filterset_class(
data['filters'], queryset=data['objects'])

Wyświetl plik

@ -0,0 +1,3 @@
"""
App that includes all views/serializers and stuff for management API
"""

Wyświetl plik

@ -0,0 +1,25 @@
from django.db.models import Count
from django_filters import rest_framework as filters
from funkwhale_api.common import fields
from funkwhale_api.music import models as music_models
class ManageTrackFileFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=[
'track__title',
'track__album__title',
'track__artist__name',
'source',
])
class Meta:
model = music_models.TrackFile
fields = [
'q',
'track__album',
'track__artist',
'track',
'library_track'
]

Wyświetl plik

@ -0,0 +1,82 @@
from django.db import transaction
from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from . import filters
class ManageTrackFileArtistSerializer(serializers.ModelSerializer):
class Meta:
model = music_models.Artist
fields = [
'id',
'mbid',
'creation_date',
'name',
]
class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
artist = ManageTrackFileArtistSerializer()
class Meta:
model = music_models.Album
fields = (
'id',
'mbid',
'title',
'artist',
'release_date',
'cover',
'creation_date',
)
class ManageTrackFileTrackSerializer(serializers.ModelSerializer):
artist = ManageTrackFileArtistSerializer()
album = ManageTrackFileAlbumSerializer()
class Meta:
model = music_models.Track
fields = (
'id',
'mbid',
'title',
'album',
'artist',
'creation_date',
'position',
)
class ManageTrackFileSerializer(serializers.ModelSerializer):
track = ManageTrackFileTrackSerializer()
class Meta:
model = music_models.TrackFile
fields = (
'id',
'path',
'source',
'filename',
'mimetype',
'track',
'duration',
'mimetype',
'bitrate',
'size',
'path',
'library_track',
)
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
actions = ['delete']
dangerous_actions = ['delete']
filterset_class = filters.ManageTrackFileFilterSet
@transaction.atomic
def handle_delete(self, objects):
return objects.delete()

Wyświetl plik

@ -0,0 +1,11 @@
from django.conf.urls import include, url
from . import views
from rest_framework import routers
library_router = routers.SimpleRouter()
library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files')
urlpatterns = [
url(r'^library/',
include((library_router.urls, 'instance'), namespace='library')),
]

Wyświetl plik

@ -0,0 +1,49 @@
from rest_framework import mixins
from rest_framework import response
from rest_framework import viewsets
from rest_framework.decorators import list_route
from funkwhale_api.music import models as music_models
from funkwhale_api.users.permissions import HasUserPermission
from . import filters
from . import serializers
class ManageTrackFileViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet):
queryset = (
music_models.TrackFile.objects.all()
.select_related(
'track__artist',
'track__album__artist',
'library_track')
.order_by('-id')
)
serializer_class = serializers.ManageTrackFileSerializer
filter_class = filters.ManageTrackFileFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ['library']
ordering_fields = [
'accessed_date',
'modification_date',
'creation_date',
'track__artist__name',
'bitrate',
'size',
'duration',
]
@list_route(methods=['post'])
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageTrackFileActionSerializer(
request.data,
queryset=queryset,
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)

Wyświetl plik

@ -117,6 +117,11 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
status='finished',
audio_file=None,
)
with_audio_file = factory.Trait(
status='finished',
audio_file=factory.django.FileField(
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')),
)
@registry.register(name='music.FileImportJob')

Wyświetl plik

@ -27,7 +27,7 @@ PERMISSIONS_CONFIGURATION = {
},
'library': {
'label': 'Manage library',
'help_text': 'Manage library',
'help_text': 'Manage library, delete files, tracks, artists, albums...',
},
'settings': {
'label': 'Manage instance-level settings',

Wyświetl plik

@ -65,3 +65,4 @@ cryptography>=2,<3
# requests-http-signature==0.0.3
# clone until the branch is merged and released upstream
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
django-cleanup==2.1.0

Wyświetl plik

@ -18,6 +18,17 @@ class TestSerializer(serializers.ActionSerializer):
return {'hello': 'world'}
class TestDangerousSerializer(serializers.ActionSerializer):
actions = ['test', 'test_dangerous']
dangerous_actions = ['test_dangerous']
def handle_test(self, objects):
pass
def handle_test_dangerous(self, objects):
pass
def test_action_serializer_validates_action():
data = {'objects': 'all', 'action': 'nope'}
serializer = TestSerializer(data, queryset=models.User.objects.none())
@ -98,3 +109,28 @@ def test_action_serializers_validates_at_least_one_object():
assert serializer.is_valid() is False
assert 'non_field_errors' in serializer.errors
def test_dangerous_actions_refuses_all(factories):
factories['users.User']()
data = {
'objects': 'all',
'action': 'test_dangerous',
}
serializer = TestDangerousSerializer(
data, queryset=models.User.objects.all())
assert serializer.is_valid() is False
assert 'non_field_errors' in serializer.errors
def test_dangerous_actions_refuses_not_listed(factories):
factories['users.User']()
data = {
'objects': 'all',
'action': 'test',
}
serializer = TestDangerousSerializer(
data, queryset=models.User.objects.all())
assert serializer.is_valid() is True

Wyświetl plik

Wyświetl plik

@ -0,0 +1,10 @@
from funkwhale_api.manage import serializers
def test_manage_track_file_action_delete(factories):
tfs = factories['music.TrackFile'](size=5)
s = serializers.ManageTrackFileActionSerializer(queryset=None)
s.handle_delete(tfs.__class__.objects.all())
assert tfs.__class__.objects.count() == 0

Wyświetl plik

@ -0,0 +1,26 @@
import pytest
from django.urls import reverse
from funkwhale_api.manage import serializers
from funkwhale_api.manage import views
@pytest.mark.parametrize('view,permissions,operator', [
(views.ManageTrackFileViewSet, ['library'], 'and'),
])
def test_permissions(assert_user_permission, view, permissions, operator):
assert_user_permission(view, permissions, operator)
def test_track_file_view(factories, superuser_api_client):
tfs = factories['music.TrackFile'].create_batch(size=5)
qs = tfs[0].__class__.objects.order_by('-creation_date')
url = reverse('api:v1:manage:library:track-files-list')
response = superuser_api_client.get(url, {'sort': '-creation_date'})
expected = serializers.ManageTrackFileSerializer(
qs, many=True, context={'request': response.wsgi_request}).data
assert response.data['count'] == len(tfs)
assert response.data['results'] == expected

Wyświetl plik

@ -0,0 +1,10 @@
Files management interface for users with "library" permission (#223)
Files management interface
^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the first bit of an ongoing work that will span several releases, to
bring more powerful library management features to Funkwhale. This iteration
includes a basic file management interface where users with the "library"
permission can list and search available files, order them using
various criterias (size, bitrate, duration...) and delete them.

Wyświetl plik

@ -0,0 +1 @@
Autoremove media files on model instance deletion (#241)

Wyświetl plik

@ -68,6 +68,12 @@
:title="$t('Pending import requests')">
{{ notifications.importRequests }}</div>
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.files'}">
<i class="book icon"></i>{{ $t('Library') }}
</router-link>
<router-link
class="item"
v-else-if="$store.state.auth.availablePermissions['upload']"

Wyświetl plik

@ -21,7 +21,7 @@
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
{{ $t('Go') }}</div>
<dangerous-button
v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
v-else-if="!currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
confirm-color="green"
color=""
@confirm="launchAction">
@ -36,7 +36,7 @@
<div class="count field">
<span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
<span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
<template v-if="checkable.length === checked.length">
<template v-if="!currentAction.isDangerous && checkable.length === checked.length">
<a @click="selectAll = true" v-if="!selectAll">
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
</a>

Wyświetl plik

@ -0,0 +1,206 @@
<template>
<div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label>{{ $t('Search') }}</label>
<input type="text" v-model="search" placeholder="Search by title, artist, domain..." />
</div>
<div class="field">
<i18next tag="label" path="Ordering"/>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
</option>
</select>
</div>
<div class="field">
<i18next tag="label" path="Ordering direction"/>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+">Ascending</option>
<option value="-">Descending</option>
</select>
</div>
</div>
</div>
<div class="dimmable">
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<action-table
v-if="result"
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
:action-url="'manage/library/track-files/action/'"
:filters="actionFilters">
<template slot="header-cells">
<th>{{ $t('Title') }}</th>
<th>{{ $t('Artist') }}</th>
<th>{{ $t('Album') }}</th>
<th>{{ $t('Import date') }}</th>
<th>{{ $t('Type') }}</th>
<th>{{ $t('Bitrate') }}</th>
<th>{{ $t('Duration') }}</th>
<th>{{ $t('Size') }}</th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(30) }}</span>
</td>
<td>
<span :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(30) }}</span>
</td>
<td>
<span :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span>
</td>
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td v-if="scope.obj.audio_mimetype">
{{ scope.obj.audio_mimetype }}
</td>
<td v-else>
{{ $t('N/A') }}
</td>
<td v-if="scope.obj.bitrate">
{{ scope.obj.bitrate | humanSize }}/s
</td>
<td v-else>
{{ $t('N/A') }}
</td>
<td v-if="scope.obj.duration">
{{ time.parse(scope.obj.duration) }}
</td>
<td v-else>
{{ $t('N/A') }}
</td>
<td v-if="scope.obj.size">
{{ scope.obj.size | humanSize }}
</td>
<td v-else>
{{ $t('N/A') }}
</td>
</template>
</action-table>
</div>
<div>
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
<span v-if="result && result.results.length > 0">
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
import OrderingMixin from '@/components/mixins/Ordering'
export default {
mixins: [OrderingMixin],
props: {
filters: {type: Object, required: false}
},
components: {
Pagination,
ActionTable
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
time,
isLoading: false,
result: null,
page: 1,
paginateBy: 25,
search: '',
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['creation_date', 'Creation date'],
['accessed_date', 'Accessed date'],
['modification_date', 'Modification date'],
['size', 'Size'],
['bitrate', 'Bitrate'],
['duration', 'Duration']
]
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
self.checked = []
axios.get('/manage/library/track-files/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
}
},
computed: {
actionFilters () {
var currentFilters = {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
} else {
return currentFilters
}
},
actions () {
return [
{
name: 'delete',
label: this.$t('Delete'),
isDangerous: true
}
]
}
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.fetchData()
},
orderingDirection () {
this.fetchData()
}
}
}
</script>

Wyświetl plik

@ -29,6 +29,8 @@ 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 AdminLibraryBase from '@/views/admin/library/Base'
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
import FederationBase from '@/views/federation/Base'
import FederationScan from '@/views/federation/Scan'
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
@ -167,6 +169,17 @@ export default new Router({
{ path: 'libraries/:id', name: 'federation.libraries.detail', component: FederationLibraryDetail, props: true }
]
},
{
path: '/manage/library',
component: AdminLibraryBase,
children: [
{
path: 'files',
name: 'manage.library.files',
component: AdminLibraryFilesList
}
]
},
{
path: '/library',
component: Library,

Wyświetl plik

@ -0,0 +1,28 @@
<template>
<div class="main pusher" v-title="'Manage library'">
<div class="ui secondary pointing menu">
<router-link
class="ui item"
:to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link>
</div>
<router-view :key="$route.fullPath"></router-view>
</div>
</template>
<script>
export default {}
</script>
<style lang="scss">
@import '../../../style/vendor/media';
.main.pusher > .ui.secondary.menu {
@include media(">tablet") {
margin: 0 2.5rem;
}
.item {
padding-top: 1.5em;
padding-bottom: 1.5em;
}
}
</style>

Wyświetl plik

@ -0,0 +1,23 @@
<template>
<div v-title="'Files'">
<div class="ui vertical stripe segment">
<h2 class="ui header">{{ $t('Library files') }}</h2>
<div class="ui hidden divider"></div>
<library-files-table :show-library="true"></library-files-table>
</div>
</div>
</template>
<script>
import LibraryFilesTable from '@/components/manage/library/FilesTable'
export default {
components: {
LibraryFilesTable
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>