Merge branch 'action-tables' into 'develop'

Action table component / implementation on federation library tracks

Closes #164 and #228

See merge request funkwhale/funkwhale!210
This commit is contained in:
Eliot Berriot 2018-05-24 19:11:33 +00:00
commit 9c02954e46
18 changed files with 637 additions and 183 deletions

View File

@ -0,0 +1,76 @@
from rest_framework import serializers
class ActionSerializer(serializers.Serializer):
"""
A special serializer that can operate on a list of objects
and apply actions on it.
"""
action = serializers.CharField(required=True)
objects = serializers.JSONField(required=True)
filters = serializers.DictField(required=False)
actions = None
filterset_class = None
def __init__(self, *args, **kwargs):
self.queryset = kwargs.pop('queryset')
if self.actions is None:
raise ValueError(
'You must declare a list of actions on '
'the serializer class')
for action in self.actions:
handler_name = 'handle_{}'.format(action)
assert hasattr(self, handler_name), (
'{} miss a {} method'.format(
self.__class__.__name__, handler_name)
)
super().__init__(self, *args, **kwargs)
def validate_action(self, value):
if value not in self.actions:
raise serializers.ValidationError(
'{} is not a valid action. Pick one of {}.'.format(
value, ', '.join(self.actions)
)
)
return value
def validate_objects(self, value):
qs = None
if value == 'all':
return self.queryset.all().order_by('id')
if type(value) in [list, tuple]:
return self.queryset.filter(pk__in=value).order_by('id')
raise serializers.ValidationError(
'{} is not a valid value for objects. You must provide either a '
'list of identifiers or the string "all".'.format(value))
def validate(self, data):
if self.filterset_class and 'filters' in data:
qs_filterset = self.filterset_class(
data['filters'], queryset=data['objects'])
try:
assert qs_filterset.form.is_valid()
except (AssertionError, TypeError):
raise serializers.ValidationError('Invalid filters')
data['objects'] = qs_filterset.qs
data['count'] = data['objects'].count()
if data['count'] < 1:
raise serializers.ValidationError(
'No object matching your request')
return data
def save(self):
handler_name = 'handle_{}'.format(self.validated_data['action'])
handler = getattr(self, handler_name)
result = handler(self.validated_data['objects'])
payload = {
'updated': self.validated_data['count'],
'action': self.validated_data['action'],
'result': result,
}
return payload

View File

@ -24,7 +24,7 @@ class LibraryFilter(django_filters.FilterSet):
class LibraryTrackFilter(django_filters.FilterSet):
library = django_filters.CharFilter('library__uuid')
imported = django_filters.CharFilter(method='filter_imported')
status = django_filters.CharFilter(method='filter_status')
q = fields.SearchFilter(search_fields=[
'artist_name',
'title',
@ -32,11 +32,15 @@ class LibraryTrackFilter(django_filters.FilterSet):
'library__actor__domain',
])
def filter_imported(self, queryset, field_name, value):
if value.lower() in ['true', '1', 'yes']:
queryset = queryset.filter(local_track_file__isnull=False)
elif value.lower() in ['false', '0', 'no']:
queryset = queryset.filter(local_track_file__isnull=True)
def filter_status(self, queryset, field_name, value):
if value == 'imported':
return queryset.filter(local_track_file__isnull=False)
elif value == 'not_imported':
return queryset.filter(
local_track_file__isnull=True
).exclude(import_jobs__status='pending')
elif value == 'import_pending':
return queryset.filter(import_jobs__status='pending')
return queryset
class Meta:

View File

