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