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 @@
+
+
+
+
+
+
+ Actions mdi-chevron-down
+
+
+
+
+
+
+
+
+
+
+ {{ item.created|date }}
+
+
+
+
+
+
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 @@
-
+
+
+ mdi-dots-horizontal
+
+
+
mdi-dots-horizontal
-
+
@@ -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 @@
+
+
+
+
+
+
+
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 @@ class AccountSerializer(v1_serializers.AccountSerializer):
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/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 @@ class UserAccountViewSetTestCase(ModoAPITestCase):
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)
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 @@ router.register(
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 @@ class UserAccountViewSet(viewsets.ViewSet):
).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 @@ class Domain(mixins.MessageLimitMixin, AdminObject):
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 @@ class ObjectLimitMixin(object):
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 @@ class UserObjectLimit(ObjectLimitMixin, models.Model):
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 @@ class UserObjectLimit(ObjectLimitMixin, models.Model):
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 @@ class DomainObjectLimit(ObjectLimitMixin, models.Model):
return None
@property
- def current_value(self):
+ def current_value(self) -> int:
"""Return the current number of objects."""
definition = self.definition
if not definition: