From 7cfa61292a12c49b9aa1f46711d3351ef77f66f8 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Thu, 21 Jun 2018 19:22:51 +0200
Subject: [PATCH] See #248: can now filter on invitation status and delete
 invitations

---
 api/funkwhale_api/manage/filters.py           |  8 +++-
 api/funkwhale_api/manage/serializers.py       |  9 +++++
 api/funkwhale_api/manage/views.py             | 10 +++++
 api/funkwhale_api/users/models.py             |  9 +++--
 api/tests/users/test_models.py                |  9 +++++
 front/src/components/common/ActionTable.vue   |  3 +-
 .../manage/users/InvitationForm.vue           |  2 +-
 .../manage/users/InvitationsTable.vue         | 37 ++++++++++++-------
 8 files changed, 67 insertions(+), 20 deletions(-)

diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index 16ee5c162..5f83ebf1a 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -40,7 +40,13 @@ class ManageUserFilterSet(filters.FilterSet):
 
 class ManageInvitationFilterSet(filters.FilterSet):
     q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"])
+    is_open = filters.BooleanFilter(method="filter_is_open")
 
     class Meta:
         model = users_models.Invitation
-        fields = ["q"]
+        fields = ["q", "is_open"]
+
+    def filter_is_open(self, queryset, field_name, value):
+        if value is None:
+            return queryset
+        return queryset.open(value)
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index f5d52bcac..d1a9ebb84 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -151,3 +151,12 @@ class ManageInvitationSerializer(serializers.ModelSerializer):
                 "An invitation with this code already exists"
             )
         return value
+
+
+class ManageInvitationActionSerializer(common_serializers.ActionSerializer):
+    actions = [common_serializers.Action("delete", allow_all=False)]
+    filterset_class = filters.ManageInvitationFilterSet
+
+    @transaction.atomic
+    def handle_delete(self, objects):
+        return objects.delete()
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index 803f8db7c..ae3c08a57 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -86,3 +86,13 @@ class ManageInvitationViewSet(
 
     def perform_create(self, serializer):
         serializer.save(owner=self.request.user)
+
+    @list_route(methods=["post"])
+    def action(self, request, *args, **kwargs):
+        queryset = self.get_queryset()
+        serializer = serializers.ManageInvitationActionSerializer(
+            request.data, queryset=queryset
+        )
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return response.Response(result, status=200)
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 61f57a3c5..e205d04d7 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -157,12 +157,13 @@ def generate_code(length=10):
 
 
 class InvitationQuerySet(models.QuerySet):
-    def open(self):
+    def open(self, include=True):
         now = timezone.now()
         qs = self.annotate(_users=models.Count("users"))
-        qs = qs.filter(_users=0)
-        qs = qs.exclude(expiration_date__lte=now)
-        return qs
+        query = models.Q(_users=0, expiration_date__gt=now)
+        if include:
+            return qs.filter(query)
+        return qs.exclude(query)
 
 
 class Invitation(models.Model):
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index 475691293..ea760cc6c 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -118,3 +118,12 @@ def test_can_filter_open_invitations(factories):
 
     assert models.Invitation.objects.count() == 3
     assert list(models.Invitation.objects.open()) == [okay]
+
+
+def test_can_filter_closed_invitations(factories):
+    factories["users.Invitation"]()
+    expired = factories["users.Invitation"](expired=True)
+    used = factories["users.User"](invited=True).invitation
+
+    assert models.Invitation.objects.count() == 3
+    assert list(models.Invitation.objects.open(False)) == [expired, used]
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
index f23479066..097fb2938 100644
--- a/front/src/components/common/ActionTable.vue
+++ b/front/src/components/common/ActionTable.vue
@@ -36,7 +36,7 @@
               <div class="count field">
                 <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
                 <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
-                <template v-if="!currentAction.isDangerous && checkable.length === checked.length">
+                <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length">
                   <a @click="selectAll = true" v-if="!selectAll">
                     {{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
                   </a>
@@ -157,6 +157,7 @@ export default {
       let self = this
       self.actionLoading = true
       self.result = null
+      self.actionErrors = []
       let payload = {
         action: this.currentActionName,
         filters: this.filters
diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue
index ffd5a7d12..9429c1ae1 100644
--- a/front/src/components/manage/users/InvitationForm.vue
+++ b/front/src/components/manage/users/InvitationForm.vue
@@ -1,6 +1,6 @@
 <template>
   <div>
-    <form v-if="!over" class="ui form" @submit.prevent="submit">
+    <form 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">
diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue
index e9b46cc2c..e8d0a2406 100644
--- a/front/src/components/manage/users/InvitationsTable.vue
+++ b/front/src/components/manage/users/InvitationsTable.vue
@@ -7,7 +7,7 @@
           <input type="text" v-model="search" placeholder="Search by username, email, code..." />
         </div>
         <div class="field">
-          <i18next tag="label" path="Ordering"/>
+          <label>{{ $t("Ordering") }}</label>
           <select class="ui dropdown" v-model="ordering">
             <option v-for="option in orderingOptions" :value="option[0]">
               {{ option[1] }}
@@ -15,10 +15,11 @@
           </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>
+          <label>{{ $t("Status") }}</label>
+          <select class="ui dropdown" v-model="isOpen">
+            <option :value="null">{{ $t('All') }}</option>
+            <option :value="true">{{ $t('Open') }}</option>
+            <option :value="false">{{ $t('Expired/used') }}</option>
           </select>
         </div>
       </div>
@@ -47,7 +48,7 @@
           </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-if="moment().isAfter(scope.obj.expiration_date)" class="ui red basic label">{{ $t('Expired') }}</span>
             <span v-else class="ui basic label">{{ $t('Not used') }}</span>
           </td>
           <td>
@@ -81,8 +82,8 @@
 
 <script>
 import axios from 'axios'
+import moment from 'moment'
 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'
@@ -99,12 +100,13 @@ export default {
   data () {
     let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
     return {
-      time,
+      moment,
       isLoading: false,
       result: null,
       page: 1,
       paginateBy: 50,
       search: '',
+      isOpen: null,
       orderingDirection: defaultOrdering.direction || '+',
       ordering: defaultOrdering.field,
       orderingOptions: [
@@ -123,6 +125,7 @@ export default {
         'page': this.page,
         'page_size': this.paginateBy,
         'q': this.search,
+        'is_open': this.isOpen,
         'ordering': this.getOrderingAsString()
       }, this.filters)
       let self = this
@@ -153,11 +156,13 @@ export default {
     },
     actions () {
       return [
-        // {
-        //   name: 'delete',
-        //   label: this.$t('Delete'),
-        //   isDangerous: true
-        // }
+        {
+          name: 'delete',
+          label: this.$t('Delete'),
+          filterCheckable: (obj) => {
+            return obj.users.length === 0 && moment().isBefore(obj.expiration_date)
+          }
+        }
       ]
     }
   },
@@ -170,9 +175,15 @@ export default {
       this.fetchData()
     },
     ordering () {
+      this.page = 1
+      this.fetchData()
+    },
+    isOpen () {
+      this.page = 1
       this.fetchData()
     },
     orderingDirection () {
+      this.page = 1
       this.fetchData()
     }
   }
-- 
GitLab