Repository: modoboa/modoboa (language: python)

Issue:
Display domain alarms in new UI
Domain alarms are currently not displayed in the new UI.


Discussion / hints:

Fix patch:
diff --git a/frontend/src/api/alarms.js b/frontend/src/api/alarms.js
new file mode 100644
index 000000000..62ac76676
--- /dev/null
+++ b/frontend/src/api/alarms.js
@@ -0,0 +1,9 @@
+import repository from './repository'
+
+const resource = 'alarms'
+
+export default {
+  getAll () {
+    return repository.get(`${resource}/`)
+  }
+}
diff --git a/frontend/src/components/alarms/AlarmList.vue b/frontend/src/components/alarms/AlarmList.vue
new file mode 100644
index 000000000..842c17147
--- /dev/null
+++ b/frontend/src/components/alarms/AlarmList.vue
@@ -0,0 +1,63 @@
+<template>
+<v-card class="mt-6">
+  <v-toolbar flat>
+    <v-menu offset-y>
+      <template v-slot:activator="{ on, attrs }">
+        <v-btn v-bind="attrs" v-on="on" small>
+          Actions <v-icon right>mdi-chevron-down</v-icon>
+        </v-btn>
+      </template>
+      <v-list dense>
+      </v-list>
+    </v-menu>
+    <v-spacer></v-spacer>
+    <v-text-field
+      v-model="search"
+      prepend-inner-icon="mdi-magnify"
+      placeholder="Search"
+      filled
+      outlined
+      dense
+      hide-details
+      ></v-text-field>
+  </v-toolbar>
+  <v-data-table
+    v-model="selected"
+    :headers="headers"
+    :items="alarms"
+    :search="search"
+    item-key="pk"
+    class="elevation-1"
+    show-select
+    >
+    <template v-slot:item.created="{ item }">
+      {{ item.created|date }}
+    </template>
+  </v-data-table>
+</v-card>
+</template>
+
+<script>
+import alarms from '@/api/alarms'
+
+export default {
+  data () {
+    return {
+      alarms: [],
+      headers: [
+        { text: this.$gettext('Date'), value: 'created' },
+        { text: this.$gettext('Domain'), value: 'domain.name' },
+        { text: this.$gettext('Mailbox'), value: 'mailbox.name' },
+        { text: this.$gettext('Message'), value: 'title' }
+      ],
+      search: '',
+      selected: []
+    }
+  },
+  mounted () {
+    alarms.getAll().then(resp => {
+      this.alarms = resp.data
+    })
+  }
+}
+</script>
diff --git a/frontend/src/components/domains/DomainList.vue b/frontend/src/components/domains/DomainList.vue
index be5958cbd..1a2b7db4d 100644
--- a/frontend/src/components/domains/DomainList.vue
+++ b/frontend/src/components/domains/DomainList.vue
@@ -75,11 +75,22 @@
           <div class="text-right">
             <v-menu offset-y>
               <template v-slot:activator="{ on, attrs }">
-                <v-btn icon v-bind="attrs" v-on="on">
+                <v-badge
+                  v-if="item.opened_alarms_count"
+                  bordered
+                  color="error"
+                  icon="mdi-bell"
+                  overlap
+                  >
+                  <v-btn icon v-bind="attrs" v-on="on">
+                    <v-icon>mdi-dots-horizontal</v-icon>
+                  </v-btn>
+                </v-badge>
+                <v-btn v-else icon v-bind="attrs" v-on="on">
                   <v-icon>mdi-dots-horizontal</v-icon>
                 </v-btn>
               </template>
-              <menu-items :items="domainMenuItems" :object="item" />
+              <menu-items :items="getDomainMenuItems(item)" :object="item" />
             </v-menu>
           </div>
         </td>
@@ -151,11 +162,6 @@ export default {
   }),
   data () {
     return {
-      domainMenuItems: [
-        { label: this.$gettext('Administrators'), icon: 'mdi-account-supervisor', onClick: this.openAdminList },
-        { label: this.$gettext('Edit'), icon: 'mdi-circle-edit-outline', onClick: this.editDomain },
-        { label: this.$gettext('Delete'), icon: 'mdi-delete-outline', onClick: this.deleteDomain, color: 'red' }
-      ],
       headers: [
         { text: this.$gettext('Name'), value: 'name' },
         { text: this.$gettext('Aliases'), value: 'domainalias_count' },
@@ -243,6 +249,22 @@ export default {
     },
     showAliases (item) {
       this.expanded = [{ item }]
+    },
+    getDomainMenuItems (domain) {
+      const result = [
+        { label: this.$gettext('Administrators'), icon: 'mdi-account-supervisor', onClick: this.openAdminList },
+        { label: this.$gettext('Edit'), icon: 'mdi-circle-edit-outline', onClick: this.editDomain },
+        { label: this.$gettext('Delete'), icon: 'mdi-delete-outline', onClick: this.deleteDomain, color: 'red' }
+      ]
+      if (domain.opened_alarms_count) {
+        result.push({
+          label: this.$gettext('Alarms'),
+          icon: 'mdi-bell',
+          color: 'red',
+          onClick: () => this.$router.push({ name: 'Alarms' })
+        })
+      }
+      return result
     }
   }
 }
diff --git a/frontend/src/components/layout/Navbar.vue b/frontend/src/components/layout/Navbar.vue
index cff2f31d4..a0d16369d 100644
--- a/frontend/src/components/layout/Navbar.vue
+++ b/frontend/src/components/layout/Navbar.vue
@@ -174,6 +174,11 @@ export default {
           icon: 'mdi-account',
           roles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
         },
+        {
+          text: this.$gettext('Alarms'),
+          to: { name: 'Alarms' },
+          icon: 'mdi-bell'
+        },
         {
           icon: 'mdi-history',
           text: this.$gettext('Logs'),
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index 70917c6b7..42bd0cc0f 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -110,6 +110,14 @@ const routes = [
       allowedRoles: ['SuperAdmins']
     }
   },
+  {
+    path: '/alarms',
+    name: 'Alarms',
+    component: () => import('../views/alarms/Alarms.vue'),
+    meta: {
+      requiresAuth: true
+    }
+  },
   {
     path: '/logs/audit_trail',
     name: 'AuditTrail',
diff --git a/frontend/src/views/alarms/Alarms.vue b/frontend/src/views/alarms/Alarms.vue
new file mode 100644
index 000000000..2268169e4
--- /dev/null
+++ b/frontend/src/views/alarms/Alarms.vue
@@ -0,0 +1,24 @@
+<template>
+<div>
+  <v-toolbar flat>
+    <v-toolbar-title><translate>Opened alarms</translate></v-toolbar-title>
+  </v-toolbar>
+  <alarm-list />
+</div>
+</template>
+
+<script>
+import AlarmList from '@/components/alarms/AlarmList'
+
+export default {
+  components: {
+    AlarmList
+  }
+}
+</script>
+
+<style scoped>
+.v-toolbar {
+  background-color: #f7f8fa !important;
+}
+</style>
diff --git a/modoboa/admin/api/v2/serializers.py b/modoboa/admin/api/v2/serializers.py
index 62fe5fb51..84bccec49 100644
--- a/modoboa/admin/api/v2/serializers.py
+++ b/modoboa/admin/api/v2/serializers.py
@@ -2,6 +2,7 @@
 
 import ipaddress
 import os
+from typing import List
 
 from django.conf import settings
 from django.core.exceptions import ValidationError
@@ -54,7 +55,7 @@ class DomainSerializer(v1_serializers.DomainSerializer):
 
     class Meta(v1_serializers.DomainSerializer.Meta):
         fields = v1_serializers.DomainSerializer.Meta.fields + (
-            "domain_admin", "transport"
+            "domain_admin", "transport", "opened_alarms_count"
         )
 
     def __init__(self, *args, **kwargs):
@@ -321,7 +322,7 @@ def __init__(self, *args, **kwargs):
             return
         self.fields["resources"] = serializers.SerializerMethodField()
 
-    def get_resources(self, account):
+    def get_resources(self, account) -> List[AccountResourceSerializer]:
         if account.role == 'SimpleUsers':
             return []
         resources = []
@@ -578,3 +579,12 @@ class CSVIdentityImportSerializer(CSVImportSerializer):
     """Custom serializer for identity import."""
 
     crypt_password = serializers.BooleanField()
+
+
+class AlarmSerializer(serializers.ModelSerializer):
+    """Serializer for Alarm related endpoints."""
+
+    class Meta:
+        depth = 1
+        fields = "__all__"
+        model = models.Alarm
diff --git a/modoboa/admin/api/v2/urls.py b/modoboa/admin/api/v2/urls.py
index d49f66bbc..aa0e04b9d 100644
--- a/modoboa/admin/api/v2/urls.py
+++ b/modoboa/admin/api/v2/urls.py
@@ -19,5 +19,7 @@
     r"identities", viewsets.IdentityViewSet, basename="identities")
 router.register(
     r"account", viewsets.UserAccountViewSet, basename="account")
+router.register(
+    r"alarms", viewsets.AlarmViewSet, basename="alarm")
 
 urlpatterns = router.urls
diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py
index f4908a643..1c4400488 100644
--- a/modoboa/admin/api/v2/viewsets.py
+++ b/modoboa/admin/api/v2/viewsets.py
@@ -321,3 +321,18 @@ def forward(self, request, **kwargs):
                 ).update(enabled=False)
             alias.set_recipients(recipients)
         return response.Response(serializer.validated_data)
+
+
+class AlarmViewSet(viewsets.ReadOnlyModelViewSet):
+    """Viewset for Alarm."""
+
+    permission_classes = (
+        permissions.IsAuthenticated,
+    )
+    serializer_class = serializers.AlarmSerializer
+
+    def get_queryset(self):
+        return models.Alarm.objects.filter(
+            domain__in=models.Domain.objects.get_for_admin(
+                self.request.user)
+        )
diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py
index ea7b6f02f..66bb412a5 100644
--- a/modoboa/admin/models/domain.py
+++ b/modoboa/admin/models/domain.py
@@ -92,12 +92,17 @@ def mbalias_count(self) -> int:
         return self.alias_set.filter(internal=False).count()
 
     @property
-    def identities_count(self):
+    def identities_count(self) -> int:
         """Total number of identities in this domain."""
         return (
             self.mailbox_set.count() +
             self.alias_set.filter(internal=False).count())
 
+    @property
+    def opened_alarms_count(self) -> int:
+        """Number of alarms currently opened for this domain."""
+        return self.alarms.opened().count()
+
     @property
     def tags(self):
         label = ""
diff --git a/modoboa/limits/models.py b/modoboa/limits/models.py
index 9c87f8494..65445c342 100644
--- a/modoboa/limits/models.py
+++ b/modoboa/limits/models.py
@@ -13,7 +13,7 @@ class ObjectLimitMixin(object):
     """Common methods."""
 
     @property
-    def label(self):
+    def label(self) -> str:
         """Return display name."""
         return self.definition["label"]
 
@@ -23,7 +23,7 @@ def type(self):
         return self.definition.get("type", "count")
 
     @property
-    def usage(self):
+    def usage(self) -> int:
         """Return current limit usage in %."""
         if self.max_value < 0:
             return -1
@@ -79,7 +79,7 @@ def definition(self):
         return None
 
     @property
-    def current_value(self):
+    def current_value(self) -> int:
         """Return the current number of objects."""
         condition = (
             self.type == "count" and
@@ -107,7 +107,7 @@ def label(self):
         return self.definition["label"]
 
     @cached_property
-    def usage(self):
+    def usage(self) -> int:
         """Return current limit usage in %."""
         if self.max_value < 0:
             return -1
@@ -141,7 +141,7 @@ def definition(self):
         return None
 
     @property
-    def current_value(self):
+    def current_value(self) -> int:
         """Return the current number of objects."""
         definition = self.definition
         if not definition:


Test patch:
diff --git a/modoboa/admin/api/v2/tests.py b/modoboa/admin/api/v2/tests.py
index bcbd146b7..d1cec2bd2 100644
--- a/modoboa/admin/api/v2/tests.py
+++ b/modoboa/admin/api/v2/tests.py
@@ -467,3 +467,20 @@ def test_forward(self):
             models.Alias.objects.filter(address=self.da.username).count(),
             1
         )
+
+
+class AlarmViewSetTestCase(ModoAPITestCase):
+
+    @classmethod
+    def setUpTestData(cls):  # NOQA:N802
+        """Create test data."""
+        super().setUpTestData()
+        factories.populate_database()
+        factories.AlarmFactory(
+            domain__name="test.com", mailbox=None, title="Test alarm")
+
+    def test_list(self):
+        url = reverse("v2:alarm-list")
+        resp = self.client.get(url)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(len(resp.json()), 1)