From f1a1b93ee54b0946f7f2fd3027935dd9265f2d8c Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 23 May 2018 19:52:47 +0200 Subject: [PATCH 1/9] See #228: serializer logic --- api/funkwhale_api/common/serializers.py | 74 ++++++++++++++++++++ api/tests/common/test_serializers.py | 89 +++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 api/funkwhale_api/common/serializers.py create mode 100644 api/tests/common/test_serializers.py diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py new file mode 100644 index 000000000..7e214d7db --- /dev/null +++ b/api/funkwhale_api/common/serializers.py @@ -0,0 +1,74 @@ +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 not self.filterset_class or 'filters' not in data: + # no additional filters to apply, we just skip + return 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 + 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['objects'].count(), + 'action': self.validated_data['action'], + 'result': result, + } + return payload diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py new file mode 100644 index 000000000..075e957f6 --- /dev/null +++ b/api/tests/common/test_serializers.py @@ -0,0 +1,89 @@ +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] From ba4b6f6ba609ad9db5043d623e2198e6e9d0161b Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 23 May 2018 21:50:23 +0200 Subject: [PATCH 2/9] See #228: now use our new action logic for library track import --- api/funkwhale_api/common/serializers.py | 24 +++++++------ api/funkwhale_api/federation/serializers.py | 31 ++++++++++++++++- api/funkwhale_api/federation/views.py | 19 +++++++++-- api/funkwhale_api/music/serializers.py | 22 ------------ api/funkwhale_api/music/views.py | 16 --------- api/tests/common/test_serializers.py | 11 ++++++ api/tests/federation/test_views.py | 34 +++++++++++++++++++ api/tests/music/test_views.py | 18 ---------- .../federation/LibraryTrackTable.vue | 7 ++-- 9 files changed, 109 insertions(+), 73 deletions(-) diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 7e214d7db..62d9c567e 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -49,17 +49,19 @@ class ActionSerializer(serializers.Serializer): 'list of identifiers or the string "all".'.format(value)) def validate(self, data): - if not self.filterset_class or 'filters' not in data: - # no additional filters to apply, we just skip - return 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 - 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): @@ -67,7 +69,7 @@ class ActionSerializer(serializers.Serializer): handler = getattr(self, handler_name) result = handler(self.validated_data['objects']) payload = { - 'updated': self.validated_data['objects'].count(), + 'updated': self.validated_data['count'], 'action': self.validated_data['action'], 'result': result, } diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 51561e222..77cb25096 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 @@ -806,3 +809,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'] + ) + for lt in objects: + job = music_models.ImportJob.objects.create( + batch=batch, + library_track=lt, + mbid=lt.mbid, + source=lt.url, + ) + funkwhale_utils.on_commit( + music_tasks.import_job_run.delay, + import_job_id=job.pk, + use_acoustid=False, + ) + + return {'batch': {'id': batch.pk}} diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 06a2cd040..01bc02fdf 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) @@ -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 c77983a40..b72bb8c4a 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/views.py b/api/funkwhale_api/music/views.py index 24a9cbbcd..b42ad1c35 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 index 075e957f6..563676556 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -87,3 +87,14 @@ def test_action_serializers_filterset(factories): 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_views.py b/api/tests/federation/test_views.py index 10237ed9f..1b0de5754 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -418,3 +418,37 @@ 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) + mocker.patch('funkwhale_api.music.tasks.import_job_run') + + 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] diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 38366442f..9328ba329 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/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue index d8ee48bf2..ad8b801a6 100644 --- a/front/src/components/federation/LibraryTrackTable.vue +++ b/front/src/components/federation/LibraryTrackTable.vue @@ -157,10 +157,11 @@ export default { let self = this self.isImporting = true let payload = { - library_tracks: this.checked + objects: this.checked, + action: 'import' } - axios.post('/submit/federation/', payload).then((response) => { - self.importBatch = response.data + axios.post('/federation/library-tracks/action/', payload).then((response) => { + self.importBatch = response.data.result.batch self.isImporting = false self.fetchData() }, error => { From c6b5c71d268dc65329bd945c84f980cebb48c91a Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 23 May 2018 22:55:33 +0200 Subject: [PATCH 3/9] Upgraded to latest vue to benefit from slot scope --- front/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/package.json b/front/package.json index 8844e8bee..3dec9c257 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", From fc88f72a71a45255114fb5dfcfc2eeb2599ba54a Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 24 May 2018 00:05:00 +0200 Subject: [PATCH 4/9] See #228: generic action table component --- front/src/components/common/ActionTable.vue | 150 ++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 front/src/components/common/ActionTable.vue diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue new file mode 100644 index 000000000..b9822ca4d --- /dev/null +++ b/front/src/components/common/ActionTable.vue @@ -0,0 +1,150 @@ + + + From f12fe0047f3c46c143d64f2840d1be206b417914 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 24 May 2018 00:05:14 +0200 Subject: [PATCH 5/9] See #228: use action table component for federation library tracks --- .../federation/LibraryTrackTable.vue | 193 +++++++----------- 1 file changed, 73 insertions(+), 120 deletions(-) diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue index ad8b801a6..551fb992a 100644 --- a/front/src/components/federation/LibraryTrackTable.vue +++ b/front/src/components/federation/LibraryTrackTable.vue @@ -16,89 +16,63 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
-
- -
-
- -
-
- {{ track.title|truncate(30) }} - - {{ track.artist_name|truncate(30) }} - - {{ track.album_title|truncate(20) }} - - - - {{ track.library.actor.domain }} -
- + + + + + +
+ -
- {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} - - - - {{ $t('Import #{% id %} launched', {id: importBatch.id}) }} - -
+ + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + + @@ -107,6 +81,7 @@ import axios from 'axios' import _ from 'lodash' import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' export default { props: { @@ -114,7 +89,8 @@ export default { showLibrary: {type: Boolean, default: false} }, components: { - Pagination + Pagination, + ActionTable }, data () { return { @@ -123,9 +99,6 @@ export default { page: 1, paginateBy: 25, search: '', - checked: {}, - isImporting: false, - importBatch: null, importedFilter: null } }, @@ -153,47 +126,26 @@ export default { self.errors = error.backendErrors }) }, - launchImport () { - let self = this - self.isImporting = true - let payload = { - objects: this.checked, - action: 'import' - } - axios.post('/federation/library-tracks/action/', payload).then((response) => { - self.importBatch = response.data.result.batch - 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 + } + } + }, watch: { search (newValue) { if (newValue.length > 0) { + this.page = 1 this.fetchData() } }, @@ -201,6 +153,7 @@ export default { this.fetchData() }, importedFilter () { + this.page = 1 this.fetchData() } } From 4f8db661fa80c3b1c38fdbbcbc84f64b0fe644c6 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 24 May 2018 18:33:26 +0200 Subject: [PATCH 6/9] See #228: now expose library track status in API --- api/funkwhale_api/federation/filters.py | 16 ++++++++------ api/funkwhale_api/federation/serializers.py | 13 ++++++++++++ api/funkwhale_api/federation/views.py | 2 +- api/tests/federation/test_serializers.py | 23 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 7a388ff12..1d93f68b9 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 77cb25096..59e93a9b1 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -296,6 +296,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): class APILibraryTrackSerializer(serializers.ModelSerializer): library = APILibrarySerializer() + status = serializers.SerializerMethodField() class Meta: model = models.LibraryTrack @@ -314,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) diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 01bc02fdf..1350ec731 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -296,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 = ( diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index f298c61f5..fcf2ba1b6 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' From eded32c2e8271cfffb9575a123f0caabdcc851f9 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 24 May 2018 18:53:12 +0200 Subject: [PATCH 7/9] See #228: more performante federation import launch via API --- api/funkwhale_api/federation/serializers.py | 12 ++++++------ api/funkwhale_api/music/tasks.py | 7 +++++++ api/tests/federation/test_views.py | 4 +++- api/tests/music/test_tasks.py | 9 +++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 59e93a9b1..6ffffaa9a 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -834,17 +834,17 @@ class LibraryTrackActionSerializer(common_serializers.ActionSerializer): source='federation', submitted_by=self.context['submitted_by'] ) + jobs = [] for lt in objects: - job = music_models.ImportJob.objects.create( + job = music_models.ImportJob( batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url, ) - funkwhale_utils.on_commit( - music_tasks.import_job_run.delay, - import_job_id=job.pk, - use_acoustid=False, - ) + 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/music/tasks.py b/api/funkwhale_api/music/tasks.py index e5426904a..993456c27 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/tests/federation/test_views.py b/api/tests/federation/test_views.py index 1b0de5754..04a419aed 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -426,7 +426,8 @@ def test_library_track_action_import( lt2 = factories['federation.LibraryTrack'](library=lt1.library) lt3 = factories['federation.LibraryTrack']() lt4 = factories['federation.LibraryTrack'](library=lt3.library) - mocker.patch('funkwhale_api.music.tasks.import_job_run') + mocked_run = mocker.patch( + 'funkwhale_api.music.tasks.import_batch_run.delay') payload = { 'objects': 'all', @@ -452,3 +453,4 @@ def test_library_track_action_import( 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 26cb9453e..dfe649be0 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' From 6586b2b73d6d094142a0d99edc6e750e4b5d925f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 24 May 2018 20:07:14 +0200 Subject: [PATCH 8/9] See #228: smarter action table with shift-click select --- front/src/components/common/ActionTable.vue | 101 ++++++++++++--- .../src/components/common/DangerousButton.vue | 13 +- .../federation/LibraryTrackTable.vue | 115 ++++++++++-------- 3 files changed, 159 insertions(+), 70 deletions(-) diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index b9822ca4d..718e57b19 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -1,29 +1,42 @@