From 107b1ea7dc98ea35e38c9555bbe2457c688fa000 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Tue, 19 Jun 2018 23:27:21 +0200 Subject: [PATCH] See #248: can now generate and list invitations in the front-end --- api/funkwhale_api/manage/filters.py | 9 +- api/funkwhale_api/manage/serializers.py | 37 ++++ api/funkwhale_api/manage/urls.py | 1 + api/funkwhale_api/manage/views.py | 24 +++ api/tests/manage/test_views.py | 21 ++ front/src/components/Sidebar.vue | 2 +- .../manage/users/InvitationForm.vue | 82 ++++++++ .../manage/users/InvitationsTable.vue | 180 ++++++++++++++++++ .../components/manage/users/UsersTable.vue | 2 +- front/src/router/index.js | 14 +- front/src/views/admin/users/Base.vue | 5 +- .../src/views/admin/users/InvitationsList.vue | 26 +++ 12 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 front/src/components/manage/users/InvitationForm.vue create mode 100644 front/src/components/manage/users/InvitationsTable.vue create mode 100644 front/src/views/admin/users/InvitationsList.vue diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index e4cda18c5b..16ee5c162f 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -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"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 6e57db81f9..e8f1e328ef 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -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 diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index f208fb8570..3d4e15db93 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -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")), diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index f9b78ef87a..803f8db7c3 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -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) diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index a72bcf5af4..d54fca5dda 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -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 diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 03ea4ee077..87c374a336 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -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> diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue new file mode 100644 index 0000000000..ffd5a7d126 --- /dev/null +++ b/front/src/components/manage/users/InvitationForm.vue @@ -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> diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue new file mode 100644 index 0000000000..e9b46cc2c9 --- /dev/null +++ b/front/src/components/manage/users/InvitationsTable.vue @@ -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> diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue index 5658583c3d..855fbe2b5d 100644 --- a/front/src/components/manage/users/UsersTable.vue +++ b/front/src/components/manage/users/UsersTable.vue @@ -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> diff --git a/front/src/router/index.js b/front/src/router/index.js index 5528addd43..1947437687 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -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 } ] }, diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue index e545b7f709..505ca587fe 100644 --- a/front/src/views/admin/users/Base.vue +++ b/front/src/views/admin/users/Base.vue @@ -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> diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue new file mode 100644 index 0000000000..230dad6c19 --- /dev/null +++ b/front/src/views/admin/users/InvitationsList.vue @@ -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> -- GitLab