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:
commit
9c02954e46
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
Can now import a whole remote library at once thanks to new Action Table
|
||||
component (#164)
|
|
@ -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.
|
|
@ -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",
|
||||
|
|
|
@ -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> </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> </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>
|
|
@ -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
|
||||
|
|
|
@ -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> </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> </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()
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue