Verified Commit fc09a3b3 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Can now accept/deny follow requests

parent ca02aca3
...@@ -43,6 +43,7 @@ class LibraryTrackFilter(django_filters.FilterSet): ...@@ -43,6 +43,7 @@ class LibraryTrackFilter(django_filters.FilterSet):
class FollowFilter(django_filters.FilterSet): class FollowFilter(django_filters.FilterSet):
pending = django_filters.CharFilter(method='filter_pending')
ordering = django_filters.OrderingFilter( ordering = django_filters.OrderingFilter(
# tuple-mapping retains order # tuple-mapping retains order
fields=( fields=(
...@@ -50,9 +51,16 @@ class FollowFilter(django_filters.FilterSet): ...@@ -50,9 +51,16 @@ class FollowFilter(django_filters.FilterSet):
('modification_date', 'modification_date'), ('modification_date', 'modification_date'),
), ),
) )
q = fields.SearchFilter(search_fields=[
'actor__domain',
'actor__preferred_username',
])
class Meta: class Meta:
model = models.Follow model = models.Follow
fields = { fields = ['approved', 'pending', 'q']
'approved': ['exact'],
} def filter_pending(self, queryset, field_name, value):
if value.lower() in ['true', '1', 'yes']:
queryset = queryset.filter(approved__isnull=True)
return queryset
...@@ -190,6 +190,35 @@ class APILibraryScanSerializer(serializers.Serializer): ...@@ -190,6 +190,35 @@ class APILibraryScanSerializer(serializers.Serializer):
until = serializers.DateTimeField(required=False) 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): class APILibraryCreateSerializer(serializers.ModelSerializer):
actor = serializers.URLField() actor = serializers.URLField()
federation_enabled = serializers.BooleanField() federation_enabled = serializers.BooleanField()
...@@ -233,8 +262,13 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): ...@@ -233,8 +262,13 @@ class APILibraryCreateSerializer(serializers.ModelSerializer):
library_data = library.get_library_data( library_data = library.get_library_data(
acs.validated_data['library_url']) acs.validated_data['library_url'])
if 'errors' in library_data: 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'] = library_data
validated_data['library'].setdefault(
'id', acs.validated_data['library_url']
)
validated_data['actor'] = actor validated_data['actor'] = actor
return validated_data return validated_data
...@@ -244,7 +278,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): ...@@ -244,7 +278,7 @@ class APILibraryCreateSerializer(serializers.ModelSerializer):
defaults={ defaults={
'actor': validated_data['actor'], 'actor': validated_data['actor'],
'follow': validated_data['follow'], 'follow': validated_data['follow'],
'tracks_count': validated_data['library']['totalItems'], 'tracks_count': validated_data['library'].get('totalItems'),
'federation_enabled': validated_data['federation_enabled'], 'federation_enabled': validated_data['federation_enabled'],
'autoimport': validated_data['autoimport'], 'autoimport': validated_data['autoimport'],
'download_files': validated_data['download_files'], 'download_files': validated_data['download_files'],
......
...@@ -221,31 +221,42 @@ class LibraryViewSet( ...@@ -221,31 +221,42 @@ class LibraryViewSet(
queryset = models.Follow.objects.filter( queryset = models.Follow.objects.filter(
actor=library_actor actor=library_actor
).select_related( ).select_related(
'target', 'actor',
'target', 'target',
).order_by('-creation_date') ).order_by('-creation_date')
filterset = filters.FollowFilter(request.GET, queryset=queryset) 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 = { data = {
'results': serializer.data, 'results': serializer.data,
'count': len(filterset.qs), 'count': len(final_qs),
} }
return response.Response(data) return response.Response(data)
@list_route(methods=['get']) @list_route(methods=['get', 'patch'])
def followers(self, request, *args, **kwargs): 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() library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
queryset = models.Follow.objects.filter( queryset = models.Follow.objects.filter(
target=library_actor target=library_actor
).select_related( ).select_related(
'target', 'actor',
'target', 'target',
).order_by('-creation_date') ).order_by('-creation_date')
filterset = filters.FollowFilter(request.GET, queryset=queryset) 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 = { data = {
'results': serializer.data, 'results': serializer.data,
'count': len(filterset.qs), 'count': len(final_qs),
} }
return response.Response(data) return response.Response(data)
......
...@@ -346,3 +346,37 @@ def test_list_library_tracks(factories, superuser_api_client): ...@@ -346,3 +346,37 @@ def test_list_library_tracks(factories, superuser_api_client):
'previous': None, 'previous': None,
'next': 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
...@@ -26,7 +26,7 @@ import Modal from '@/components/semantic/Modal' ...@@ -26,7 +26,7 @@ import Modal from '@/components/semantic/Modal'
export default { export default {
props: { props: {
action: {type: Function, required: true}, action: {type: Function, required: false},
disabled: {type: Boolean, default: false}, disabled: {type: Boolean, default: false},
color: {type: String, default: 'red'} color: {type: String, default: 'red'}
}, },
...@@ -41,7 +41,10 @@ export default { ...@@ -41,7 +41,10 @@ export default {
methods: { methods: {
confirm () { confirm () {
this.showModal = false this.showModal = false
this.action() this.$emit('confirm')
if (this.action) {
this.action()
}
} }
} }
} }
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
<span class="right floated" v-else> <span class="right floated" v-else>
<i class="open lock icon"></i> Open <i class="open lock icon"></i> Open
</span> </span>
<span> <span v-if="totalItems">
<i class="music icon"></i> <i class="music icon"></i>
{{ totalItems }} tracks {{ totalItems }} tracks
</span> </span>
...@@ -25,10 +25,6 @@ ...@@ -25,10 +25,6 @@
<i class="clock icon"></i> <i class="clock icon"></i>
Follow request pending approval Follow request pending approval
</template> </template>
<template v-else-if="following">
<i class="check icon"></i>
Already following this library
</template>
<div <div
v-if="!library" v-if="!library"
@click="follow" @click="follow"
......
<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>
...@@ -64,13 +64,19 @@ ...@@ -64,13 +64,19 @@
></pagination> ></pagination>
</th> </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> <th>
<button <button
@click="launchImport" @click="launchImport"
:disabled="checked.length === 0 || isImporting" :disabled="checked.length === 0 || isImporting"
:class="['ui', 'green', {loading: isImporting}, 'button']">Import {{ checked.length }} tracks :class="['ui', 'green', {loading: isImporting}, 'button']">Import {{ checked.length }} tracks
</button> </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></th>
<th></th> <th></th>
...@@ -104,7 +110,8 @@ export default { ...@@ -104,7 +110,8 @@ export default {
paginateBy: 25, paginateBy: 25,
search: '', search: '',
checked: {}, checked: {},
isImporting: false isImporting: false,
importBatch: null
} }
}, },
created () { created () {
...@@ -135,6 +142,7 @@ export default { ...@@ -135,6 +142,7 @@ export default {
library_tracks: this.checked library_tracks: this.checked
} }
axios.post('/submit/federation/', payload).then((response) => { axios.post('/submit/federation/', payload).then((response) => {
self.importBatch = response.data
self.isImporting = false self.isImporting = false
self.fetchData() self.fetchData()
}, error => { }, error => {
......
...@@ -30,6 +30,7 @@ import FederationScan from '@/views/federation/Scan' ...@@ -30,6 +30,7 @@ import FederationScan from '@/views/federation/Scan'
import FederationLibraryDetail from '@/views/federation/LibraryDetail' import FederationLibraryDetail from '@/views/federation/LibraryDetail'
import FederationLibraryList from '@/views/federation/LibraryList' import FederationLibraryList from '@/views/federation/LibraryList'
import FederationTrackList from '@/views/federation/LibraryTrackList' import FederationTrackList from '@/views/federation/LibraryTrackList'
import FederationFollowersList from '@/views/federation/LibraryFollowersList'
Vue.use(Router) Vue.use(Router)
...@@ -118,6 +119,17 @@ export default new Router({ ...@@ -118,6 +119,17 @@ export default new Router({
defaultPage: route.query.page 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 } { path: 'libraries/:id', name: 'federation.libraries.detail', component: FederationLibraryDetail, props: true }
] ]
}, },
......
...@@ -7,10 +7,39 @@ ...@@ -7,10 +7,39 @@
<router-link <router-link
class="ui item" class="ui item"
:to="{name: 'federation.tracks.list'}">Tracks</router-link> :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> </div>
<router-view :key="$route.fullPath"></router-view> <router-view :key="$route.fullPath"></router-view>
</div> </div>
</template> </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"> <style lang="scss">
@import '../../style/vendor/media'; @import '../../style/vendor/media';
......
<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>