Skip to content
Snippets Groups Projects
Verified Commit 107b1ea7 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

See #248: can now generate and list invitations in the front-end

parent d18f98e0
No related branches found
No related tags found
No related merge requests found
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>
......
<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>
<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>
......
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment