diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..62d9c567e9693cb9a5542c5456b71e79315e6af5 --- /dev/null +++ b/api/funkwhale_api/common/serializers.py @@ -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 diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 7a388ff1298fbab740342c5de1885157ea433a94..1d93f68b993397407ca7b3476c0d8be05d977c4b 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -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: diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 51561e222837d2fab9fcf3473112227c68e740e2..6ffffaa9aa37396bda8acb5fe2bcfe6d281b8edf 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -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}} diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 06a2cd040cfc0d94fe51ab419bf6159c6e14f631..1350ec731ece68010cc907191d6412ad425fdce3 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -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) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index c77983a404cf89e9e9f4779ecc3e19773136fda0..b72bb8c4a63cce3c9b23ed2a62082527a00455d4 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -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, diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index e5426904a7f2b16bca519f37c1e30a0a2d5d4489..993456c2701bbff50477eacea02ef3a56cdac4e1 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -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): diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 5e3a7a4c1784e38a6e35bee0033a45b3ec20734c..aa07ad52c1a86c0f5f51e7eb254fd28ae2d69807 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -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 diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..5636765562d61c6262f7f17b77c85c3b5b98b128 --- /dev/null +++ b/api/tests/common/test_serializers.py @@ -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 diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index f298c61f5fbd36f9516e607d00e60649c4b9b281..fcf2ba1b673d876660aa8825e2f8495bd10a9d99 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -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' diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 10237ed9fd76d156656238edbce11495dc08934b..04a419aed6f5fae696cbd3d94f02c8420d9e9021 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -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) diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 26cb9453efacca6137f412bdd03c8df570f41a02..dfe649be0f91b460db912078d25ae825063af523 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -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' diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 38366442f309a4b80eb25f7dfeda517b608ab778..9328ba329ce79b3cc05413e7edb611b6f6e39c7f 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -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') diff --git a/changes/changelog.d/164.enhancement b/changes/changelog.d/164.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..ceea6c2b8e53fb858bb051e9b4efdd70bb774654 --- /dev/null +++ b/changes/changelog.d/164.enhancement @@ -0,0 +1,2 @@ +Can now import a whole remote library at once thanks to new Action Table +component (#164) diff --git a/changes/changelog.d/228.feature b/changes/changelog.d/228.feature new file mode 100644 index 0000000000000000000000000000000000000000..548c1927ebd6d1deb5d22161ff6d9cadf50dc30f --- /dev/null +++ b/changes/changelog.d/228.feature @@ -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. diff --git a/front/package.json b/front/package.json index 8844e8bee53c05c4436ec6e8783ca405972162ae..3dec9c2571a25cf4e8592bb5da7a28e7699d636a 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..718e57b19b61672913d0de6145ec7447c69ac0cf --- /dev/null +++ b/front/src/components/common/ActionTable.vue @@ -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> diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue index 690291d5b1d08abaa60f8576fce71b25a73f1047..52fcdca6136528bea7cb7b36125074654e62800e 100644 --- a/front/src/components/common/DangerousButton.vue +++ b/front/src/components/common/DangerousButton.vue @@ -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 diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue index d8ee48bf2b8e93f0ab9c22494ecee2883f5e859f..43b52c835bb84ec3434f5a8fca110e70ecd24ff7 100644 --- a/front/src/components/federation/LibraryTrackTable.vue +++ b/front/src/components/federation/LibraryTrackTable.vue @@ -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 }) + selectPage: function (page) { + this.page = page + } + }, + computed: { + actionFilters () { + var currentFilters = { + q: this.search } - }, - toggleCheck (id) { - if (this.checked.indexOf(id) > -1) { - // we uncheck - this.checked.splice(this.checked.indexOf(id), 1) + if (this.filters) { + return _.merge(currentFilters, this.filters) } else { - this.checked.push(id) + return currentFilters } }, - selectPage: function (page) { - this.page = page + 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() } }