@ -10,8 +10,11 @@ from rest_framework import serializers
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from . import activity
from . import filters
from . import models
from . import utils
@ -293,6 +296,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer):
class APILibraryTrackSerializer(serializers.ModelSerializer):
library = APILibrarySerializer()
status = serializers.SerializerMethodField()
class Meta:
model = models.LibraryTrack
@ -311,8 +315,20 @@ class APILibraryTrackSerializer(serializers.ModelSerializer):
'title',
'library',
'local_track_file',
'status',
]
def get_status(self, o):
try:
if o.local_track_file is not None:
return 'imported'
except music_models.TrackFile.DoesNotExist:
pass
for job in o.import_jobs.all():
if job.status == 'pending':
return 'import_pending'
return 'not_imported'
class FollowSerializer(serializers.Serializer):
id = serializers.URLField(max_length=500)
@ -806,3 +822,29 @@ class CollectionSerializer(serializers.Serializer):
if self.context.get('include_ap_context', True):
d['@context'] = AP_CONTEXT
return d
class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
actions = ['import']
filterset_class = filters.LibraryTrackFilter
@transaction.atomic
def handle_import(self, objects):
batch = music_models.ImportBatch.objects.create(
source='federation',
submitted_by=self.context['submitted_by']
)
jobs = []
for lt in objects:
job = music_models.ImportJob(
batch=batch,
library_track=lt,
mbid=lt.mbid,
source=lt.url,
)
jobs.append(job)
music_models.ImportJob.objects.bulk_create(jobs)
music_tasks.import_batch_run.delay(import_batch_id=batch.pk)
return {'batch': {'id': batch.pk}}

View File

