From fc09a3b320b98b950addc1c0cead8ef0a6f720df Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Sat, 14 Apr 2018 18:50:37 +0200
Subject: [PATCH] Can now accept/deny follow requests
---
api/funkwhale_api/federation/filters.py | 14 +-
api/funkwhale_api/federation/serializers.py | 38 ++++-
api/funkwhale_api/federation/views.py | 25 ++-
api/tests/federation/test_views.py | 34 ++++
.../src/components/common/DangerousButton.vue | 7 +-
.../src/components/federation/LibraryCard.vue | 6 +-
.../federation/LibraryFollowTable.vue | 161 ++++++++++++++++++
.../federation/LibraryTrackTable.vue | 12 +-
front/src/router/index.js | 12 ++
front/src/views/federation/Base.vue | 29 ++++
.../views/federation/LibraryFollowersList.vue | 27 +++
11 files changed, 344 insertions(+), 21 deletions(-)
create mode 100644 front/src/components/federation/LibraryFollowTable.vue
create mode 100644 front/src/views/federation/LibraryFollowersList.vue
diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py
index 2803186b..c911f1a8 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 e6ad0c0b..4964106d 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 a3f02a37..381f87ef 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 3e5bdf1a..8c5235b8 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 910209b9..690291d5 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 267d41bd..a5579c12 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 00000000..9a35e0db
--- /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 e5255252..6404f399 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 0ef3dcf2..a2bf7819 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 b90ba723..7958bb36 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 00000000..8ca120e8
--- /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>
--
GitLab