Verified Commit fc09a3b3 authored by Agate's avatar Agate 💬

Can now accept/deny follow requests

parent ca02aca3
......@@ -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"
......
<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';
......
<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>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment