diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 2803186baf036b6418d806a493adcb142af49d95..c911f1a891966adf0ba942670ad1a74fbaca4da7 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -43,6 +43,7 @@ class LibraryTrackFilter(django_filters.FilterSet): class FollowFilter(django_filters.FilterSet): + pending = django_filters.CharFilter(method='filter_pending') ordering = django_filters.OrderingFilter( # tuple-mapping retains order fields=( @@ -50,9 +51,16 @@ class FollowFilter(django_filters.FilterSet): ('modification_date', 'modification_date'), ), ) + q = fields.SearchFilter(search_fields=[ + 'actor__domain', + 'actor__preferred_username', + ]) class Meta: model = models.Follow - fields = { - 'approved': ['exact'], - } + fields = ['approved', 'pending', 'q'] + + def filter_pending(self, queryset, field_name, value): + if value.lower() in ['true', '1', 'yes']: + queryset = queryset.filter(approved__isnull=True) + return queryset diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index e6ad0c0be02d330e880ff8b2c7e9d7b4b8c143db..4964106d8f3d5be3f1029b0811aec2beffcabc7f 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -190,6 +190,35 @@ class APILibraryScanSerializer(serializers.Serializer): until = serializers.DateTimeField(required=False) +class APILibraryFollowUpdateSerializer(serializers.Serializer): + follow = serializers.IntegerField() + approved = serializers.BooleanField() + + def validate_follow(self, value): + from . import actors + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + qs = models.Follow.objects.filter( + pk=value, + target=library_actor, + ) + try: + return qs.get() + except models.Follow.DoesNotExist: + raise serializers.ValidationError('Invalid follow') + + def save(self): + new_status = self.validated_data['approved'] + follow = self.validated_data['follow'] + if new_status == follow.approved: + return follow + + follow.approved = new_status + follow.save(update_fields=['approved', 'modification_date']) + if new_status: + activity.accept_follow(follow) + return follow + + class APILibraryCreateSerializer(serializers.ModelSerializer): actor = serializers.URLField() federation_enabled = serializers.BooleanField() @@ -233,8 +262,13 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): library_data = library.get_library_data( acs.validated_data['library_url']) if 'errors' in library_data: - raise serializers.ValidationError(str(library_data['errors'])) + # we pass silently because it may means we require permission + # before scanning + pass validated_data['library'] = library_data + validated_data['library'].setdefault( + 'id', acs.validated_data['library_url'] + ) validated_data['actor'] = actor return validated_data @@ -244,7 +278,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): defaults={ 'actor': validated_data['actor'], 'follow': validated_data['follow'], - 'tracks_count': validated_data['library']['totalItems'], + 'tracks_count': validated_data['library'].get('totalItems'), 'federation_enabled': validated_data['federation_enabled'], 'autoimport': validated_data['autoimport'], 'download_files': validated_data['download_files'], diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index a3f02a372879d44a6a906d207784df498dea0c9b..381f87eff2e90f4a64feade856756044393d4f4d 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -221,31 +221,42 @@ class LibraryViewSet( queryset = models.Follow.objects.filter( actor=library_actor ).select_related( - 'target', + 'actor', 'target', ).order_by('-creation_date') filterset = filters.FollowFilter(request.GET, queryset=queryset) - serializer = serializers.APIFollowSerializer(filterset.qs, many=True) + final_qs = filterset.qs + serializer = serializers.APIFollowSerializer(final_qs, many=True) data = { 'results': serializer.data, - 'count': len(filterset.qs), + 'count': len(final_qs), } return response.Response(data) - @list_route(methods=['get']) + @list_route(methods=['get', 'patch']) def followers(self, request, *args, **kwargs): + if request.method.lower() == 'patch': + serializer = serializers.APILibraryFollowUpdateSerializer( + data=request.data) + serializer.is_valid(raise_exception=True) + follow = serializer.save() + return response.Response( + serializers.APIFollowSerializer(follow).data + ) + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() queryset = models.Follow.objects.filter( target=library_actor ).select_related( - 'target', + 'actor', 'target', ).order_by('-creation_date') filterset = filters.FollowFilter(request.GET, queryset=queryset) - serializer = serializers.APIFollowSerializer(filterset.qs, many=True) + final_qs = filterset.qs + serializer = serializers.APIFollowSerializer(final_qs, many=True) data = { 'results': serializer.data, - 'count': len(filterset.qs), + 'count': len(final_qs), } return response.Response(data) diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 3e5bdf1a5ec1b46816ba5ab6e66f83dc22dea1ee..8c5235b8bbeb0f8a42b5a79296d4902067312a2a 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -346,3 +346,37 @@ def test_list_library_tracks(factories, superuser_api_client): 'previous': None, 'next': None, } + + +def test_can_update_follow_status(factories, superuser_api_client, mocker): + patched_accept = mocker.patch( + 'funkwhale_api.federation.activity.accept_follow' + ) + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + follow = factories['federation.Follow'](target=library_actor) + + payload = { + 'follow': follow.pk, + 'approved': True + } + url = reverse('api:v1:federation:libraries-followers') + response = superuser_api_client.patch(url, payload) + follow.refresh_from_db() + + assert response.status_code == 200 + assert follow.approved is True + patched_accept.assert_called_once_with(follow) + + +def test_can_filter_pending_follows(factories, superuser_api_client): + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + follow = factories['federation.Follow']( + target=library_actor, + approved=True) + + params = {'pending': True} + url = reverse('api:v1:federation:libraries-followers') + response = superuser_api_client.get(url, params) + + assert response.status_code == 200 + assert len(response.data['results']) == 0 diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue index 910209b9dc1c6f506ab759404e3393e484b5b456..690291d5b1d08abaa60f8576fce71b25a73f1047 100644 --- a/front/src/components/common/DangerousButton.vue +++ b/front/src/components/common/DangerousButton.vue @@ -26,7 +26,7 @@ import Modal from '@/components/semantic/Modal' export default { props: { - action: {type: Function, required: true}, + action: {type: Function, required: false}, disabled: {type: Boolean, default: false}, color: {type: String, default: 'red'} }, @@ -41,7 +41,10 @@ export default { methods: { confirm () { this.showModal = false - this.action() + this.$emit('confirm') + if (this.action) { + this.action() + } } } } diff --git a/front/src/components/federation/LibraryCard.vue b/front/src/components/federation/LibraryCard.vue index 267d41bd0796bbe5e20c1f0d2bcc74ff745aa244..a5579c125acb8cf5691cfc572b03a6ce03fd5204 100644 --- a/front/src/components/federation/LibraryCard.vue +++ b/front/src/components/federation/LibraryCard.vue @@ -15,7 +15,7 @@ <span class="right floated" v-else> <i class="open lock icon"></i> Open </span> - <span> + <span v-if="totalItems"> <i class="music icon"></i> {{ totalItems }} tracks </span> @@ -25,10 +25,6 @@ <i class="clock icon"></i> Follow request pending approval </template> - <template v-else-if="following"> - <i class="check icon"></i> - Already following this library - </template> <div v-if="!library" @click="follow" diff --git a/front/src/components/federation/LibraryFollowTable.vue b/front/src/components/federation/LibraryFollowTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..9a35e0db636b92be2a953253d09c2418de731702 --- /dev/null +++ b/front/src/components/federation/LibraryFollowTable.vue @@ -0,0 +1,161 @@ +<template> + <div> + <div class="ui form"> + <div class="fields"> + <div class="ui six wide field"> + <input type="text" v-model="search" placeholder="Search by username, domain..." /> + </div> + <div class="ui four wide inline field"> + <div class="ui checkbox"> + <input v-model="pending" type="checkbox"> + <label>Pending approval</label> + </div> + </div> + </div> + </div> + <div class="ui hidden divider"></div> + <table v-if="result" class="ui very basic single line unstackable table"> + <thead> + <tr> + <th>Actor</th> + <th>Creation date</th> + <th>Status</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + <tr v-for="follow in result.results"> + <td> + {{ follow.actor.preferred_username }}@{{ follow.actor.domain }} + </td> + <td> + <human-date :date="follow.creation_date"></human-date> + </td> + <td> + <template v-if="follow.approved === true"> + <i class="check icon"></i> Approved + </template> + <template v-else-if="follow.approved === false"> + <i class="x icon"></i> Refused + </template> + <template v-else> + <i class="clock icon"></i> Pending + </template> + </td> + <td> + <dangerous-button v-if="follow.approved !== false" class="tiny basic labeled icon" color='red' @confirm="updateFollow(follow, false)"> + <i class="x icon"></i> Deny + <p slot="modal-header">Deny access?</p> + <p slot="modal-content">By confirming, {{ follow.actor.preferred_username }}@{{ follow.actor.domain }} will be denied access to your library.</p> + <p slot="modal-confirm">Deny</p> + </dangerous-button> + <dangerous-button v-if="follow.approved !== true" class="tiny basic labeled icon" color='green' @confirm="updateFollow(follow, true)"> + <i class="x icon"></i> Approve + <p slot="modal-header">Approve access?</p> + <p slot="modal-content">By confirming, {{ follow.actor.preferred_username }}@{{ follow.actor.domain }} will be granted access to your library.</p> + <p slot="modal-confirm">Approve</p> + </dangerous-button> + </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> + </th> + <th v-if="result && result.results.length > 0"> + Showing results {{ ((page-1) * paginateBy) + 1 }}-{{ ((page-1) * paginateBy) + result.results.length }} on {{ result.count }}</th> + <th></th> + <th></th> + </tr> + </tfoot> + </table> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' + +import Pagination from '@/components/Pagination' + +export default { + props: { + filters: {type: Object, required: false, default: () => {}} + }, + components: { + Pagination + }, + data () { + return { + isLoading: false, + result: null, + page: 1, + paginateBy: 25, + search: '', + pending: false + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search + }, this.filters) + if (this.pending) { + params.pending = true + } + let self = this + self.isLoading = true + axios.get('/federation/libraries/followers/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + }, + updateFollow (follow, approved) { + let payload = { + follow: follow.id, + approved: approved + } + let self = this + axios.patch('/federation/libraries/followers/', payload).then((response) => { + follow.approved = response.data.approved + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + }, + watch: { + search (newValue) { + if (newValue.length > 0) { + this.fetchData() + } + }, + page () { + this.fetchData() + }, + pending () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue index e5255252e463747149b210c1fbe2493f181dd4b6..6404f39905814e88812c3012e6be0d8e58fd4a52 100644 --- a/front/src/components/federation/LibraryTrackTable.vue +++ b/front/src/components/federation/LibraryTrackTable.vue @@ -64,13 +64,19 @@ ></pagination> </th> - <th>Showing results {{ ((page-1) * paginateBy) + 1 }}-{{ ((page-1) * paginateBy) + result.results.length }} on {{ result.count }}</th> + <th v-if="result && result.results.length > 0"> + Showing results {{ ((page-1) * paginateBy) + 1 }}-{{ ((page-1) * paginateBy) + result.results.length }} on {{ result.count }}</th> <th> <button @click="launchImport" :disabled="checked.length === 0 || isImporting" :class="['ui', 'green', {loading: isImporting}, 'button']">Import {{ checked.length }} tracks </button> + <router-link + v-if="importBatch" + :to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}"> + Import #{{ importBatch.id }} launched + </router-link> </th> <th></th> <th></th> @@ -104,7 +110,8 @@ export default { paginateBy: 25, search: '', checked: {}, - isImporting: false + isImporting: false, + importBatch: null } }, created () { @@ -135,6 +142,7 @@ export default { library_tracks: this.checked } axios.post('/submit/federation/', payload).then((response) => { + self.importBatch = response.data self.isImporting = false self.fetchData() }, error => { diff --git a/front/src/router/index.js b/front/src/router/index.js index 0ef3dcf24cefbe78045b1bc96bf55417b99cbd3f..a2bf781956a3350d514d108f5a8812be2e5f6156 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -30,6 +30,7 @@ import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' import FederationLibraryList from '@/views/federation/LibraryList' import FederationTrackList from '@/views/federation/LibraryTrackList' +import FederationFollowersList from '@/views/federation/LibraryFollowersList' Vue.use(Router) @@ -118,6 +119,17 @@ export default new Router({ defaultPage: route.query.page }) }, + { + path: 'followers', + name: 'federation.followers.list', + component: FederationFollowersList, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, { path: 'libraries/:id', name: 'federation.libraries.detail', component: FederationLibraryDetail, props: true } ] }, diff --git a/front/src/views/federation/Base.vue b/front/src/views/federation/Base.vue index b90ba723a29563bcb3c947071524ce22b1821be5..7958bb36b65332b840111cba5ab37c5e2a7962b0 100644 --- a/front/src/views/federation/Base.vue +++ b/front/src/views/federation/Base.vue @@ -7,10 +7,39 @@ <router-link class="ui item" :to="{name: 'federation.tracks.list'}">Tracks</router-link> + <div class="ui secondary right menu"> + <router-link + class="ui item" + :to="{name: 'federation.followers.list'}"> + Followers + <div class="ui teal label" title="Pending requests">{{ requestsCount }}</div> + </router-link> + </div> </div> <router-view :key="$route.fullPath"></router-view> </div> </template> +<script> +import axios from 'axios' +export default { + data () { + return { + requestsCount: 0 + } + }, + created () { + this.fetchRequestsCount() + }, + methods: { + fetchRequestsCount () { + let self = this + axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => { + self.requestsCount = response.data.count + }) + } + } +} +</script> <style lang="scss"> @import '../../style/vendor/media'; diff --git a/front/src/views/federation/LibraryFollowersList.vue b/front/src/views/federation/LibraryFollowersList.vue new file mode 100644 index 0000000000000000000000000000000000000000..8ca120e8b54e3178d16dfcd7cd6f4af9468ddf1d --- /dev/null +++ b/front/src/views/federation/LibraryFollowersList.vue @@ -0,0 +1,27 @@ +<template> + <div v-title="'Followers'"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">Browsing followers</h2> + <p> + Be careful when accepting follow requests, as it means the follower + will have access to your entire library. + </p> + <div class="ui hidden divider"></div> + <library-follow-table></library-follow-table> + </div> + </div> +</template> + +<script> +import LibraryFollowTable from '@/components/federation/LibraryFollowTable' + +export default { + components: { + LibraryFollowTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>