See #248: can now generate and list invitations in the front-end
This commit is contained in:
parent
d18f98e0f8
commit
107b1ea7dc
|
@ -1,4 +1,3 @@
|
|||
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
|
@ -37,3 +36,11 @@ class ManageUserFilterSet(filters.FilterSet):
|
|||
"permission_settings",
|
||||
"permission_federation",
|
||||
]
|
||||
|
||||
|
||||
class ManageInvitationFilterSet(filters.FilterSet):
|
||||
q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"])
|
||||
|
||||
class Meta:
|
||||
model = users_models.Invitation
|
||||
fields = ["q"]
|
||||
|
|
|
@ -78,6 +78,23 @@ class PermissionsSerializer(serializers.Serializer):
|
|||
return {"permissions": o}
|
||||
|
||||
|
||||
class ManageUserSimpleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = users_models.User
|
||||
fields = (
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"name",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"date_joined",
|
||||
"last_activity",
|
||||
"privacy_level",
|
||||
)
|
||||
|
||||
|
||||
class ManageUserSerializer(serializers.ModelSerializer):
|
||||
permissions = PermissionsSerializer(source="*")
|
||||
|
||||
|
@ -115,3 +132,23 @@ class ManageUserSerializer(serializers.ModelSerializer):
|
|||
update_fields=["permission_{}".format(p) for p in permissions.keys()]
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
class ManageInvitationSerializer(serializers.ModelSerializer):
|
||||
users = ManageUserSimpleSerializer(many=True, required=False)
|
||||
owner = ManageUserSimpleSerializer(required=False)
|
||||
code = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = users_models.Invitation
|
||||
fields = ("id", "owner", "code", "expiration_date", "creation_date", "users")
|
||||
read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"]
|
||||
|
||||
def validate_code(self, value):
|
||||
if not value:
|
||||
return value
|
||||
if users_models.Invitation.objects.filter(code=value.lower()).exists():
|
||||
raise serializers.ValidationError(
|
||||
"An invitation with this code already exists"
|
||||
)
|
||||
return value
|
||||
|
|
|
@ -7,6 +7,7 @@ library_router = routers.SimpleRouter()
|
|||
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
|
||||
users_router = routers.SimpleRouter()
|
||||
users_router.register(r"users", views.ManageUserViewSet, "users")
|
||||
users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
|
||||
|
|
|
@ -62,3 +62,27 @@ class ManageUserViewSet(
|
|||
context = super().get_serializer_context()
|
||||
context["default_permissions"] = preferences.get("users__default_permissions")
|
||||
return context
|
||||
|
||||
|
||||
class ManageInvitationViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
queryset = (
|
||||
users_models.Invitation.objects.all()
|
||||
.order_by("-id")
|
||||
.prefetch_related("users")
|
||||
.select_related("owner")
|
||||
)
|
||||
serializer_class = serializers.ManageInvitationSerializer
|
||||
filter_class = filters.ManageInvitationFilterSet
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ["settings"]
|
||||
ordering_fields = ["creation_date", "expiration_date"]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
|
|
@ -9,6 +9,7 @@ from funkwhale_api.manage import serializers, views
|
|||
[
|
||||
(views.ManageTrackFileViewSet, ["library"], "and"),
|
||||
(views.ManageUserViewSet, ["settings"], "and"),
|
||||
(views.ManageInvitationViewSet, ["settings"], "and"),
|
||||
],
|
||||
)
|
||||
def test_permissions(assert_user_permission, view, permissions, operator):
|
||||
|
@ -42,3 +43,23 @@ def test_user_view(factories, superuser_api_client, mocker):
|
|||
|
||||
assert response.data["count"] == len(users)
|
||||
assert response.data["results"] == expected
|
||||
|
||||
|
||||
def test_invitation_view(factories, superuser_api_client, mocker):
|
||||
invitations = factories["users.Invitation"].create_batch(size=5)
|
||||
qs = invitations[0].__class__.objects.order_by("-id")
|
||||
url = reverse("api:v1:manage:users:invitations-list")
|
||||
|
||||
response = superuser_api_client.get(url, {"sort": "-id"})
|
||||
expected = serializers.ManageInvitationSerializer(qs, many=True).data
|
||||
|
||||
assert response.data["count"] == len(invitations)
|
||||
assert response.data["results"] == expected
|
||||
|
||||
|
||||
def test_invitation_view_create(factories, superuser_api_client, mocker):
|
||||
url = reverse("api:v1:manage:users:invitations-list")
|
||||
response = superuser_api_client.post(url)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert superuser_api_client.user.invitations.latest("id") is not None
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
:to="{path: '/manage/users'}">
|
||||
:to="{name: 'manage.users.users.list'}">
|
||||
<i class="users icon"></i>{{ $t('Users') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<div>
|
||||
<form v-if="!over" class="ui form" @submit.prevent="submit">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">{{ $t('Error while creating invitation') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="inline fields">
|
||||
<div class="ui field">
|
||||
<label>{{ $t('Invitation code')}}</label>
|
||||
<input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" />
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit">
|
||||
{{ $t('Get a new invitation') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="invitations.length > 0">
|
||||
<div class="ui hidden divider"></div>
|
||||
<table class="ui ui basic table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('Code') }}</th>
|
||||
<th>{{ $t('Share link') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="invitation in invitations" :key="invitation.code">
|
||||
<td>{{ invitation.code.toUpperCase() }}</td>
|
||||
<td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
import backend from '@/audio/backend'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
code: null,
|
||||
invitations: [],
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
this.errors = []
|
||||
let url = 'manage/users/invitations/'
|
||||
let payload = {
|
||||
code: this.code
|
||||
}
|
||||
axios.post(url, payload).then((response) => {
|
||||
self.isLoading = false
|
||||
self.invitations.unshift(response.data)
|
||||
}, (error) => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
getUrl (code) {
|
||||
return backend.absoluteUrl(this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui inline form">
|
||||
<div class="fields">
|
||||
<div class="ui field">
|
||||
<label>{{ $t('Search') }}</label>
|
||||
<input type="text" v-model="search" placeholder="Search by username, email, code..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Ordering"/>
|
||||
<select class="ui dropdown" v-model="ordering">
|
||||
<option v-for="option in orderingOptions" :value="option[0]">
|
||||
{{ option[1] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Ordering direction"/>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="+">{{ $t('Ascending') }}</option>
|
||||
<option value="-">{{ $t('Descending') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dimmable">
|
||||
<div v-if="isLoading" class="ui active inverted dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<action-table
|
||||
v-if="result"
|
||||
@action-launched="fetchData"
|
||||
:objects-data="result"
|
||||
:actions="actions"
|
||||
:action-url="'manage/users/invitations/action/'"
|
||||
:filters="actionFilters">
|
||||
<template slot="header-cells">
|
||||
<th>{{ $t('Owner') }}</th>
|
||||
<th>{{ $t('Status') }}</th>
|
||||
<th>{{ $t('Creation date') }}</th>
|
||||
<th>{{ $t('Expiration date') }}</th>
|
||||
<th>{{ $t('Code') }}</th>
|
||||
</template>
|
||||
<template slot="row-cells" slot-scope="scope">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.owner.username }}</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span>
|
||||
<span v-else-if="scope.obj.expiration_date < new Date()" class="ui red basic label">{{ $t('Expired') }}</span>
|
||||
<span v-else class="ui basic label">{{ $t('Not used') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="scope.obj.creation_date"></human-date>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="scope.obj.expiration_date"></human-date>
|
||||
</td>
|
||||
<td>
|
||||
{{ scope.obj.code.toUpperCase() }}
|
||||
</td>
|
||||
</template>
|
||||
</action-table>
|
||||
</div>
|
||||
<div>
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
|
||||
<span v-if="result && result.results.length > 0">
|
||||
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import time from '@/utils/time'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ActionTable from '@/components/common/ActionTable'
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin],
|
||||
props: {
|
||||
filters: {type: Object, required: false}
|
||||
},
|
||||
components: {
|
||||
Pagination,
|
||||
ActionTable
|
||||
},
|
||||
data () {
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
time,
|
||||
isLoading: false,
|
||||
result: null,
|
||||
page: 1,
|
||||
paginateBy: 50,
|
||||
search: '',
|
||||
orderingDirection: defaultOrdering.direction || '+',
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['expiration_date', 'Expiration date'],
|
||||
['creation_date', 'Creation date']
|
||||
]
|
||||
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let params = _.merge({
|
||||
'page': this.page,
|
||||
'page_size': this.paginateBy,
|
||||
'q': this.search,
|
||||
'ordering': this.getOrderingAsString()
|
||||
}, this.filters)
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.checked = []
|
||||
axios.get('/manage/users/invitations/', {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
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actionFilters () {
|
||||
var currentFilters = {
|
||||
q: this.search
|
||||
}
|
||||
if (this.filters) {
|
||||
return _.merge(currentFilters, this.filters)
|
||||
} else {
|
||||
return currentFilters
|
||||
}
|
||||
},
|
||||
actions () {
|
||||
return [
|
||||
// {
|
||||
// name: 'delete',
|
||||
// label: this.$t('Delete'),
|
||||
// isDangerous: true
|
||||
// }
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search (newValue) {
|
||||
this.page = 1
|
||||
this.fetchData()
|
||||
},
|
||||
page () {
|
||||
this.fetchData()
|
||||
},
|
||||
ordering () {
|
||||
this.fetchData()
|
||||
},
|
||||
orderingDirection () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -45,7 +45,7 @@
|
|||
</template>
|
||||
<template slot="row-cells" slot-scope="scope">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
|
||||
<router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ scope.obj.email }}</span>
|
||||
|
|
|
@ -34,6 +34,7 @@ import AdminLibraryFilesList from '@/views/admin/library/FilesList'
|
|||
import AdminUsersBase from '@/views/admin/users/Base'
|
||||
import AdminUsersDetail from '@/views/admin/users/UsersDetail'
|
||||
import AdminUsersList from '@/views/admin/users/UsersList'
|
||||
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
|
||||
import FederationBase from '@/views/federation/Base'
|
||||
import FederationScan from '@/views/federation/Scan'
|
||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||
|
@ -191,15 +192,20 @@ export default new Router({
|
|||
component: AdminUsersBase,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'manage.users.list',
|
||||
path: 'users',
|
||||
name: 'manage.users.users.list',
|
||||
component: AdminUsersList
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'manage.users.detail',
|
||||
path: 'users/:id',
|
||||
name: 'manage.users.users.detail',
|
||||
component: AdminUsersDetail,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'invitations',
|
||||
name: 'manage.users.invitations.list',
|
||||
component: AdminInvitationsList
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
<div class="ui secondary pointing menu">
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'manage.users.list'}">{{ $t('Users') }}</router-link>
|
||||
:to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link>
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link>
|
||||
</div>
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<div v-title="$t('Invitations')">
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">{{ $t('Invitations') }}</h2>
|
||||
<invitation-form></invitation-form>
|
||||
<div class="ui hidden divider"></div>
|
||||
<invitations-table></invitations-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InvitationForm from '@/components/manage/users/InvitationForm'
|
||||
import InvitationsTable from '@/components/manage/users/InvitationsTable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InvitationForm,
|
||||
InvitationsTable
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
Loading…
Reference in New Issue