@ -15,7 +15,7 @@ from rest_framework.serializers import ValidationError
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.music.models import TrackFile
from funkwhale_api.music import models as music_models
from funkwhale_api.users.permissions import HasUserPermission
from . import activity
@ -148,7 +148,9 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
def list(self, request, *args, **kwargs):
page = request.GET.get('page')
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
qs = TrackFile.objects.order_by('-creation_date').select_related(
qs = music_models.TrackFile.objects.order_by(
'-creation_date'
).select_related(
'track__artist',
'track__album__artist'
).filter(library_track__isnull=True)
@ -294,7 +296,7 @@ class LibraryTrackViewSet(
'library__actor',
'library__follow',
'local_track_file',
)
).prefetch_related('import_jobs')
filter_class = filters.LibraryTrackFilter
serializer_class = serializers.APILibraryTrackSerializer
ordering_fields = (
@ -307,3 +309,16 @@ class LibraryTrackViewSet(
'fetched_date',
'published_date',
)
@list_route(methods=['post'])
def action(self, request, *args, **kwargs):
queryset = models.LibraryTrack.objects.filter(
local_track_file__isnull=True)
serializer = serializers.LibraryTrackActionSerializer(
request.data,
queryset=queryset,
context={'submitted_by': request.user}
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)

View File

@ -250,28 +250,6 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
return 'Audio'
class SubmitFederationTracksSerializer(serializers.Serializer):
library_tracks = serializers.PrimaryKeyRelatedField(
many=True,
queryset=LibraryTrack.objects.filter(local_track_file__isnull=True),
)
@transaction.atomic
def save(self, **kwargs):
batch = models.ImportBatch.objects.create(
source='federation',
**kwargs
)
for lt in self.validated_data['library_tracks']:
models.ImportJob.objects.create(
batch=batch,
library_track=lt,
mbid=lt.mbid,
source=lt.url,
)
return batch
class ImportJobRunSerializer(serializers.Serializer):
jobs = serializers.PrimaryKeyRelatedField(
many=True,

View File

@ -173,6 +173,13 @@ def import_job_run(self, import_job, replace=False, use_acoustid=False):
raise
@celery.app.task(name='ImportBatch.run')
@celery.require_instance(models.ImportBatch, 'import_batch')
def import_batch_run(import_batch):
for job_id in import_batch.jobs.order_by('id').values_list('id', flat=True):
import_job_run.delay(import_job_id=job_id)
@celery.app.task(name='Lyrics.fetch_content')
@celery.require_instance(models.Lyrics, 'lyrics')
def fetch_content(lyrics):

View File

@ -449,22 +449,6 @@ class SubmitViewSet(viewsets.ViewSet):
data, request, batch=None, import_request=import_request)
return Response(import_data)
@list_route(methods=['post'])
@transaction.non_atomic_requests
def federation(self, request, *args, **kwargs):
serializer = serializers.SubmitFederationTracksSerializer(
data=request.data)
serializer.is_valid(raise_exception=True)
batch = serializer.save(submitted_by=request.user)
for job in batch.jobs.all():
funkwhale_utils.on_commit(
tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False,
)
return Response({'id': batch.id}, status=201)
@transaction.atomic
def _import_album(self, data, request, batch=None, import_request=None):
# we import the whole album here to prevent race conditions that occurs

View File

@ -0,0 +1,100 @@
import django_filters
from funkwhale_api.common import serializers
from funkwhale_api.users import models
class TestActionFilterSet(django_filters.FilterSet):
class Meta:
model = models.User
fields = ['is_active']
class TestSerializer(serializers.ActionSerializer):
actions = ['test']
filterset_class = TestActionFilterSet
def handle_test(self, objects):
return {'hello': 'world'}
def test_action_serializer_validates_action():
data = {'objects': 'all', 'action': 'nope'}
serializer = TestSerializer(data, queryset=models.User.objects.none())
assert serializer.is_valid() is False
assert 'action' in serializer.errors
def test_action_serializer_validates_objects():
data = {'objects': 'nope', 'action': 'test'}
serializer = TestSerializer(data, queryset=models.User.objects.none())
assert serializer.is_valid() is False
assert 'objects' in serializer.errors
def test_action_serializers_objects_clean_ids(factories):
user1 = factories['users.User']()
user2 = factories['users.User']()
data = {'objects': [user1.pk], 'action': 'test'}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert list(serializer.validated_data['objects']) == [user1]
def test_action_serializers_objects_clean_all(factories):
user1 = factories['users.User']()
user2 = factories['users.User']()
data = {'objects': 'all', 'action': 'test'}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert list(serializer.validated_data['objects']) == [user1, user2]
def test_action_serializers_save(factories, mocker):
handler = mocker.spy(TestSerializer, 'handle_test')
user1 = factories['users.User']()
user2 = factories['users.User']()
data = {'objects': 'all', 'action': 'test'}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
result = serializer.save()
assert result == {
'updated': 2,
'action': 'test',
'result': {'hello': 'world'},
}
handler.assert_called_once()
def test_action_serializers_filterset(factories):
user1 = factories['users.User'](is_active=False)
user2 = factories['users.User'](is_active=True)
data = {
'objects': 'all',
'action': 'test',
'filters': {'is_active': True},
}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert list(serializer.validated_data['objects']) == [user2]
def test_action_serializers_validates_at_least_one_object():
data = {
'objects': 'all',
'action': 'test',
}
serializer = TestSerializer(data, queryset=models.User.objects.none())
assert serializer.is_valid() is False
assert 'non_field_errors' in serializer.errors

View File

@ -699,3 +699,26 @@ def test_api_library_create_serializer_save(factories, r_mock):
assert library.tracks_count == 10
assert library.actor == actor
assert library.follow == follow
def test_tapi_library_track_serializer_not_imported(factories):
lt = factories['federation.LibraryTrack']()
serializer = serializers.APILibraryTrackSerializer(lt)
assert serializer.get_status(lt) == 'not_imported'
def test_tapi_library_track_serializer_imported(factories):
tf = factories['music.TrackFile'](federation=True)
lt = tf.library_track
serializer = serializers.APILibraryTrackSerializer(lt)
assert serializer.get_status(lt) == 'imported'
def test_tapi_library_track_serializer_import_pending(factories):
job = factories['music.ImportJob'](federation=True, status='pending')
lt = job.library_track
serializer = serializers.APILibraryTrackSerializer(lt)
assert serializer.get_status(lt) == 'import_pending'

View File

@ -418,3 +418,39 @@ def test_can_filter_pending_follows(factories, superuser_api_client):
assert response.status_code == 200
assert len(response.data['results']) == 0
def test_library_track_action_import(
factories, superuser_api_client, mocker):
lt1 = factories['federation.LibraryTrack']()
lt2 = factories['federation.LibraryTrack'](library=lt1.library)
lt3 = factories['federation.LibraryTrack']()
lt4 = factories['federation.LibraryTrack'](library=lt3.library)
mocked_run = mocker.patch(
'funkwhale_api.music.tasks.import_batch_run.delay')
payload = {
'objects': 'all',
'action': 'import',
'filters': {
'library': lt1.library.uuid
}
}
url = reverse('api:v1:federation:library-tracks-action')
response = superuser_api_client.post(url, payload, format='json')
batch = superuser_api_client.user.imports.latest('id')
expected = {
'updated': 2,
'action': 'import',
'result': {
'batch': {'id': batch.pk}
}
}
imported_lts = [lt1, lt2]
assert response.status_code == 200
assert response.data == expected
assert batch.jobs.count() == 2
for i, job in enumerate(batch.jobs.all()):
assert job.library_track == imported_lts[i]
mocked_run.assert_called_once_with(import_batch_id=batch.pk)

View File

@ -47,6 +47,15 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
assert track_file.acoustid_track_id is None
def test_import_batch_run(factories, mocker):
job = factories['music.ImportJob']()
mocked_job_run = mocker.patch(
'funkwhale_api.music.tasks.import_job_run.delay')
tasks.import_batch_run(import_batch_id=job.batch.pk)
mocked_job_run.assert_called_once_with(import_job_id=job.pk)
def test_import_job_can_run_with_file_and_acoustid(
artists, albums, tracks, preferences, factories, mocker):
preferences['providers_acoustid__api_key'] = 'test'

View File

@ -249,24 +249,6 @@ def test_serve_updates_access_date(factories, settings, api_client):
assert track_file.accessed_date > now
def test_can_create_import_from_federation_tracks(
factories, superuser_api_client, mocker):
lts = factories['federation.LibraryTrack'].create_batch(size=5)
mocker.patch('funkwhale_api.music.tasks.import_job_run')
payload = {
'library_tracks': [l.pk for l in lts]
}
url = reverse('api:v1:submit-federation')
response = superuser_api_client.post(url, payload)
assert response.status_code == 201
batch = superuser_api_client.user.imports.latest('id')
assert batch.jobs.count() == 5
for i, job in enumerate(batch.jobs.all()):
assert job.library_track == lts[i]
def test_can_list_import_jobs(factories, superuser_api_client):
job = factories['music.ImportJob']()
url = reverse('api:v1:import-jobs-list')

View File

@ -0,0 +1,2 @@
Can now import a whole remote library at once thanks to new Action Table
component (#164)

View File

@ -0,0 +1,3 @@
New action table component for quick and efficient batch actions (#228)
This is implemented on the federated tracks pages, but will be included
in other pages as well depending on the feedback.

View File

@ -33,7 +33,7 @@
"raven-js": "^3.22.3",
"semantic-ui-css": "^2.2.10",
"showdown": "^1.8.6",
"vue": "^2.3.3",
"vue": "^2.5.16",
"vue-lazyload": "^1.1.4",
"vue-masonry": "^0.10.16",
"vue-router": "^2.3.1",

View File

@ -0,0 +1,215 @@
<template>
<table class="ui compact very basic single line unstackable table">
<thead>
<tr v-if="actions.length > 0">
<th colspan="1000">
<div class="ui small form">
<div class="ui inline fields">
<div class="field">
<label>{{ $t('Actions') }}</label>
<select class="ui dropdown" v-model="currentActionName">
<option v-for="action in actions" :value="action.name">
{{ action.label }}
</option>
</select>
</div>
<div class="field">
<div
v-if="!selectAll"
@click="launchAction"
:disabled="checked.length === 0"
: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']"
confirm-color="green"
color=""
@confirm="launchAction">
{{ $t('Go') }}
<p slot="modal-header">{{ $t('Do you want to launch action "{% action %}" on {% total %} elements?', {action: currentActionName, total: objectsData.count}) }}
<p slot="modal-content">
{{ $t('This may affect a lot of elements, please double check this is really what you want.')}}
</p>
<p slot="modal-confirm">{{ $t('Launch') }}</p>
</dangerous-button>
</div>
<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">
<a @click="selectAll = true" v-if="!selectAll">
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
</a>
<a @click="selectAll = false" v-else>
{{ $t('Select only current page') }}
</a>
</template>
</div>
</div>
<div v-if="actionErrors.length > 0" class="ui negative message">
<div class="header">{{ $t('Error while applying action') }}</div>
<ul class="list">
<li v-for="error in actionErrors">{{ error }}</li>
</ul>
</div>
<div v-if="actionResult" class="ui positive message">
<p>{{ $t('Action {% action %} was launched successfully on {% count %} objects.', {action: actionResult.action, count: actionResult.updated}) }}</p>
<slot name="action-success-footer" :result="actionResult">
</slot>
</div>
</div>
</th>
</tr>
<tr>
<th>
<div class="ui checkbox">
<input
type="checkbox"
@change="toggleCheckAll"
:disabled="checkable.length === 0"
:checked="checkable.length > 0 && checked.length === checkable.length"><label>&nbsp;</label>
</div>
</th>
<slot name="header-cells"></slot>
</tr>
</thead>
<tbody v-if="objectsData.count > 0">
<tr v-for="(obj, index) in objectsData.results">
<td class="collapsing">
<input
type="checkbox"
:disabled="checkable.indexOf(obj.id) === -1"
@click="toggleCheck($event, obj.id, index)"
:checked="checked.indexOf(obj.id) > -1"><label>&nbsp;</label>
</div>
</td>
<slot name="row-cells" :obj="obj"></slot>
</tr>
</tbody>
</table>
</template>
<script>
import axios from 'axios'
export default {
props: {
actionUrl: {type: String, required: true},
objectsData: {type: Object, required: true},
actions: {type: Array, required: true, default: () => { return [] }},
filters: {type: Object, required: false, default: () => { return {} }}
},
components: {},
data () {
let d = {
checked: [],
actionLoading: false,
actionResult: null,
actionErrors: [],
currentActionName: null,
selectAll: false,
lastCheckedIndex: -1
}
if (this.actions.length > 0) {
d.currentActionName = this.actions[0].name
}
return d
},
methods: {
toggleCheckAll () {
this.lastCheckedIndex = -1
if (this.checked.length === this.checkable.length) {
// we uncheck
this.checked = []
} else {
this.checked = this.checkable.map(i => { return i })
}
},
toggleCheck (event, id, index) {
let self = this
let affectedIds = [id]
let newValue = null
if (this.checked.indexOf(id) > -1) {
// we uncheck
this.selectAll = false
newValue = false
} else {
newValue = true
}
if (event.shiftKey && this.lastCheckedIndex > -1) {
// we also add inbetween ids to the list of affected ids
let idxs = [index, this.lastCheckedIndex]
idxs.sort((a, b) => a - b)
let objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1)
affectedIds = affectedIds.concat(objs.map((o) => { return o.id }))
}
affectedIds.forEach((i) => {
let checked = self.checked.indexOf(i) > -1
if (newValue && !checked && self.checkable.indexOf(i) > -1) {
return self.checked.push(i)
}
if (!newValue && checked) {
self.checked.splice(self.checked.indexOf(i), 1)
}
})
this.lastCheckedIndex = index
},
launchAction () {
let self = this
self.actionLoading = true
self.result = null
let payload = {
action: this.currentActionName,
filters: this.filters
}
if (this.selectAll) {
payload.objects = 'all'
} else {
payload.objects = this.checked
}
axios.post(this.actionUrl, payload).then((response) => {
self.actionResult = response.data
self.actionLoading = false
self.$emit('action-launched', response.data)
}, error => {
self.actionLoading = false
self.actionErrors = error.backendErrors
})
}
},
computed: {
currentAction () {
let self = this
return this.actions.filter((a) => {
return a.name === self.currentActionName
})[0]
},
checkable () {
let objs = this.objectsData.results
let filter = this.currentAction.filterCheckable
if (filter) {
objs = objs.filter((o) => {
return filter(o)
})
}
return objs.map((o) => { return o.id })
}
},
watch: {
objectsData: {
handler () {
this.checked = []
this.selectAll = false
},
deep: true
}
}
}
</script>
<style scoped>
.count.field {
font-weight: normal;
}
.ui.form .inline.fields {
margin: 0;
}
</style>

View File

@ -13,7 +13,7 @@
</div>
<div class="actions">
<div class="ui cancel button"><i18next path="Cancel"/></div>
<div :class="['ui', 'confirm', color, 'button']" @click="confirm">
<div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
<slot name="modal-confirm"><i18next path="Confirm"/></slot>
</div>
</div>
@ -28,7 +28,8 @@ export default {
props: {
action: {type: Function, required: false},
disabled: {type: Boolean, default: false},
color: {type: String, default: 'red'}
color: {type: String, default: 'red'},
confirmColor: {type: String, default: null, required: false}
},
components: {
Modal
@ -38,6 +39,14 @@ export default {
showModal: false
}
},
computed: {
confirmButtonColor () {
if (this.confirmColor) {
return this.confirmColor
}
return this.color
}
},
methods: {
confirm () {
this.showModal = false

View File

@ -10,95 +10,77 @@
<label>{{ $t('Import status') }}</label>
<select class="ui dropdown" v-model="importedFilter">
<option :value="null">{{ $t('Any') }}</option>
<option :value="true">{{ $t('Imported') }}</option>
<option :value="false">{{ $t('Not imported') }}</option>
<option :value="'imported'">{{ $t('Imported') }}</option>
<option :value="'not_imported'">{{ $t('Not imported') }}</option>
<option :value="'import_pending'">{{ $t('Import pending') }}</option>
</select>
</div>
</div>
</div>
<table v-if="result" class="ui compact very basic single line unstackable table">
<thead>
<tr>
<th>
<div class="ui checkbox">
<input
type="checkbox"
@change="toggleCheckAll"
:checked="result.results.length === checked.length"><label>&nbsp;</label>
</div>
</th>
<i18next tag="th" path="Title"/>
<i18next tag="th" path="Artist"/>
<i18next tag="th" path="Album"/>
<i18next tag="th" path="Published date"/>
<i18next tag="th" v-if="showLibrary" path="Library"/>
</tr>
</thead>
<tbody>
<tr v-for="track in result.results">
<td class="collapsing">
<div v-if="!track.local_track_file" class="ui checkbox">
<input
type="checkbox"
@change="toggleCheck(track.id)"
:checked="checked.indexOf(track.id) > -1"><label>&nbsp;</label>
</div>
<div v-else class="ui label">
<i18next path="In library"/>
</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="'federation/library-tracks/action/'"
:filters="actionFilters">
<template slot="header-cells">
<th>{{ $t('Status') }}</th>
<th>{{ $t('Title') }}</th>
<th>{{ $t('Artist') }}</th>
<th>{{ $t('Album') }}</th>
<th>{{ $t('Published date') }}</th>
<th v-if="showLibrary">{{ $t('Library') }}</th>
</template>
<template slot="action-success-footer" slot-scope="scope">
<router-link
v-if="scope.result.action === 'import'"
:to="{name: 'library.import.batches.detail', params: {id: scope.result.result.batch.id }}">
{{ $t('Import #{% id %} launched', {id: scope.result.result.batch.id}) }}
</router-link>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<span v-if="scope.obj.status === 'imported'" class="ui basic green label">{{ $t('In library') }}</span>
<span v-else-if="scope.obj.status === 'import_pending'" class="ui basic yellow label">{{ $t('Import pending') }}</span>
<span v-else class="ui basic label">{{ $t('Not imported') }}</span>
</td>
<td>
<span :title="track.title">{{ track.title|truncate(30) }}</span>
<span :title="scope.obj.title">{{ scope.obj.title|truncate(30) }}</span>
</td>
<td>
<span :title="track.artist_name">{{ track.artist_name|truncate(30) }}</span>
<span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span>
</td>
<td>
<span :title="track.album_title">{{ track.album_title|truncate(20) }}</span>
<span :title="scope.obj.album_title">{{ scope.obj.album_title|truncate(20) }}</span>
</td>
<td>
<human-date :date="track.published_date"></human-date>
<human-date :date="scope.obj.published_date"></human-date>
</td>
<td v-if="showLibrary">
{{ track.library.actor.domain }}
{{ scope.obj.library.actor.domain }}
</td>
</tr>
</tbody>
<tfoot class="full-width">
<tr>
<th>
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
</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>
</th>
<th 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})}}
<th>
<button
@click="launchImport"
:disabled="checked.length === 0 || isImporting"
:class="['ui', 'green', {loading: isImporting}, 'button']">
{{ $t('Import {%count%} tracks', {'count': checked.length}) }}
</button>
<router-link
v-if="importBatch"
:to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}">
{{ $t('Import #{% id %} launched', {id: importBatch.id}) }}
</router-link>
</th>
<th></th>
<th></th>
<th></th>
<th v-if="showLibrary"></th>
</tr>
</tfoot>
</table>
<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>
@ -107,6 +89,7 @@ import axios from 'axios'
import _ from 'lodash'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
export default {
props: {
@ -114,7 +97,8 @@ export default {
showLibrary: {type: Boolean, default: false}
},
components: {
Pagination
Pagination,
ActionTable
},
data () {
return {
@ -123,9 +107,6 @@ export default {
page: 1,
paginateBy: 25,
search: '',
checked: {},
isImporting: false,
importBatch: null,
importedFilter: null
}
},
@ -140,7 +121,7 @@ export default {
'q': this.search
}, this.filters)
if (this.importedFilter !== null) {
params.imported = this.importedFilter
params.status = this.importedFilter
}
let self = this
self.isLoading = true
@ -153,53 +134,41 @@ export default {
self.errors = error.backendErrors
})
},
launchImport () {
let self = this
self.isImporting = true
let payload = {
library_tracks: this.checked
}
axios.post('/submit/federation/', payload).then((response) => {
self.importBatch = response.data
self.isImporting = false
self.fetchData()
}, error => {
self.isImporting = false
self.errors = error.backendErrors
})
},
toggleCheckAll () {
if (this.checked.length === this.result.results.length) {
// we uncheck
this.checked = []
} else {
this.checked = this.result.results.filter(t => {
return t.local_track_file === null
}).map(t => { return t.id })
}
},
toggleCheck (id) {
if (this.checked.indexOf(id) > -1) {
// we uncheck
this.checked.splice(this.checked.indexOf(id), 1)
} else {
this.checked.push(id)
}
},
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: 'import',
label: this.$t('Import'),
filterCheckable: (obj) => { return obj.status === 'not_imported' }
}
]
}
},
watch: {
search (newValue) {
if (newValue.length > 0) {
this.fetchData()
}
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
importedFilter () {
this.page = 1
this.fetchData()
}
}