Can now accept/deny follow requests
This commit is contained in:
parent
ca02aca327
commit
fc09a3b320
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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 => {
|
||||
|
|
|
@ -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 }
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue