kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
				
				
				
			See #248: can now generate and list invitations in the front-end
							rodzic
							
								
									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> | ||||
		Ładowanie…
	
		Reference in New Issue
	
	 Eliot Berriot
						Eliot Berriot