Selaa lähdekoodia

Closes #15621: User notifications (#16800)

* Initial work on #15621

* Signal receiver should ignore models which don't support notifications

* Flesh out NotificationGroup functionality

* Add NotificationGroup filters for users & groups

* Separate read & dimiss actions

* Enable one-click dismissals from notifications list

* Include total notification count in dropdown

* Drop 'kind' field from Notification model

* Register event types in the registry; add colors & icons

* Enable event rules to target notification groups

* Define dynamic choices for Notification.event_name

* Move event registration to core

* Add more job events

* Misc cleanup

* Misc cleanup

* Correct absolute URLs for notifications & subscriptions

* Optimize subscriber notifications

* Use core event types when queuing events

* Standardize queued event attribute to event_type; change content_type to object_type

* Rename Notification.event_name to event_type

* Restore NotificationGroupBulkEditView

* Add API tests

* Add view & filterset tests

* Add model documentation

* Fix tests

* Update notification bell when notifications have been cleared

* Ensure subscribe button appears only on relevant models

* Notifications/subscriptions cannot be ordered by object

* Misc cleanup

* Add event icon & type to notifications table

* Adjust icon sizing

* Mute color of read notifications

* Misc cleanup
Jeremy Stretch 1 vuosi sitten
vanhempi
commit
b0e7294bc1
59 muutettua tiedostoa jossa 1912 lisäystä ja 89 poistoa
  1. 17 0
      docs/models/extras/notification.md
  2. 17 0
      docs/models/extras/notificationgroup.md
  3. 15 0
      docs/models/extras/subscription.md
  4. 3 0
      mkdocs.yml
  5. 2 0
      netbox/account/urls.py
  6. 31 1
      netbox/account/views.py
  7. 1 1
      netbox/core/apps.py
  8. 33 0
      netbox/core/events.py
  9. 0 1
      netbox/core/models/jobs.py
  10. 1 0
      netbox/extras/api/serializers.py
  11. 82 0
      netbox/extras/api/serializers_/notifications.py
  12. 3 0
      netbox/extras/api/urls.py
  13. 21 0
      netbox/extras/api/views.py
  14. 2 0
      netbox/extras/choices.py
  15. 9 12
      netbox/extras/constants.py
  16. 48 30
      netbox/extras/events.py
  17. 45 0
      netbox/extras/filtersets.py
  18. 15 0
      netbox/extras/forms/bulk_edit.py
  19. 25 2
      netbox/extras/forms/bulk_import.py
  20. 15 0
      netbox/extras/forms/filtersets.py
  21. 58 0
      netbox/extras/forms/model_forms.py
  22. 7 0
      netbox/extras/graphql/filters.py
  23. 15 0
      netbox/extras/graphql/schema.py
  24. 28 0
      netbox/extras/graphql/types.py
  25. 78 0
      netbox/extras/migrations/0118_notifications.py
  26. 1 0
      netbox/extras/models/__init__.py
  27. 222 0
      netbox/extras/models/notifications.py
  28. 15 0
      netbox/extras/querysets.py
  29. 59 12
      netbox/extras/signals.py
  30. 13 0
      netbox/extras/tables/columns.py
  31. 97 2
      netbox/extras/tables/tables.py
  32. 195 2
      netbox/extras/tests/test_api.py
  33. 14 14
      netbox/extras/tests/test_event_rules.py
  34. 63 4
      netbox/extras/tests/test_filtersets.py
  35. 164 3
      netbox/extras/tests/test_views.py
  36. 18 0
      netbox/extras/urls.py
  37. 134 0
      netbox/extras/views.py
  38. 2 2
      netbox/extras/webhooks.py
  39. 45 0
      netbox/netbox/events.py
  40. 1 0
      netbox/netbox/models/__init__.py
  41. 22 1
      netbox/netbox/models/features.py
  42. 1 0
      netbox/netbox/navigation/menu.py
  43. 1 0
      netbox/netbox/registry.py
  44. 10 0
      netbox/netbox/settings.py
  45. 0 0
      netbox/project-static/dist/netbox.css
  46. 9 0
      netbox/project-static/styles/custom/_notifications.scss
  47. 1 0
      netbox/project-static/styles/netbox.scss
  48. 6 0
      netbox/templates/account/base.html
  49. 32 0
      netbox/templates/account/notifications.html
  50. 32 0
      netbox/templates/account/subscriptions.html
  51. 57 0
      netbox/templates/extras/notificationgroup.html
  52. 3 0
      netbox/templates/generic/object.html
  53. 33 0
      netbox/templates/htmx/notifications.html
  54. 9 0
      netbox/templates/inc/notification_bell.html
  55. 14 0
      netbox/templates/inc/user_menu.html
  56. 11 0
      netbox/users/filtersets.py
  57. 1 1
      netbox/utilities/templates/buttons/bookmark.html
  58. 18 0
      netbox/utilities/templates/buttons/subscribe.html
  59. 38 1
      netbox/utilities/templatetags/buttons.py

+ 17 - 0
docs/models/extras/notification.md

@@ -0,0 +1,17 @@
+# Notification
+
+A notification alerts a user that a specific action has taken place in NetBox, such as an object being modified or a background job completing. A notification may be generated via a user's [subscription](./subscription.md) to a particular object, or by an event rule targeting a [notification group](./notificationgroup.md) of which the user is a member.
+
+## Fields
+
+### User
+
+The recipient of the notification.
+
+### Object
+
+The object to which the notification relates.
+
+### Event Type
+
+The type of event indicated by the notification.

+ 17 - 0
docs/models/extras/notificationgroup.md

@@ -0,0 +1,17 @@
+# Notification Group
+
+A set of NetBox users and/or groups of users identified as recipients for certain [notifications](./notification.md).
+
+## Fields
+
+### Name
+
+The name of the notification group.
+
+### Users
+
+One or more users directly designated as members of the notification group.
+
+### Groups
+
+All users of any selected groups are considered as members of the notification group.

+ 15 - 0
docs/models/extras/subscription.md

@@ -0,0 +1,15 @@
+# Subscription
+
+A record indicating that a user is to be notified of any changes to a particular NetBox object. A notification maps exactly one user to exactly one object.
+
+When an object to which a user is subscribed changes, a [notification](./notification.md) is generated for the user.
+
+## Fields
+
+### User
+
+The subscribed user.
+
+### Object
+
+The object to which the user is subscribed.

+ 3 - 0
mkdocs.yml

@@ -225,8 +225,11 @@ nav:
             - ExportTemplate: 'models/extras/exporttemplate.md'
             - ExportTemplate: 'models/extras/exporttemplate.md'
             - ImageAttachment: 'models/extras/imageattachment.md'
             - ImageAttachment: 'models/extras/imageattachment.md'
             - JournalEntry: 'models/extras/journalentry.md'
             - JournalEntry: 'models/extras/journalentry.md'
+            - Notification: 'models/extras/notification.md'
+            - NotificationGroup: 'models/extras/notificationgroup.md'
             - SavedFilter: 'models/extras/savedfilter.md'
             - SavedFilter: 'models/extras/savedfilter.md'
             - StagedChange: 'models/extras/stagedchange.md'
             - StagedChange: 'models/extras/stagedchange.md'
+            - Subscription: 'models/extras/subscription.md'
             - Tag: 'models/extras/tag.md'
             - Tag: 'models/extras/tag.md'
             - Webhook: 'models/extras/webhook.md'
             - Webhook: 'models/extras/webhook.md'
         - IPAM:
         - IPAM:

+ 2 - 0
netbox/account/urls.py

@@ -9,6 +9,8 @@ urlpatterns = [
     # Account views
     # Account views
     path('profile/', views.ProfileView.as_view(), name='profile'),
     path('profile/', views.ProfileView.as_view(), name='profile'),
     path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
     path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
+    path('notifications/', views.NotificationListView.as_view(), name='notifications'),
+    path('subscriptions/', views.SubscriptionListView.as_view(), name='subscriptions'),
     path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
     path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),

+ 31 - 1
netbox/account/views.py

@@ -22,7 +22,7 @@ from account.models import UserToken
 from core.models import ObjectChange
 from core.models import ObjectChange
 from core.tables import ObjectChangeTable
 from core.tables import ObjectChangeTable
 from extras.models import Bookmark
 from extras.models import Bookmark
-from extras.tables import BookmarkTable
+from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.views import generic
 from netbox.views import generic
@@ -267,6 +267,36 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
         }
         }
 
 
 
 
+#
+# Notifications & subscriptions
+#
+
+class NotificationListView(LoginRequiredMixin, generic.ObjectListView):
+    table = NotificationTable
+    template_name = 'account/notifications.html'
+
+    def get_queryset(self, request):
+        return request.user.notifications.all()
+
+    def get_extra_context(self, request):
+        return {
+            'active_tab': 'notifications',
+        }
+
+
+class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView):
+    table = SubscriptionTable
+    template_name = 'account/subscriptions.html'
+
+    def get_queryset(self, request):
+        return request.user.subscriptions.all()
+
+    def get_extra_context(self, request):
+        return {
+            'active_tab': 'subscriptions',
+        }
+
+
 #
 #
 # User views for token management
 # User views for token management
 #
 #

+ 1 - 1
netbox/core/apps.py

@@ -18,7 +18,7 @@ class CoreConfig(AppConfig):
     def ready(self):
     def ready(self):
         from core.api import schema  # noqa
         from core.api import schema  # noqa
         from netbox.models.features import register_models
         from netbox.models.features import register_models
-        from . import data_backends, search
+        from . import data_backends, events, search
 
 
         # Register models
         # Register models
         register_models(*self.get_models())
         register_models(*self.get_models())

+ 33 - 0
netbox/core/events.py

@@ -0,0 +1,33 @@
+from django.utils.translation import gettext as _
+
+from netbox.events import *
+
+__all__ = (
+    'JOB_COMPLETED',
+    'JOB_ERRORED',
+    'JOB_FAILED',
+    'JOB_STARTED',
+    'OBJECT_CREATED',
+    'OBJECT_DELETED',
+    'OBJECT_UPDATED',
+)
+
+# Object events
+OBJECT_CREATED = 'object_created'
+OBJECT_UPDATED = 'object_updated'
+OBJECT_DELETED = 'object_deleted'
+
+# Job events
+JOB_STARTED = 'job_started'
+JOB_COMPLETED = 'job_completed'
+JOB_FAILED = 'job_failed'
+JOB_ERRORED = 'job_errored'
+
+# Register core events
+Event(name=OBJECT_CREATED, text=_('Object created')).register()
+Event(name=OBJECT_UPDATED, text=_('Object updated')).register()
+Event(name=OBJECT_DELETED, text=_('Object deleted')).register()
+Event(name=JOB_STARTED, text=_('Job started')).register()
+Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register()
+Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register()
+Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register()

+ 0 - 1
netbox/core/models/jobs.py

@@ -13,7 +13,6 @@ from django.utils.translation import gettext as _
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
 from core.models import ObjectType
 from core.models import ObjectType
 from core.signals import job_end, job_start
 from core.signals import job_end, job_start
-from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet

+ 1 - 0
netbox/extras/api/serializers.py

@@ -7,6 +7,7 @@ from .serializers_.dashboard import *
 from .serializers_.events import *
 from .serializers_.events import *
 from .serializers_.exporttemplates import *
 from .serializers_.exporttemplates import *
 from .serializers_.journaling import *
 from .serializers_.journaling import *
+from .serializers_.notifications import *
 from .serializers_.configcontexts import *
 from .serializers_.configcontexts import *
 from .serializers_.configtemplates import *
 from .serializers_.configtemplates import *
 from .serializers_.savedfilters import *
 from .serializers_.savedfilters import *

+ 82 - 0
netbox/extras/api/serializers_/notifications.py

@@ -0,0 +1,82 @@
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from core.models import ObjectType
+from extras.models import Notification, NotificationGroup, Subscription
+from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
+from netbox.api.serializers import ValidatedModelSerializer
+from users.api.serializers_.users import GroupSerializer, UserSerializer
+from users.models import Group, User
+from utilities.api import get_serializer_for_model
+
+__all__ = (
+    'NotificationSerializer',
+    'NotificationGroupSerializer',
+    'SubscriptionSerializer',
+)
+
+
+class NotificationSerializer(ValidatedModelSerializer):
+    object_type = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('notifications'),
+    )
+    object = serializers.SerializerMethodField(read_only=True)
+    user = UserSerializer(nested=True)
+
+    class Meta:
+        model = Notification
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'read', 'event_type',
+        ]
+        brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user', 'read', 'event_type')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_object(self, instance):
+        serializer = get_serializer_for_model(instance.object)
+        context = {'request': self.context['request']}
+        return serializer(instance.object, nested=True, context=context).data
+
+
+class NotificationGroupSerializer(ValidatedModelSerializer):
+    groups = SerializedPKRelatedField(
+        queryset=Group.objects.all(),
+        serializer=GroupSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+    users = SerializedPKRelatedField(
+        queryset=User.objects.all(),
+        serializer=UserSerializer,
+        nested=True,
+        required=False,
+        many=True
+    )
+
+    class Meta:
+        model = NotificationGroup
+        fields = [
+            'id', 'url', 'display', 'display_url', 'name', 'description', 'groups', 'users',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class SubscriptionSerializer(ValidatedModelSerializer):
+    object_type = ContentTypeField(
+        queryset=ObjectType.objects.with_feature('notifications'),
+    )
+    object = serializers.SerializerMethodField(read_only=True)
+    user = UserSerializer(nested=True)
+
+    class Meta:
+        model = Subscription
+        fields = [
+            'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
+        ]
+        brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_object(self, instance):
+        serializer = get_serializer_for_model(instance.object)
+        context = {'request': self.context['request']}
+        return serializer(instance.object, nested=True, context=context).data

+ 3 - 0
netbox/extras/api/urls.py

@@ -15,6 +15,9 @@ router.register('custom-links', views.CustomLinkViewSet)
 router.register('export-templates', views.ExportTemplateViewSet)
 router.register('export-templates', views.ExportTemplateViewSet)
 router.register('saved-filters', views.SavedFilterViewSet)
 router.register('saved-filters', views.SavedFilterViewSet)
 router.register('bookmarks', views.BookmarkViewSet)
 router.register('bookmarks', views.BookmarkViewSet)
+router.register('notifications', views.NotificationViewSet)
+router.register('notification-groups', views.NotificationGroupViewSet)
+router.register('subscriptions', views.SubscriptionViewSet)
 router.register('tags', views.TagViewSet)
 router.register('tags', views.TagViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)

+ 21 - 0
netbox/extras/api/views.py

@@ -140,6 +140,27 @@ class BookmarkViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.BookmarkFilterSet
     filterset_class = filtersets.BookmarkFilterSet
 
 
 
 
+#
+# Notifications & subscriptions
+#
+
+class NotificationViewSet(NetBoxModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = Notification.objects.all()
+    serializer_class = serializers.NotificationSerializer
+
+
+class NotificationGroupViewSet(NetBoxModelViewSet):
+    queryset = NotificationGroup.objects.all()
+    serializer_class = serializers.NotificationGroupSerializer
+
+
+class SubscriptionViewSet(NetBoxModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = Subscription.objects.all()
+    serializer_class = serializers.SubscriptionSerializer
+
+
 #
 #
 # Tags
 # Tags
 #
 #

+ 2 - 0
netbox/extras/choices.py

@@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet):
 
 
     WEBHOOK = 'webhook'
     WEBHOOK = 'webhook'
     SCRIPT = 'script'
     SCRIPT = 'script'
+    NOTIFICATION = 'notification'
 
 
     CHOICES = (
     CHOICES = (
         (WEBHOOK, _('Webhook')),
         (WEBHOOK, _('Webhook')),
         (SCRIPT, _('Script')),
         (SCRIPT, _('Script')),
+        (NOTIFICATION, _('Notification')),
     )
     )

+ 9 - 12
netbox/extras/constants.py

@@ -1,12 +1,6 @@
+from core.events import *
 from extras.choices import LogLevelChoices
 from extras.choices import LogLevelChoices
 
 
-# Events
-EVENT_CREATE = 'create'
-EVENT_UPDATE = 'update'
-EVENT_DELETE = 'delete'
-EVENT_JOB_START = 'job_start'
-EVENT_JOB_END = 'job_end'
-
 # Custom fields
 # Custom fields
 CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 
 
@@ -14,11 +8,14 @@ CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
 
 WEBHOOK_EVENT_TYPES = {
 WEBHOOK_EVENT_TYPES = {
-    EVENT_CREATE: 'created',
-    EVENT_UPDATE: 'updated',
-    EVENT_DELETE: 'deleted',
-    EVENT_JOB_START: 'job_started',
-    EVENT_JOB_END: 'job_ended',
+    # Map registered event types to public webhook "event" equivalents
+    OBJECT_CREATED: 'created',
+    OBJECT_UPDATED: 'updated',
+    OBJECT_DELETED: 'deleted',
+    JOB_STARTED: 'job_started',
+    JOB_COMPLETED: 'job_ended',
+    JOB_FAILED: 'job_ended',
+    JOB_ERRORED: 'job_ended',
 }
 }
 
 
 # Dashboard
 # Dashboard

+ 48 - 30
netbox/extras/events.py

@@ -8,7 +8,7 @@ from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from django_rq import get_queue
 from django_rq import get_queue
 
 
-from core.choices import ObjectChangeActionChoices
+from core.events import *
 from core.models import Job
 from core.models import Job
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
 from netbox.constants import RQ_QUEUE_DEFAULT
@@ -35,12 +35,12 @@ def serialize_for_event(instance):
     return serializer.data
     return serializer.data
 
 
 
 
-def get_snapshots(instance, action):
+def get_snapshots(instance, event_type):
     snapshots = {
     snapshots = {
         'prechange': getattr(instance, '_prechange_snapshot', None),
         'prechange': getattr(instance, '_prechange_snapshot', None),
         'postchange': None,
         'postchange': None,
     }
     }
-    if action != ObjectChangeActionChoices.ACTION_DELETE:
+    if event_type != OBJECT_DELETED:
         # Use model's serialize_object() method if defined; fall back to serialize_object() utility function
         # Use model's serialize_object() method if defined; fall back to serialize_object() utility function
         if hasattr(instance, 'serialize_object'):
         if hasattr(instance, 'serialize_object'):
             snapshots['postchange'] = instance.serialize_object()
             snapshots['postchange'] = instance.serialize_object()
@@ -50,7 +50,7 @@ def get_snapshots(instance, action):
     return snapshots
     return snapshots
 
 
 
 
-def enqueue_object(queue, instance, user, request_id, action):
+def enqueue_event(queue, instance, user, request_id, event_type):
     """
     """
     Enqueue a serialized representation of a created/updated/deleted object for the processing of
     Enqueue a serialized representation of a created/updated/deleted object for the processing of
     events once the request has completed.
     events once the request has completed.
@@ -65,27 +65,24 @@ def enqueue_object(queue, instance, user, request_id, action):
     key = f'{app_label}.{model_name}:{instance.pk}'
     key = f'{app_label}.{model_name}:{instance.pk}'
     if key in queue:
     if key in queue:
         queue[key]['data'] = serialize_for_event(instance)
         queue[key]['data'] = serialize_for_event(instance)
-        queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
+        queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
         # If the object is being deleted, update any prior "update" event to "delete"
         # If the object is being deleted, update any prior "update" event to "delete"
-        if action == ObjectChangeActionChoices.ACTION_DELETE:
-            queue[key]['event'] = action
+        if event_type == OBJECT_DELETED:
+            queue[key]['event_type'] = event_type
     else:
     else:
         queue[key] = {
         queue[key] = {
-            'content_type': ContentType.objects.get_for_model(instance),
+            'object_type': ContentType.objects.get_for_model(instance),
             'object_id': instance.pk,
             'object_id': instance.pk,
-            'event': action,
+            'event_type': event_type,
             'data': serialize_for_event(instance),
             'data': serialize_for_event(instance),
-            'snapshots': get_snapshots(instance, action),
+            'snapshots': get_snapshots(instance, event_type),
             'username': user.username,
             'username': user.username,
             'request_id': request_id
             'request_id': request_id
         }
         }
 
 
 
 
-def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
-    if username:
-        user = get_user_model().objects.get(username=username)
-    else:
-        user = None
+def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None):
+    user = get_user_model().objects.get(username=username) if username else None
 
 
     for event_rule in event_rules:
     for event_rule in event_rules:
 
 
@@ -103,8 +100,8 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
             # Compile the task parameters
             # Compile the task parameters
             params = {
             params = {
                 "event_rule": event_rule,
                 "event_rule": event_rule,
-                "model_name": model_name,
-                "event": event,
+                "model_name": object_type.model,
+                "event_type": event_type,
                 "data": data,
                 "data": data,
                 "snapshots": snapshots,
                 "snapshots": snapshots,
                 "timestamp": timezone.now().isoformat(),
                 "timestamp": timezone.now().isoformat(),
@@ -136,6 +133,15 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
                 data=data
                 data=data
             )
             )
 
 
+        # Notification groups
+        elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
+            # Bulk-create notifications for all members of the notification group
+            event_rule.action_object.notify(
+                object_type=object_type,
+                object_id=data['id'],
+                event_type=event_type
+            )
+
         else:
         else:
             raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
             raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
                 action_type=event_rule.action_type
                 action_type=event_rule.action_type
@@ -151,27 +157,39 @@ def process_event_queue(events):
         'type_update': {},
         'type_update': {},
         'type_delete': {},
         'type_delete': {},
     }
     }
+    event_actions = {
+        # TODO: Add EventRule support for dynamically registered event types
+        OBJECT_CREATED: 'type_create',
+        OBJECT_UPDATED: 'type_update',
+        OBJECT_DELETED: 'type_delete',
+        JOB_STARTED: 'type_job_start',
+        JOB_COMPLETED: 'type_job_end',
+        # Map failed & errored jobs to type_job_end
+        JOB_FAILED: 'type_job_end',
+        JOB_ERRORED: 'type_job_end',
+    }
 
 
-    for data in events:
-        action_flag = {
-            ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
-            ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
-            ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
-        }[data['event']]
-        content_type = data['content_type']
+    for event in events:
+        action_flag = event_actions[event['event_type']]
+        object_type = event['object_type']
 
 
         # Cache applicable Event Rules
         # Cache applicable Event Rules
-        if content_type not in events_cache[action_flag]:
-            events_cache[action_flag][content_type] = EventRule.objects.filter(
+        if object_type not in events_cache[action_flag]:
+            events_cache[action_flag][object_type] = EventRule.objects.filter(
                 **{action_flag: True},
                 **{action_flag: True},
-                object_types=content_type,
+                object_types=object_type,
                 enabled=True
                 enabled=True
             )
             )
-        event_rules = events_cache[action_flag][content_type]
+        event_rules = events_cache[action_flag][object_type]
 
 
         process_event_rules(
         process_event_rules(
-            event_rules, content_type.model, data['event'], data['data'], data['username'],
-            snapshots=data['snapshots'], request_id=data['request_id']
+            event_rules=event_rules,
+            object_type=object_type,
+            event_type=event['event_type'],
+            data=event['data'],
+            username=event['username'],
+            snapshots=event['snapshots'],
+            request_id=event['request_id']
         )
         )
 
 
 
 

+ 45 - 0
netbox/extras/filtersets.py

@@ -8,6 +8,7 @@ from core.models import DataSource, ObjectType
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from users.models import Group, User
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .choices import *
 from .choices import *
@@ -26,6 +27,7 @@ __all__ = (
     'ImageAttachmentFilterSet',
     'ImageAttachmentFilterSet',
     'JournalEntryFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
+    'NotificationGroupFilterSet',
     'ObjectTypeFilterSet',
     'ObjectTypeFilterSet',
     'SavedFilterFilterSet',
     'SavedFilterFilterSet',
     'ScriptFilterSet',
     'ScriptFilterSet',
@@ -336,6 +338,49 @@ class BookmarkFilterSet(BaseFilterSet):
         fields = ('id', 'object_id')
         fields = ('id', 'object_id')
 
 
 
 
+class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='users',
+        queryset=User.objects.all(),
+        label=_('User (ID)'),
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='users__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label=_('User (name)'),
+    )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='groups',
+        queryset=Group.objects.all(),
+        label=_('Group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='groups__name',
+        queryset=Group.objects.all(),
+        to_field_name='name',
+        label=_('Group (name)'),
+    )
+
+    class Meta:
+        model = NotificationGroup
+        fields = (
+            'id', 'name', 'description',
+        )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+
 class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
 class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 15 - 0
netbox/extras/forms/bulk_edit.py

@@ -18,6 +18,7 @@ __all__ = (
     'EventRuleBulkEditForm',
     'EventRuleBulkEditForm',
     'ExportTemplateBulkEditForm',
     'ExportTemplateBulkEditForm',
     'JournalEntryBulkEditForm',
     'JournalEntryBulkEditForm',
+    'NotificationGroupBulkEditForm',
     'SavedFilterBulkEditForm',
     'SavedFilterBulkEditForm',
     'TagBulkEditForm',
     'TagBulkEditForm',
     'WebhookBulkEditForm',
     'WebhookBulkEditForm',
@@ -343,3 +344,17 @@ class JournalEntryBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
     comments = CommentField()
     comments = CommentField()
+
+
+class NotificationGroupBulkEditForm(BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=NotificationGroup.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    nullable_fields = ('description',)

+ 25 - 2
netbox/extras/forms/bulk_import.py

@@ -3,16 +3,17 @@ import re
 from django import forms
 from django import forms
 from django.contrib.postgres.forms import SimpleArrayField
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
-from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
+from users.models import Group, User
 from utilities.forms import CSVModelForm
 from utilities.forms import CSVModelForm
 from utilities.forms.fields import (
 from utilities.forms.fields import (
-    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
+    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField,
+    SlugField,
 )
 )
 
 
 __all__ = (
 __all__ = (
@@ -23,6 +24,7 @@ __all__ = (
     'EventRuleImportForm',
     'EventRuleImportForm',
     'ExportTemplateImportForm',
     'ExportTemplateImportForm',
     'JournalEntryImportForm',
     'JournalEntryImportForm',
+    'NotificationGroupImportForm',
     'SavedFilterImportForm',
     'SavedFilterImportForm',
     'TagImportForm',
     'TagImportForm',
     'WebhookImportForm',
     'WebhookImportForm',
@@ -247,3 +249,24 @@ class JournalEntryImportForm(NetBoxModelImportForm):
         fields = (
         fields = (
             'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
             'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
         )
         )
+
+
+class NotificationGroupImportForm(CSVModelForm):
+    users = CSVModelMultipleChoiceField(
+        label=_('Users'),
+        queryset=User.objects.all(),
+        required=False,
+        to_field_name='username',
+        help_text=_('User names separated by commas, encased with double quotes')
+    )
+    groups = CSVModelMultipleChoiceField(
+        label=_('Groups'),
+        queryset=Group.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Group names separated by commas, encased with double quotes')
+    )
+
+    class Meta:
+        model = NotificationGroup
+        fields = ('name', 'description', 'users', 'groups')

+ 15 - 0
netbox/extras/forms/filtersets.py

@@ -9,6 +9,7 @@ from extras.models import *
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.forms.mixins import SavedFiltersMixin
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from users.models import Group
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
@@ -28,6 +29,7 @@ __all__ = (
     'ImageAttachmentFilterForm',
     'ImageAttachmentFilterForm',
     'JournalEntryFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'LocalConfigContextFilterForm',
+    'NotificationGroupFilterForm',
     'SavedFilterFilterForm',
     'SavedFilterFilterForm',
     'TagFilterForm',
     'TagFilterForm',
     'WebhookFilterForm',
     'WebhookFilterForm',
@@ -496,3 +498,16 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         required=False
         required=False
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
+
+
+class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
+    user_id = DynamicModelMultipleChoiceField(
+        queryset=get_user_model().objects.all(),
+        required=False,
+        label=_('User')
+    )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=Group.objects.all(),
+        required=False,
+        label=_('Group')
+    )

+ 58 - 0
netbox/extras/forms/model_forms.py

@@ -12,6 +12,7 @@ from extras.choices import *
 from extras.models import *
 from extras.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from users.models import Group, User
 from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms import add_blank_choice, get_field_value
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
@@ -32,7 +33,9 @@ __all__ = (
     'ExportTemplateForm',
     'ExportTemplateForm',
     'ImageAttachmentForm',
     'ImageAttachmentForm',
     'JournalEntryForm',
     'JournalEntryForm',
+    'NotificationGroupForm',
     'SavedFilterForm',
     'SavedFilterForm',
+    'SubscriptionForm',
     'TagForm',
     'TagForm',
     'WebhookForm',
     'WebhookForm',
 )
 )
@@ -238,6 +241,43 @@ class BookmarkForm(forms.ModelForm):
         fields = ('object_type', 'object_id')
         fields = ('object_type', 'object_id')
 
 
 
 
+class NotificationGroupForm(forms.ModelForm):
+    groups = DynamicModelMultipleChoiceField(
+        label=_('Groups'),
+        required=False,
+        queryset=Group.objects.all()
+    )
+    users = DynamicModelMultipleChoiceField(
+        label=_('Users'),
+        required=False,
+        queryset=User.objects.all()
+    )
+
+    class Meta:
+        model = NotificationGroup
+        fields = ('name', 'description', 'groups', 'users')
+
+    def clean(self):
+        super().clean()
+
+        # At least one User or Group must be assigned
+        if not self.cleaned_data['groups'] and not self.cleaned_data['users']:
+            raise forms.ValidationError(_("A notification group specify at least one user or group."))
+
+        return self.cleaned_data
+
+
+class SubscriptionForm(forms.ModelForm):
+    object_type = ContentTypeChoiceField(
+        label=_('Object type'),
+        queryset=ObjectType.objects.with_feature('notifications')
+    )
+
+    class Meta:
+        model = Subscription
+        fields = ('object_type', 'object_id')
+
+
 class WebhookForm(NetBoxModelForm):
 class WebhookForm(NetBoxModelForm):
 
 
     fieldsets = (
     fieldsets = (
@@ -329,6 +369,18 @@ class EventRuleForm(NetBoxModelForm):
             initial=initial
             initial=initial
         )
         )
 
 
+    def init_notificationgroup_choice(self):
+        initial = None
+        if self.instance.action_type == EventRuleActionChoices.NOTIFICATION:
+            notificationgroup_id = get_field_value(self, 'action_object_id')
+            initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None
+        self.fields['action_choice'] = DynamicModelChoiceField(
+            label=_('Notification group'),
+            queryset=NotificationGroup.objects.all(),
+            required=True,
+            initial=initial
+        )
+
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.fields['action_object_type'].required = False
         self.fields['action_object_type'].required = False
@@ -341,6 +393,8 @@ class EventRuleForm(NetBoxModelForm):
             self.init_webhook_choice()
             self.init_webhook_choice()
         elif action_type == EventRuleActionChoices.SCRIPT:
         elif action_type == EventRuleActionChoices.SCRIPT:
             self.init_script_choice()
             self.init_script_choice()
+        elif action_type == EventRuleActionChoices.NOTIFICATION:
+            self.init_notificationgroup_choice()
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
@@ -357,6 +411,10 @@ class EventRuleForm(NetBoxModelForm):
                 for_concrete_model=False
                 for_concrete_model=False
             )
             )
             self.cleaned_data['action_object_id'] = action_choice.id
             self.cleaned_data['action_object_id'] = action_choice.id
+        # Notification
+        elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION:
+            self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
+            self.cleaned_data['action_object_id'] = action_choice.id
 
 
         return self.cleaned_data
         return self.cleaned_data
 
 

+ 7 - 0
netbox/extras/graphql/filters.py

@@ -13,6 +13,7 @@ __all__ = (
     'ExportTemplateFilter',
     'ExportTemplateFilter',
     'ImageAttachmentFilter',
     'ImageAttachmentFilter',
     'JournalEntryFilter',
     'JournalEntryFilter',
+    'NotificationGroupFilter',
     'SavedFilterFilter',
     'SavedFilterFilter',
     'TagFilter',
     'TagFilter',
     'WebhookFilter',
     'WebhookFilter',
@@ -67,6 +68,12 @@ class JournalEntryFilter(BaseFilterMixin):
     pass
     pass
 
 
 
 
+@strawberry_django.filter(models.NotificationGroup, lookups=True)
+@autotype_decorator(filtersets.NotificationGroupFilterSet)
+class NotificationGroupFilter(BaseFilterMixin):
+    pass
+
+
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 @strawberry_django.filter(models.SavedFilter, lookups=True)
 @autotype_decorator(filtersets.SavedFilterFilterSet)
 @autotype_decorator(filtersets.SavedFilterFilterSet)
 class SavedFilterFilter(BaseFilterMixin):
 class SavedFilterFilter(BaseFilterMixin):

+ 15 - 0
netbox/extras/graphql/schema.py

@@ -54,6 +54,21 @@ class ExtrasQuery:
         return models.JournalEntry.objects.get(pk=id)
         return models.JournalEntry.objects.get(pk=id)
     journal_entry_list: List[JournalEntryType] = strawberry_django.field()
     journal_entry_list: List[JournalEntryType] = strawberry_django.field()
 
 
+    @strawberry.field
+    def notification(self, id: int) -> NotificationType:
+        return models.Notification.objects.get(pk=id)
+    notification_list: List[NotificationType] = strawberry_django.field()
+
+    @strawberry.field
+    def notification_group(self, id: int) -> NotificationGroupType:
+        return models.NotificationGroup.objects.get(pk=id)
+    notification_group_list: List[NotificationGroupType] = strawberry_django.field()
+
+    @strawberry.field
+    def subscription(self, id: int) -> SubscriptionType:
+        return models.Subscription.objects.get(pk=id)
+    subscription_list: List[SubscriptionType] = strawberry_django.field()
+
     @strawberry.field
     @strawberry.field
     def tag(self, id: int) -> TagType:
     def tag(self, id: int) -> TagType:
         return models.Tag.objects.get(pk=id)
         return models.Tag.objects.get(pk=id)

+ 28 - 0
netbox/extras/graphql/types.py

@@ -18,7 +18,10 @@ __all__ = (
     'ExportTemplateType',
     'ExportTemplateType',
     'ImageAttachmentType',
     'ImageAttachmentType',
     'JournalEntryType',
     'JournalEntryType',
+    'NotificationGroupType',
+    'NotificationType',
     'SavedFilterType',
     'SavedFilterType',
+    'SubscriptionType',
     'TagType',
     'TagType',
     'WebhookType',
     'WebhookType',
 )
 )
@@ -122,6 +125,23 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
     created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
     created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
 
 
+@strawberry_django.type(
+    models.Notification,
+    # filters=NotificationFilter
+)
+class NotificationType(ObjectType):
+    user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
+
+
+@strawberry_django.type(
+    models.NotificationGroup,
+    filters=NotificationGroupFilter
+)
+class NotificationGroupType(ObjectType):
+    users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]]
+    groups: List[Annotated["GroupType", strawberry.lazy('users.graphql.types')]]
+
+
 @strawberry_django.type(
 @strawberry_django.type(
     models.SavedFilter,
     models.SavedFilter,
     exclude=['content_types',],
     exclude=['content_types',],
@@ -131,6 +151,14 @@ class SavedFilterType(ObjectType):
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
 
 
+@strawberry_django.type(
+    models.Subscription,
+    # filters=NotificationFilter
+)
+class SubscriptionType(ObjectType):
+    user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
+
+
 @strawberry_django.type(
 @strawberry_django.type(
     models.Tag,
     models.Tag,
     exclude=['extras_taggeditem_items', ],
     exclude=['extras_taggeditem_items', ],

+ 78 - 0
netbox/extras/migrations/0118_notifications.py

@@ -0,0 +1,78 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0117_customfield_uniqueness'),
+        ('users', '0009_update_group_perms'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='NotificationGroup',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('groups', models.ManyToManyField(blank=True, related_name='notification_groups', to='users.group')),
+                ('users', models.ManyToManyField(blank=True, related_name='notification_groups', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'notification group',
+                'verbose_name_plural': 'notification groups',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.CreateModel(
+            name='Subscription',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('object_id', models.PositiveBigIntegerField()),
+                ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'subscription',
+                'verbose_name_plural': 'subscriptions',
+                'ordering': ('-created', 'user'),
+            },
+        ),
+        migrations.CreateModel(
+            name='Notification',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('read', models.DateTimeField(blank=True, null=True)),
+                ('object_id', models.PositiveBigIntegerField()),
+                ('event_type', models.CharField(max_length=50)),
+                ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'notification',
+                'verbose_name_plural': 'notifications',
+                'ordering': ('-created', 'pk'),
+                'indexes': [models.Index(fields=['object_type', 'object_id'], name='extras_noti_object__be74d5_idx')],
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='notification',
+            constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_notification_unique_per_object_and_user'),
+        ),
+        migrations.AddIndex(
+            model_name='subscription',
+            index=models.Index(fields=['object_type', 'object_id'], name='extras_subs_object__37ef68_idx'),
+        ),
+        migrations.AddConstraint(
+            model_name='subscription',
+            constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_subscription_unique_per_object_and_user'),
+        ),
+    ]

+ 1 - 0
netbox/extras/models/__init__.py

@@ -2,6 +2,7 @@ from .configs import *
 from .customfields import *
 from .customfields import *
 from .dashboard import *
 from .dashboard import *
 from .models import *
 from .models import *
+from .notifications import *
 from .scripts import *
 from .scripts import *
 from .search import *
 from .search import *
 from .staging import *
 from .staging import *

+ 222 - 0
netbox/extras/models/notifications.py

@@ -0,0 +1,222 @@
+from functools import cached_property
+
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from core.models import ObjectType
+from extras.querysets import NotificationQuerySet
+from netbox.models import ChangeLoggedModel
+from netbox.registry import registry
+from users.models import User
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+    'Notification',
+    'NotificationGroup',
+    'Subscription',
+)
+
+
+def get_event_type_choices():
+    """
+    Compile a list of choices from all registered event types
+    """
+    return [
+        (name, event.text)
+        for name, event in registry['events'].items()
+    ]
+
+
+class Notification(models.Model):
+    """
+    A notification message for a User relating to a specific object in NetBox.
+    """
+    created = models.DateTimeField(
+        verbose_name=_('created'),
+        auto_now_add=True
+    )
+    read = models.DateTimeField(
+        verbose_name=_('read'),
+        null=True,
+        blank=True
+    )
+    user = models.ForeignKey(
+        to=settings.AUTH_USER_MODEL,
+        on_delete=models.CASCADE,
+        related_name='notifications'
+    )
+    object_type = models.ForeignKey(
+        to='contenttypes.ContentType',
+        on_delete=models.PROTECT
+    )
+    object_id = models.PositiveBigIntegerField()
+    object = GenericForeignKey(
+        ct_field='object_type',
+        fk_field='object_id'
+    )
+    event_type = models.CharField(
+        verbose_name=_('event'),
+        max_length=50,
+        choices=get_event_type_choices
+    )
+
+    objects = NotificationQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('-created', 'pk')
+        indexes = (
+            models.Index(fields=('object_type', 'object_id')),
+        )
+        constraints = (
+            models.UniqueConstraint(
+                fields=('object_type', 'object_id', 'user'),
+                name='%(app_label)s_%(class)s_unique_per_object_and_user'
+            ),
+        )
+        verbose_name = _('notification')
+        verbose_name_plural = _('notifications')
+
+    def __str__(self):
+        if self.object:
+            return str(self.object)
+        return super().__str__()
+
+    def get_absolute_url(self):
+        return reverse('account:notifications')
+
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.object_type not in ObjectType.objects.with_feature('notifications'):
+            raise ValidationError(
+                _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type)
+            )
+
+    @cached_property
+    def event(self):
+        """
+        Returns the registered Event which triggered this Notification.
+        """
+        return registry['events'].get(self.event_type)
+
+
+class NotificationGroup(ChangeLoggedModel):
+    """
+    A collection of users and/or groups to be informed for certain notifications.
+    """
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    groups = models.ManyToManyField(
+        to='users.Group',
+        verbose_name=_('groups'),
+        blank=True,
+        related_name='notification_groups'
+    )
+    users = models.ManyToManyField(
+        to='users.User',
+        verbose_name=_('users'),
+        blank=True,
+        related_name='notification_groups'
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('notification group')
+        verbose_name_plural = _('notification groups')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('extras:notificationgroup', args=[self.pk])
+
+    @cached_property
+    def members(self):
+        """
+        Return all Users who belong to this notification group.
+        """
+        return self.users.union(
+            User.objects.filter(groups__in=self.groups.all())
+        ).order_by('username')
+
+    def notify(self, **kwargs):
+        """
+        Bulk-create Notifications for all members of this group.
+        """
+        Notification.objects.bulk_create([
+            Notification(user=member, **kwargs)
+            for member in self.members
+        ])
+    notify.alters_data = True
+
+
+class Subscription(models.Model):
+    """
+    A User's subscription to a particular object, to be notified of changes.
+    """
+    created = models.DateTimeField(
+        verbose_name=_('created'),
+        auto_now_add=True
+    )
+    user = models.ForeignKey(
+        to=settings.AUTH_USER_MODEL,
+        on_delete=models.CASCADE,
+        related_name='subscriptions'
+    )
+    object_type = models.ForeignKey(
+        to='contenttypes.ContentType',
+        on_delete=models.PROTECT
+    )
+    object_id = models.PositiveBigIntegerField()
+    object = GenericForeignKey(
+        ct_field='object_type',
+        fk_field='object_id'
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('-created', 'user')
+        indexes = (
+            models.Index(fields=('object_type', 'object_id')),
+        )
+        constraints = (
+            models.UniqueConstraint(
+                fields=('object_type', 'object_id', 'user'),
+                name='%(app_label)s_%(class)s_unique_per_object_and_user'
+            ),
+        )
+        verbose_name = _('subscription')
+        verbose_name_plural = _('subscriptions')
+
+    def __str__(self):
+        if self.object:
+            return str(self.object)
+        return super().__str__()
+
+    def get_absolute_url(self):
+        return reverse('account:subscriptions')
+
+    def clean(self):
+        super().clean()
+
+        # Validate the assigned object type
+        if self.object_type not in ObjectType.objects.with_feature('notifications'):
+            raise ValidationError(
+                _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type)
+            )

+ 15 - 0
netbox/extras/querysets.py

@@ -5,6 +5,12 @@ from extras.models.tags import TaggedItem
 from utilities.query_functions import EmptyGroupByJSONBAgg
 from utilities.query_functions import EmptyGroupByJSONBAgg
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
+__all__ = (
+    'ConfigContextModelQuerySet',
+    'ConfigContextQuerySet',
+    'NotificationQuerySet',
+)
+
 
 
 class ConfigContextQuerySet(RestrictedQuerySet):
 class ConfigContextQuerySet(RestrictedQuerySet):
 
 
@@ -145,3 +151,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
         )
 
 
         return base_query
         return base_query
+
+
+class NotificationQuerySet(RestrictedQuerySet):
+
+    def unread(self):
+        """
+        Return only unread notifications.
+        """
+        return self.filter(read__isnull=True)

+ 59 - 12
netbox/extras/signals.py

@@ -10,17 +10,18 @@ from django.utils.translation import gettext_lazy as _
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
+from core.events import *
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from core.signals import job_end, job_start
 from core.signals import job_end, job_start
-from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 from extras.events import process_event_rules
-from extras.models import EventRule
+from extras.models import EventRule, Notification, Subscription
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
+from netbox.registry import registry
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
-from .events import enqueue_object, get_snapshots, serialize_for_event
+from .events import enqueue_event
 from .models import CustomField, TaggedItem
 from .models import CustomField, TaggedItem
 from .validators import CustomValidator
 from .validators import CustomValidator
 
 
@@ -72,17 +73,22 @@ def handle_changed_object(sender, instance, **kwargs):
 
 
     # Determine the type of change being made
     # Determine the type of change being made
     if kwargs.get('created'):
     if kwargs.get('created'):
-        action = ObjectChangeActionChoices.ACTION_CREATE
+        event_type = OBJECT_CREATED
     elif 'created' in kwargs:
     elif 'created' in kwargs:
-        action = ObjectChangeActionChoices.ACTION_UPDATE
+        event_type = OBJECT_UPDATED
     elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
     elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
         # m2m_changed with objects added or removed
         # m2m_changed with objects added or removed
         m2m_changed = True
         m2m_changed = True
-        action = ObjectChangeActionChoices.ACTION_UPDATE
+        event_type = OBJECT_UPDATED
     else:
     else:
         return
         return
 
 
     # Create/update an ObjectChange record for this change
     # Create/update an ObjectChange record for this change
+    action = {
+        OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE,
+        OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE,
+        OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE,
+    }[event_type]
     objectchange = instance.to_objectchange(action)
     objectchange = instance.to_objectchange(action)
     # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
     # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
     # for this object by this request and update it
     # for this object by this request and update it
@@ -106,13 +112,13 @@ def handle_changed_object(sender, instance, **kwargs):
 
 
     # Enqueue the object for event processing
     # Enqueue the object for event processing
     queue = events_queue.get()
     queue = events_queue.get()
-    enqueue_object(queue, instance, request.user, request.id, action)
+    enqueue_event(queue, instance, request.user, request.id, event_type)
     events_queue.set(queue)
     events_queue.set(queue)
 
 
     # Increment metric counters
     # Increment metric counters
-    if action == ObjectChangeActionChoices.ACTION_CREATE:
+    if event_type == OBJECT_CREATED:
         model_inserts.labels(instance._meta.model_name).inc()
         model_inserts.labels(instance._meta.model_name).inc()
-    elif action == ObjectChangeActionChoices.ACTION_UPDATE:
+    elif event_type == OBJECT_UPDATED:
         model_updates.labels(instance._meta.model_name).inc()
         model_updates.labels(instance._meta.model_name).inc()
 
 
 
 
@@ -168,7 +174,7 @@ def handle_deleted_object(sender, instance, **kwargs):
 
 
     # Enqueue the object for event processing
     # Enqueue the object for event processing
     queue = events_queue.get()
     queue = events_queue.get()
-    enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
+    enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
     events_queue.set(queue)
     events_queue.set(queue)
 
 
     # Increment metric counters
     # Increment metric counters
@@ -270,7 +276,13 @@ def process_job_start_event_rules(sender, **kwargs):
     """
     """
     event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type)
     event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type)
     username = sender.user.username if sender.user else None
     username = sender.user.username if sender.user else None
-    process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
+    process_event_rules(
+        event_rules=event_rules,
+        object_type=sender.object_type,
+        event_type=JOB_STARTED,
+        data=sender.data,
+        username=username
+    )
 
 
 
 
 @receiver(job_end)
 @receiver(job_end)
@@ -280,4 +292,39 @@ def process_job_end_event_rules(sender, **kwargs):
     """
     """
     event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
     event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
     username = sender.user.username if sender.user else None
     username = sender.user.username if sender.user else None
-    process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)
+    process_event_rules(
+        event_rules=event_rules,
+        object_type=sender.object_type,
+        event_type=JOB_COMPLETED,
+        data=sender.data,
+        username=username
+    )
+
+
+#
+# Notifications
+#
+
+@receiver(post_save)
+def notify_object_changed(sender, instance, created, raw, **kwargs):
+    if created or raw:
+        return
+
+    # Skip unsupported object types
+    ct = ContentType.objects.get_for_model(instance)
+    if ct.model not in registry['model_features']['notifications'].get(ct.app_label, []):
+        return
+
+    # Find all subscribed Users
+    subscribed_users = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values_list('user', flat=True)
+    if not subscribed_users:
+        return
+
+    # Delete any existing Notifications for the object
+    Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete()
+
+    # Create Notifications for Subscribers
+    Notification.objects.bulk_create([
+        Notification(user_id=user, object=instance, event_type=OBJECT_UPDATED)
+        for user in subscribed_users
+    ])

+ 13 - 0
netbox/extras/tables/columns.py

@@ -0,0 +1,13 @@
+from django.utils.translation import gettext as _
+
+from netbox.tables.columns import ActionsColumn, ActionsItem
+
+__all__ = (
+    'NotificationActionsColumn',
+)
+
+
+class NotificationActionsColumn(ActionsColumn):
+    actions = {
+        'dismiss': ActionsItem(_('Dismiss'), 'trash-can-outline', 'delete', 'danger'),
+    }

+ 97 - 2
netbox/extras/tables/tables.py

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
 from extras.models import *
 from extras.models import *
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.tables import BaseTable, NetBoxTable, columns
 from netbox.tables import BaseTable, NetBoxTable, columns
+from .columns import NotificationActionsColumn
 
 
 __all__ = (
 __all__ = (
     'BookmarkTable',
     'BookmarkTable',
@@ -19,21 +20,28 @@ __all__ = (
     'ExportTemplateTable',
     'ExportTemplateTable',
     'ImageAttachmentTable',
     'ImageAttachmentTable',
     'JournalEntryTable',
     'JournalEntryTable',
+    'NotificationGroupTable',
+    'NotificationTable',
     'SavedFilterTable',
     'SavedFilterTable',
     'ReportResultsTable',
     'ReportResultsTable',
     'ScriptResultsTable',
     'ScriptResultsTable',
+    'SubscriptionTable',
     'TaggedItemTable',
     'TaggedItemTable',
     'TagTable',
     'TagTable',
     'WebhookTable',
     'WebhookTable',
 )
 )
 
 
-IMAGEATTACHMENT_IMAGE = '''
+IMAGEATTACHMENT_IMAGE = """
 {% if record.image %}
 {% if record.image %}
   <a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
   <a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
 {% else %}
 {% else %}
   &mdash;
   &mdash;
 {% endif %}
 {% endif %}
-'''
+"""
+
+NOTIFICATION_ICON = """
+<span class="text-{{ value.color }} fs-3"><i class="{{ value.icon }}"></i></span>
+"""
 
 
 
 
 class CustomFieldTable(NetBoxTable):
 class CustomFieldTable(NetBoxTable):
@@ -263,6 +271,93 @@ class BookmarkTable(NetBoxTable):
         default_columns = ('object', 'object_type', 'created')
         default_columns = ('object', 'object_type', 'created')
 
 
 
 
+class SubscriptionTable(NetBoxTable):
+    object_type = columns.ContentTypeColumn(
+        verbose_name=_('Object Type'),
+    )
+    object = tables.Column(
+        verbose_name=_('Object'),
+        linkify=True,
+        orderable=False
+    )
+    user = tables.Column(
+        verbose_name=_('User'),
+        linkify=True
+    )
+    actions = columns.ActionsColumn(
+        actions=('delete',)
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = Subscription
+        fields = ('pk', 'object', 'object_type', 'created', 'user')
+        default_columns = ('object', 'object_type', 'created')
+
+
+class NotificationTable(NetBoxTable):
+    icon = columns.TemplateColumn(
+        template_code=NOTIFICATION_ICON,
+        accessor=tables.A('event'),
+        attrs={
+            'td': {'class': 'w-1'},
+            'th': {'class': 'w-1'},
+        },
+        verbose_name=''
+    )
+    object_type = columns.ContentTypeColumn(
+        verbose_name=_('Object Type'),
+    )
+    object = tables.Column(
+        verbose_name=_('Object'),
+        linkify={
+            'viewname': 'extras:notification_read',
+            'args': [tables.A('pk')],
+        },
+        orderable=False
+    )
+    created = columns.DateTimeColumn(
+        timespec='minutes',
+        verbose_name=_('Created'),
+    )
+    read = columns.DateTimeColumn(
+        timespec='minutes',
+        verbose_name=_('Read'),
+    )
+    user = tables.Column(
+        verbose_name=_('User'),
+        linkify=True
+    )
+    actions = NotificationActionsColumn(
+        actions=('dismiss',)
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = Notification
+        fields = ('pk', 'icon', 'object', 'object_type', 'event_type', 'created', 'read', 'user')
+        default_columns = ('icon', 'object', 'object_type', 'event_type', 'created')
+        row_attrs = {
+            'data-read': lambda record: bool(record.read),
+        }
+
+
+class NotificationGroupTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True,
+        verbose_name=_('Name')
+    )
+    users = columns.ManyToManyColumn(
+        linkify_item=True
+    )
+    groups = columns.ManyToManyColumn(
+        linkify_item=True
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = NotificationGroup
+        fields = ('pk', 'name', 'description', 'groups', 'users')
+        default_columns = ('name', 'description', 'groups', 'users')
+
+
 class WebhookTable(NetBoxTable):
 class WebhookTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         verbose_name=_('Name'),
         verbose_name=_('Name'),

+ 195 - 2
netbox/extras/tests/test_api.py

@@ -7,15 +7,15 @@ from django.utils.timezone import make_aware
 from rest_framework import status
 from rest_framework import status
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
+from core.events import *
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
+from users.models import Group, User
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
-User = get_user_model()
-
 
 
 class AppTest(APITestCase):
 class AppTest(APITestCase):
 
 
@@ -890,3 +890,196 @@ class ObjectTypeTest(APITestCase):
 
 
         url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
         url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
         self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
         self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
+
+
+class SubscriptionTest(APIViewTestCases.APIViewTestCase):
+    model = Subscription
+    brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+            User(username='User 4'),
+        )
+        User.objects.bulk_create(users)
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        subscriptions = (
+            Subscription(
+                object=sites[0],
+                user=users[0],
+            ),
+            Subscription(
+                object=sites[1],
+                user=users[1],
+            ),
+            Subscription(
+                object=sites[2],
+                user=users[2],
+            ),
+        )
+        Subscription.objects.bulk_create(subscriptions)
+
+        cls.create_data = [
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[0].pk,
+                'user': users[3].pk,
+            },
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[1].pk,
+                'user': users[3].pk,
+            },
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[2].pk,
+                'user': users[3].pk,
+            },
+        ]
+
+
+class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
+    model = NotificationGroup
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    create_data = [
+        {
+            'object_types': ['dcim.site'],
+            'name': 'Custom Link 4',
+            'enabled': True,
+            'link_text': 'Link 4',
+            'link_url': 'http://example.com/?4',
+        },
+        {
+            'object_types': ['dcim.site'],
+            'name': 'Custom Link 5',
+            'enabled': True,
+            'link_text': 'Link 5',
+            'link_url': 'http://example.com/?5',
+        },
+        {
+            'object_types': ['dcim.site'],
+            'name': 'Custom Link 6',
+            'enabled': False,
+            'link_text': 'Link 6',
+            'link_url': 'http://example.com/?6',
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+        groups = (
+            Group(name='Group 1'),
+            Group(name='Group 2'),
+            Group(name='Group 3'),
+        )
+        Group.objects.bulk_create(groups)
+
+        notification_groups = (
+            NotificationGroup(name='Notification Group 1'),
+            NotificationGroup(name='Notification Group 2'),
+            NotificationGroup(name='Notification Group 3'),
+        )
+        NotificationGroup.objects.bulk_create(notification_groups)
+        for i, notification_group in enumerate(notification_groups):
+            notification_group.users.add(users[i])
+            notification_group.groups.add(groups[i])
+
+        cls.create_data = [
+            {
+                'name': 'Notification Group 4',
+                'description': 'Foo',
+                'users': [users[0].pk],
+                'groups': [groups[0].pk],
+            },
+            {
+                'name': 'Notification Group 5',
+                'description': 'Bar',
+                'users': [users[1].pk],
+                'groups': [groups[1].pk],
+            },
+            {
+                'name': 'Notification Group 6',
+                'description': 'Baz',
+                'users': [users[2].pk],
+                'groups': [groups[2].pk],
+            },
+        ]
+
+
+class NotificationTest(APIViewTestCases.APIViewTestCase):
+    model = Notification
+    brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+            User(username='User 4'),
+        )
+        User.objects.bulk_create(users)
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        notifications = (
+            Notification(
+                object=sites[0],
+                event_type=OBJECT_CREATED,
+                user=users[0],
+            ),
+            Notification(
+                object=sites[1],
+                event_type=OBJECT_UPDATED,
+                user=users[1],
+            ),
+            Notification(
+                object=sites[2],
+                event_type=OBJECT_DELETED,
+                user=users[2],
+            ),
+        )
+        Notification.objects.bulk_create(notifications)
+
+        cls.create_data = [
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[0].pk,
+                'user': users[3].pk,
+                'event_type': OBJECT_CREATED,
+            },
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[1].pk,
+                'user': users[3].pk,
+                'event_type': OBJECT_UPDATED,
+            },
+            {
+                'object_type': 'dcim.site',
+                'object_id': sites[2].pk,
+                'user': users[3].pk,
+                'event_type': OBJECT_DELETED,
+            },
+        ]

+ 14 - 14
netbox/extras/tests/test_event_rules.py

@@ -9,12 +9,12 @@ from django.urls import reverse
 from requests import Session
 from requests import Session
 from rest_framework import status
 from rest_framework import status
 
 
-from core.choices import ObjectChangeActionChoices
+from core.events import *
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import EventRuleActionChoices
 from extras.choices import EventRuleActionChoices
-from extras.events import enqueue_object, flush_events, serialize_for_event
+from extras.events import enqueue_event, flush_events, serialize_for_event
 from extras.models import EventRule, Tag, Webhook
 from extras.models import EventRule, Tag, Webhook
 from extras.webhooks import generate_signature, send_webhook
 from extras.webhooks import generate_signature, send_webhook
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
@@ -132,7 +132,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 1)
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
         self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
         self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
-        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['data']['id'], response.data['id'])
         self.assertEqual(job.kwargs['data']['id'], response.data['id'])
         self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
         self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
@@ -182,7 +182,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 3)
         self.assertEqual(self.queue.count, 3)
         for i, job in enumerate(self.queue.jobs):
         for i, job in enumerate(self.queue.jobs):
             self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
             self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
-            self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
+            self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
             self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
             self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
             self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
@@ -213,7 +213,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 1)
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
         self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
         self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
-        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['data']['id'], site.pk)
         self.assertEqual(job.kwargs['data']['id'], site.pk)
         self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
         self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
@@ -269,7 +269,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 3)
         self.assertEqual(self.queue.count, 3)
         for i, job in enumerate(self.queue.jobs):
         for i, job in enumerate(self.queue.jobs):
             self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
             self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
-            self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
+            self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
             self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
             self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
             self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
@@ -295,7 +295,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 1)
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
         self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
         self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
-        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['data']['id'], site.pk)
         self.assertEqual(job.kwargs['data']['id'], site.pk)
         self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
         self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
@@ -328,7 +328,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 3)
         self.assertEqual(self.queue.count, 3)
         for i, job in enumerate(self.queue.jobs):
         for i, job in enumerate(self.queue.jobs):
             self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
             self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
-            self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
+            self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
             self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
             self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
             self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
@@ -365,12 +365,12 @@ class EventRuleTest(APITestCase):
         # Enqueue a webhook for processing
         # Enqueue a webhook for processing
         webhooks_queue = {}
         webhooks_queue = {}
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
-        enqueue_object(
+        enqueue_event(
             webhooks_queue,
             webhooks_queue,
             instance=site,
             instance=site,
             user=self.user,
             user=self.user,
             request_id=request_id,
             request_id=request_id,
-            action=ObjectChangeActionChoices.ACTION_CREATE
+            event_type=OBJECT_CREATED
         )
         )
         flush_events(list(webhooks_queue.values()))
         flush_events(list(webhooks_queue.values()))
 
 
@@ -378,7 +378,7 @@ class EventRuleTest(APITestCase):
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
 
 
         # Patch the Session object with our dummy_send() method, then process the webhook for sending
         # Patch the Session object with our dummy_send() method, then process the webhook for sending
-        with patch.object(Session, 'send', dummy_send) as mock_send:
+        with patch.object(Session, 'send', dummy_send):
             send_webhook(**job.kwargs)
             send_webhook(**job.kwargs)
 
 
     def test_duplicate_triggers(self):
     def test_duplicate_triggers(self):
@@ -399,7 +399,7 @@ class EventRuleTest(APITestCase):
             site.save()
             site.save()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         job = self.queue.get_jobs()[0]
         job = self.queue.get_jobs()[0]
-        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
         self.queue.empty()
         self.queue.empty()
 
 
         # Test multiple updates
         # Test multiple updates
@@ -411,7 +411,7 @@ class EventRuleTest(APITestCase):
             site.save()
             site.save()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         job = self.queue.get_jobs()[0]
         job = self.queue.get_jobs()[0]
-        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
         self.queue.empty()
         self.queue.empty()
 
 
         # Test update & delete
         # Test update & delete
@@ -422,5 +422,5 @@ class EventRuleTest(APITestCase):
             site.delete()
             site.delete()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         job = self.queue.get_jobs()[0]
         job = self.queue.get_jobs()[0]
-        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
         self.queue.empty()
         self.queue.empty()

+ 63 - 4
netbox/extras/tests/test_filtersets.py

@@ -1,7 +1,6 @@
 import uuid
 import uuid
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 
 
-from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 
 
@@ -15,13 +14,11 @@ from extras.choices import *
 from extras.filtersets import *
 from extras.filtersets import *
 from extras.models import *
 from extras.models import *
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from users.models import Group, User
 from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 
 
-User = get_user_model()
-
-
 class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
 class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomField.objects.all()
     queryset = CustomField.objects.all()
     filterset = CustomFieldFilterSet
     filterset = CustomFieldFilterSet
@@ -1370,3 +1367,65 @@ class ChangeLoggedFilterSetTestCase(TestCase):
         params = {'modified_by_request': self.create_update_request_id}
         params = {'modified_by_request': self.create_update_request_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.queryset.count(), 4)
         self.assertEqual(self.queryset.count(), 4)
+
+
+class NotificationGroupTestCase(TestCase, BaseFilterSetTests):
+    queryset = NotificationGroup.objects.all()
+    filterset = NotificationGroupFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+
+        groups = (
+            Group(name='Group 1'),
+            Group(name='Group 2'),
+            Group(name='Group 3'),
+        )
+        Group.objects.bulk_create(groups)
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        notification_groups = (
+            NotificationGroup(name='Notification Group 1'),
+            NotificationGroup(name='Notification Group 2'),
+            NotificationGroup(name='Notification Group 3'),
+        )
+        NotificationGroup.objects.bulk_create(notification_groups)
+        notification_groups[0].users.add(users[0])
+        notification_groups[1].users.add(users[1])
+        notification_groups[2].users.add(users[2])
+        notification_groups[0].groups.add(groups[0])
+        notification_groups[1].groups.add(groups[1])
+        notification_groups[2].groups.add(groups[2])
+
+    def test_user(self):
+        users = User.objects.filter(username__startswith='User')
+        params = {'user': [users[0].username, users[1].username]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'user_id': [users[0].pk, users[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_group(self):
+        groups = Group.objects.all()
+        params = {'group': [groups[0].name, groups[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'group_id': [groups[0].pk, groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 164 - 3
netbox/extras/tests/test_views.py

@@ -1,4 +1,3 @@
-from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
@@ -6,10 +5,9 @@ from core.models import ObjectType
 from dcim.models import DeviceType, Manufacturer, Site
 from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
+from users.models import Group, User
 from utilities.testing import ViewTestCases, TestCase
 from utilities.testing import ViewTestCases, TestCase
 
 
-User = get_user_model()
-
 
 
 class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = CustomField
     model = CustomField
@@ -620,3 +618,166 @@ class CustomLinkTest(TestCase):
         response = self.client.get(site.get_absolute_url(), follow=True)
         response = self.client.get(site.get_absolute_url(), follow=True)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertIn(f'FOO {site.name} BAR', str(response.content))
         self.assertIn(f'FOO {site.name} BAR', str(response.content))
+
+
+class SubscriptionTestCase(
+    ViewTestCases.CreateObjectViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
+    model = Subscription
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
+        )
+        Site.objects.bulk_create(sites)
+
+        cls.form_data = {
+            'object_type': site_ct.pk,
+            'object_id': sites[3].pk,
+        }
+
+    def setUp(self):
+        super().setUp()
+
+        sites = Site.objects.all()
+        user = self.user
+
+        subscriptions = (
+            Subscription(object=sites[0], user=user),
+            Subscription(object=sites[1], user=user),
+            Subscription(object=sites[2], user=user),
+        )
+        Subscription.objects.bulk_create(subscriptions)
+
+    def _get_url(self, action, instance=None):
+        if action == 'list':
+            return reverse('account:subscriptions')
+        return super()._get_url(action, instance)
+
+    def test_list_objects_anonymous(self):
+        self.client.logout()
+        url = reverse('account:subscriptions')
+        login_url = reverse('login')
+        self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
+
+    def test_list_objects_with_permission(self):
+        return
+
+    def test_list_objects_with_constrained_permission(self):
+        return
+
+
+class NotificationGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = NotificationGroup
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+        groups = (
+            Group(name='Group 1'),
+            Group(name='Group 2'),
+            Group(name='Group 3'),
+        )
+        Group.objects.bulk_create(groups)
+
+        notification_groups = (
+            NotificationGroup(name='Notification Group 1'),
+            NotificationGroup(name='Notification Group 2'),
+            NotificationGroup(name='Notification Group 3'),
+        )
+        NotificationGroup.objects.bulk_create(notification_groups)
+        for i, notification_group in enumerate(notification_groups):
+            notification_group.users.add(users[i])
+            notification_group.groups.add(groups[i])
+
+        cls.form_data = {
+            'name': 'Notification Group X',
+            'description': 'Blah',
+            'users': [users[0].pk, users[1].pk],
+            'groups': [groups[0].pk, groups[1].pk],
+        }
+
+        cls.csv_data = (
+            'name,description,users,groups',
+            'Notification Group 4,Foo,"User 1,User 2","Group 1,Group 2"',
+            'Notification Group 5,Bar,"User 1,User 2","Group 1,Group 2"',
+            'Notification Group 6,Baz,"User 1,User 2","Group 1,Group 2"',
+        )
+
+        cls.csv_update_data = (
+            "id,name",
+            f"{notification_groups[0].pk},Notification Group 7",
+            f"{notification_groups[1].pk},Notification Group 8",
+            f"{notification_groups[2].pk},Notification Group 9",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
+class NotificationTestCase(
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
+    model = Notification
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
+        )
+        Site.objects.bulk_create(sites)
+
+        cls.form_data = {
+            'object_type': site_ct.pk,
+            'object_id': sites[3].pk,
+        }
+
+    def setUp(self):
+        super().setUp()
+
+        sites = Site.objects.all()
+        user = self.user
+
+        notifications = (
+            Notification(object=sites[0], user=user),
+            Notification(object=sites[1], user=user),
+            Notification(object=sites[2], user=user),
+        )
+        Notification.objects.bulk_create(notifications)
+
+    def _get_url(self, action, instance=None):
+        if action == 'list':
+            return reverse('account:notifications')
+        return super()._get_url(action, instance)
+
+    def test_list_objects_anonymous(self):
+        self.client.logout()
+        url = reverse('account:notifications')
+        login_url = reverse('login')
+        self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
+
+    def test_list_objects_with_permission(self):
+        return
+
+    def test_list_objects_with_constrained_permission(self):
+        return

+ 18 - 0
netbox/extras/urls.py

@@ -53,6 +53,24 @@ urlpatterns = [
     path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
     path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
     path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
     path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
 
 
+    # Notification groups
+    path('notification-groups/', views.NotificationGroupListView.as_view(), name='notificationgroup_list'),
+    path('notification-groups/add/', views.NotificationGroupEditView.as_view(), name='notificationgroup_add'),
+    path('notification-groups/import/', views.NotificationGroupBulkImportView.as_view(), name='notificationgroup_import'),
+    path('notification-groups/edit/', views.NotificationGroupBulkEditView.as_view(), name='notificationgroup_bulk_edit'),
+    path('notification-groups/delete/', views.NotificationGroupBulkDeleteView.as_view(), name='notificationgroup_bulk_delete'),
+    path('notification-groups/<int:pk>/', include(get_model_urls('extras', 'notificationgroup'))),
+
+    # Notifications
+    path('notifications/', views.NotificationsView.as_view(), name='notifications'),
+    path('notifications/delete/', views.NotificationBulkDeleteView.as_view(), name='notification_bulk_delete'),
+    path('notifications/<int:pk>/', include(get_model_urls('extras', 'notification'))),
+
+    # Subscriptions
+    path('subscriptions/add/', views.SubscriptionCreateView.as_view(), name='subscription_add'),
+    path('subscriptions/delete/', views.SubscriptionBulkDeleteView.as_view(), name='subscription_bulk_delete'),
+    path('subscriptions/<int:pk>/', include(get_model_urls('extras', 'subscription'))),
+
     # Webhooks
     # Webhooks
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
     path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

+ 134 - 0
netbox/extras/views.py

@@ -6,6 +6,7 @@ from django.db.models import Count, Q
 from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
 from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from django.views.generic import View
 from django.views.generic import View
 
 
@@ -356,6 +357,139 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
         return Bookmark.objects.filter(user=request.user)
         return Bookmark.objects.filter(user=request.user)
 
 
 
 
+#
+# Notification groups
+#
+
+class NotificationGroupListView(generic.ObjectListView):
+    queryset = NotificationGroup.objects.all()
+    filterset = filtersets.NotificationGroupFilterSet
+    filterset_form = forms.NotificationGroupFilterForm
+    table = tables.NotificationGroupTable
+
+
+@register_model_view(NotificationGroup)
+class NotificationGroupView(generic.ObjectView):
+    queryset = NotificationGroup.objects.all()
+
+
+@register_model_view(NotificationGroup, 'edit')
+class NotificationGroupEditView(generic.ObjectEditView):
+    queryset = NotificationGroup.objects.all()
+    form = forms.NotificationGroupForm
+
+
+@register_model_view(NotificationGroup, 'delete')
+class NotificationGroupDeleteView(generic.ObjectDeleteView):
+    queryset = NotificationGroup.objects.all()
+
+
+class NotificationGroupBulkImportView(generic.BulkImportView):
+    queryset = NotificationGroup.objects.all()
+    model_form = forms.NotificationGroupImportForm
+
+
+class NotificationGroupBulkEditView(generic.BulkEditView):
+    queryset = NotificationGroup.objects.all()
+    filterset = filtersets.NotificationGroupFilterSet
+    table = tables.NotificationGroupTable
+    form = forms.NotificationGroupBulkEditForm
+
+
+class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
+    queryset = NotificationGroup.objects.all()
+    filterset = filtersets.NotificationGroupFilterSet
+    table = tables.NotificationGroupTable
+
+
+#
+# Notifications
+#
+
+class NotificationsView(LoginRequiredMixin, View):
+    """
+    HTMX-only user-specific notifications list.
+    """
+    def get(self, request):
+        return render(request, 'htmx/notifications.html', {
+            'notifications': request.user.notifications.unread(),
+            'total_count': request.user.notifications.count(),
+        })
+
+
+@register_model_view(Notification, 'read')
+class NotificationReadView(LoginRequiredMixin, View):
+    """
+    Mark the Notification read and redirect the user to its attached object.
+    """
+    def get(self, request, pk):
+        notification = get_object_or_404(request.user.notifications, pk=pk)
+        notification.read = timezone.now()
+        notification.save()
+
+        return redirect(notification.object.get_absolute_url())
+
+
+@register_model_view(Notification, 'dismiss')
+class NotificationDismissView(LoginRequiredMixin, View):
+    """
+    A convenience view which allows deleting notifications with one click.
+    """
+    def get(self, request, pk):
+        notification = get_object_or_404(request.user.notifications, pk=pk)
+        notification.delete()
+
+        if htmx_partial(request):
+            return render(request, 'htmx/notifications.html', {
+                'notifications': request.user.notifications.unread()[:10],
+            })
+
+        return redirect('account:notifications')
+
+
+@register_model_view(Notification, 'delete')
+class NotificationDeleteView(generic.ObjectDeleteView):
+
+    def get_queryset(self, request):
+        return Notification.objects.filter(user=request.user)
+
+
+class NotificationBulkDeleteView(generic.BulkDeleteView):
+    table = tables.NotificationTable
+
+    def get_queryset(self, request):
+        return Notification.objects.filter(user=request.user)
+
+
+#
+# Subscriptions
+#
+
+class SubscriptionCreateView(generic.ObjectEditView):
+    form = forms.SubscriptionForm
+
+    def get_queryset(self, request):
+        return Subscription.objects.filter(user=request.user)
+
+    def alter_object(self, obj, request, url_args, url_kwargs):
+        obj.user = request.user
+        return obj
+
+
+@register_model_view(Subscription, 'delete')
+class SubscriptionDeleteView(generic.ObjectDeleteView):
+
+    def get_queryset(self, request):
+        return Subscription.objects.filter(user=request.user)
+
+
+class SubscriptionBulkDeleteView(generic.BulkDeleteView):
+    table = tables.SubscriptionTable
+
+    def get_queryset(self, request):
+        return Subscription.objects.filter(user=request.user)
+
+
 #
 #
 # Webhooks
 # Webhooks
 #
 #

+ 2 - 2
netbox/extras/webhooks.py

@@ -25,7 +25,7 @@ def generate_signature(request_body, secret):
 
 
 
 
 @job('default')
 @job('default')
-def send_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
+def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None):
     """
     """
     Make a POST request to the defined Webhook
     Make a POST request to the defined Webhook
     """
     """
@@ -33,7 +33,7 @@ def send_webhook(event_rule, model_name, event, data, timestamp, username, reque
 
 
     # Prepare context data for headers & body templates
     # Prepare context data for headers & body templates
     context = {
     context = {
-        'event': WEBHOOK_EVENT_TYPES[event],
+        'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type),
         'timestamp': timestamp,
         'timestamp': timestamp,
         'model': model_name,
         'model': model_name,
         'username': username,
         'username': username,

+ 45 - 0
netbox/netbox/events.py

@@ -0,0 +1,45 @@
+from dataclasses import dataclass
+
+from netbox.registry import registry
+
+EVENT_TYPE_INFO = 'info'
+EVENT_TYPE_SUCCESS = 'success'
+EVENT_TYPE_WARNING = 'warning'
+EVENT_TYPE_DANGER = 'danger'
+
+__all__ = (
+    'EVENT_TYPE_DANGER',
+    'EVENT_TYPE_INFO',
+    'EVENT_TYPE_SUCCESS',
+    'EVENT_TYPE_WARNING',
+    'Event',
+)
+
+
+@dataclass
+class Event:
+    name: str
+    text: str
+    type: str = EVENT_TYPE_INFO
+
+    def __str__(self):
+        return self.text
+
+    def register(self):
+        registry['events'][self.name] = self
+
+    def color(self):
+        return {
+            EVENT_TYPE_INFO: 'blue',
+            EVENT_TYPE_SUCCESS: 'green',
+            EVENT_TYPE_WARNING: 'orange',
+            EVENT_TYPE_DANGER: 'red',
+        }.get(self.type)
+
+    def icon(self):
+        return {
+            EVENT_TYPE_INFO: 'mdi mdi-information',
+            EVENT_TYPE_SUCCESS: 'mdi mdi-check-circle',
+            EVENT_TYPE_WARNING: 'mdi mdi-alert-box',
+            EVENT_TYPE_DANGER: 'mdi mdi-alert-octagon',
+        }.get(self.type)

+ 1 - 0
netbox/netbox/models/__init__.py

@@ -29,6 +29,7 @@ class NetBoxFeatureSet(
     CustomValidationMixin,
     CustomValidationMixin,
     ExportTemplatesMixin,
     ExportTemplatesMixin,
     JournalingMixin,
     JournalingMixin,
+    NotificationsMixin,
     TagsMixin,
     TagsMixin,
     EventRulesMixin
     EventRulesMixin
 ):
 ):

+ 22 - 1
netbox/netbox/models/features.py

@@ -34,6 +34,7 @@ __all__ = (
     'ImageAttachmentsMixin',
     'ImageAttachmentsMixin',
     'JobsMixin',
     'JobsMixin',
     'JournalingMixin',
     'JournalingMixin',
+    'NotificationsMixin',
     'SyncedDataMixin',
     'SyncedDataMixin',
     'TagsMixin',
     'TagsMixin',
     'register_models',
     'register_models',
@@ -377,6 +378,25 @@ class BookmarksMixin(models.Model):
         abstract = True
         abstract = True
 
 
 
 
+class NotificationsMixin(models.Model):
+    """
+    Enables support for user notifications.
+    """
+    notifications = GenericRelation(
+        to='extras.Notification',
+        content_type_field='object_type',
+        object_id_field='object_id'
+    )
+    subscriptions = GenericRelation(
+        to='extras.Subscription',
+        content_type_field='object_type',
+        object_id_field='object_id'
+    )
+
+    class Meta:
+        abstract = True
+
+
 class JobsMixin(models.Model):
 class JobsMixin(models.Model):
     """
     """
     Enables support for job results.
     Enables support for job results.
@@ -582,13 +602,14 @@ FEATURES_MAP = {
     'custom_fields': CustomFieldsMixin,
     'custom_fields': CustomFieldsMixin,
     'custom_links': CustomLinksMixin,
     'custom_links': CustomLinksMixin,
     'custom_validation': CustomValidationMixin,
     'custom_validation': CustomValidationMixin,
+    'event_rules': EventRulesMixin,
     'export_templates': ExportTemplatesMixin,
     'export_templates': ExportTemplatesMixin,
     'image_attachments': ImageAttachmentsMixin,
     'image_attachments': ImageAttachmentsMixin,
     'jobs': JobsMixin,
     'jobs': JobsMixin,
     'journaling': JournalingMixin,
     'journaling': JournalingMixin,
+    'notifications': NotificationsMixin,
     'synced_data': SyncedDataMixin,
     'synced_data': SyncedDataMixin,
     'tags': TagsMixin,
     'tags': TagsMixin,
-    'event_rules': EventRulesMixin,
 }
 }
 
 
 registry['model_features'].update({
 registry['model_features'].update({

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -355,6 +355,7 @@ OPERATIONS_MENU = Menu(
         MenuGroup(
         MenuGroup(
             label=_('Logging'),
             label=_('Logging'),
             items=(
             items=(
+                get_model_item('extras', 'notificationgroup', _('Notification Groups')),
                 get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
                 get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
                 get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
                 get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
             ),
             ),

+ 1 - 0
netbox/netbox/registry.py

@@ -25,6 +25,7 @@ registry = Registry({
     'counter_fields': collections.defaultdict(dict),
     'counter_fields': collections.defaultdict(dict),
     'data_backends': dict(),
     'data_backends': dict(),
     'denormalized_fields': collections.defaultdict(list),
     'denormalized_fields': collections.defaultdict(list),
+    'events': dict(),
     'model_features': dict(),
     'model_features': dict(),
     'models': collections.defaultdict(set),
     'models': collections.defaultdict(set),
     'plugins': dict(),
     'plugins': dict(),

+ 10 - 0
netbox/netbox/settings.py

@@ -84,6 +84,16 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
     'extras.add_bookmark': ({'user': '$user'},),
     'extras.add_bookmark': ({'user': '$user'},),
     'extras.change_bookmark': ({'user': '$user'},),
     'extras.change_bookmark': ({'user': '$user'},),
     'extras.delete_bookmark': ({'user': '$user'},),
     'extras.delete_bookmark': ({'user': '$user'},),
+    # Permit users to manage their own notifications
+    'extras.view_notification': ({'user': '$user'},),
+    'extras.add_notification': ({'user': '$user'},),
+    'extras.change_notification': ({'user': '$user'},),
+    'extras.delete_notification': ({'user': '$user'},),
+    # Permit users to manage their own subscriptions
+    'extras.view_subscription': ({'user': '$user'},),
+    'extras.add_subscription': ({'user': '$user'},),
+    'extras.change_subscription': ({'user': '$user'},),
+    'extras.delete_subscription': ({'user': '$user'},),
     # Permit users to manage their own API tokens
     # Permit users to manage their own API tokens
     'users.view_token': ({'user': '$user'},),
     'users.view_token': ({'user': '$user'},),
     'users.add_token': ({'user': '$user'},),
     'users.add_token': ({'user': '$user'},),

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
netbox/project-static/dist/netbox.css


+ 9 - 0
netbox/project-static/styles/custom/_notifications.scss

@@ -0,0 +1,9 @@
+@use 'sass:map';
+
+// Mute read notifications
+tr[data-read=True] {
+  td {
+    background-color: var(--#{$prefix}bg-surface-secondary);
+    color: $text-muted;
+  }
+}

+ 1 - 0
netbox/project-static/styles/netbox.scss

@@ -24,3 +24,4 @@
 @import 'custom/interfaces';
 @import 'custom/interfaces';
 @import 'custom/markdown';
 @import 'custom/markdown';
 @import 'custom/misc';
 @import 'custom/misc';
+@import 'custom/notifications';

+ 6 - 0
netbox/templates/account/base.html

@@ -9,6 +9,12 @@
     <li role="presentation" class="nav-item">
     <li role="presentation" class="nav-item">
       <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
       <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
     </li>
     </li>
+    <li role="presentation" class="nav-item">
+      <a class="nav-link{% if active_tab == 'notifications' %} active{% endif %}" href="{% url 'account:notifications' %}">{% trans "Notifications" %}</a>
+    </li>
+    <li role="presentation" class="nav-item">
+      <a class="nav-link{% if active_tab == 'subscriptions' %} active{% endif %}" href="{% url 'account:subscriptions' %}">{% trans "Subscriptions" %}</a>
+    </li>
     <li role="presentation" class="nav-item">
     <li role="presentation" class="nav-item">
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
     </li>
     </li>

+ 32 - 0
netbox/templates/account/notifications.html

@@ -0,0 +1,32 @@
+{% extends 'account/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block title %}{% trans "Notifications" %}{% endblock %}
+
+{% block content %}
+  <form method="post" class="form form-horizontal">
+    {% csrf_token %}
+    <input type="hidden" name="return_url" value="{% url 'account:notifications' %}" />
+
+    {# Table #}
+    <div class="row">
+      <div class="col col-md-12">
+        <div class="card">
+          <div class="htmx-container table-responsive" id="object_list">
+            {% include 'htmx/table.html' %}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    {# Form buttons #}
+    <div class="btn-list d-print-none mt-2">
+      {% if 'bulk_delete' in actions %}
+        {% bulk_delete_button model query_params=request.GET %}
+      {% endif %}
+    </div>
+  </form>
+{% endblock %}

+ 32 - 0
netbox/templates/account/subscriptions.html

@@ -0,0 +1,32 @@
+{% extends 'account/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block title %}{% trans "Subscriptions" %}{% endblock %}
+
+{% block content %}
+  <form method="post" class="form form-horizontal">
+    {% csrf_token %}
+    <input type="hidden" name="return_url" value="{% url 'account:subscriptions' %}" />
+
+    {# Table #}
+    <div class="row">
+      <div class="col col-md-12">
+        <div class="card">
+          <div class="htmx-container table-responsive" id="object_list">
+            {% include 'htmx/table.html' %}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    {# Form buttons #}
+    <div class="btn-list d-print-none mt-2">
+      {% if 'bulk_delete' in actions %}
+        {% bulk_delete_button model query_params=request.GET %}
+      {% endif %}
+    </div>
+  </form>
+{% endblock %}

+ 57 - 0
netbox/templates/extras/notificationgroup.html

@@ -0,0 +1,57 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Notification Group" %}</h5>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Name" %}</th>
+            <td>
+              {{ object.name }}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>
+                {{ object.description|placeholder }}
+            </td>
+          </tr>
+        </table>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Groups" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for group in object.groups.all %}
+            <a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None assigned" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Users" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for user in object.users.all %}
+            <a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None assigned" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 3 - 0
netbox/templates/generic/object.html

@@ -77,6 +77,9 @@ Context:
       {% if perms.extras.add_bookmark and object.bookmarks %}
       {% if perms.extras.add_bookmark and object.bookmarks %}
         {% bookmark_button object %}
         {% bookmark_button object %}
       {% endif %}
       {% endif %}
+      {% if perms.extras.add_subscription and object.subscriptions %}
+        {% subscribe_button object %}
+      {% endif %}
       {% if request.user|can_add:object %}
       {% if request.user|can_add:object %}
         {% clone_button object %}
         {% clone_button object %}
       {% endif %}
       {% endif %}

+ 33 - 0
netbox/templates/htmx/notifications.html

@@ -0,0 +1,33 @@
+{% load i18n %}
+<div class="list-group list-group-flush list-group-hoverable" style="min-width: 300px">
+  {% for notification in notifications %}
+    <div class="list-group-item p-2">
+      <div class="row align-items-center">
+        <div class="col-auto text-{{ notification.event.color }} fs-2 pe-0">
+          <i class="{{ notification.event.icon }}"></i>
+        </div>
+        <div class="col text-truncate">
+          <a href="{% url 'extras:notification_read' pk=notification.pk %}" class="text-body d-block">{{ notification.object }}</a>
+          <div class="d-block text-secondary fs-5">{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}</div>
+        </div>
+        <div class="col-auto">
+          <a href="#" hx-get="{% url 'extras:notification_dismiss' pk=notification.pk %}" hx-target="closest .notifications" class="list-group-item-actions text-secondary" title="{% trans "Dismiss" %}">
+            <i class="mdi mdi-close"></i>
+          </a>
+        </div>
+      </div>
+    </div>
+  {% empty %}
+    <div class="dropdown-item text-muted">
+      {% trans "No unread notifications" %}
+    </div>
+  {% endfor %}
+  {% if total_count %}
+    <a href="{% url 'account:notifications' %}" class="list-group-item list-group-item-action d-flex justify-content-between p-2">
+      {% trans "All notifications" %}
+      {% badge total_count %}
+    </a>
+  {% endif %}
+</div>
+{% include 'inc/notification_bell.html' %}
+

+ 9 - 0
netbox/templates/inc/notification_bell.html

@@ -0,0 +1,9 @@
+{% if notifications %}
+  <span class="text-primary" id="notifications-alert" hx-swap-oob="true">
+    <i class="mdi mdi-bell-badge"></i>
+  </span>
+{% else %}
+  <span class="text-muted" id="notifications-alert" hx-swap-oob="true">
+    <i class="mdi mdi-bell"></i>
+  </span>
+{% endif %}

+ 14 - 0
netbox/templates/inc/user_menu.html

@@ -2,6 +2,17 @@
 {% load navigation %}
 {% load navigation %}
 
 
 {% if request.user.is_authenticated %}
 {% if request.user.is_authenticated %}
+  {# Notifications #}
+  {% with notifications=request.user.notifications.unread.exists %}
+    <div class="nav-item dropdown">
+      <a href="#" class="nav-link" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="Notifications">
+        {% include 'inc/notification_bell.html' %}
+      </a>
+      <div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow notifications"></div>
+    </div>
+  {% endwith %}
+
+  {# User menu #}
   <div class="nav-item dropdown">
   <div class="nav-item dropdown">
     <a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
     <a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
       <div class="d-xl-block ps-2">
       <div class="d-xl-block ps-2">
@@ -29,6 +40,9 @@
       <a href="{% url 'account:bookmarks' %}" class="dropdown-item">
       <a href="{% url 'account:bookmarks' %}" class="dropdown-item">
         <i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
         <i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
       </a>
       </a>
+      <a href="{% url 'account:subscriptions' %}" class="dropdown-item">
+        <i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
+      </a>
       <a href="{% url 'account:preferences' %}" class="dropdown-item">
       <a href="{% url 'account:preferences' %}" class="dropdown-item">
         <i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
         <i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
       </a>
       </a>

+ 11 - 0
netbox/users/filtersets.py

@@ -5,6 +5,7 @@ from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
+from extras.models import NotificationGroup
 from netbox.filtersets import BaseFilterSet
 from netbox.filtersets import BaseFilterSet
 from users.models import Group, ObjectPermission, Token
 from users.models import Group, ObjectPermission, Token
 from utilities.filters import ContentTypeFilter
 from utilities.filters import ContentTypeFilter
@@ -32,6 +33,11 @@ class GroupFilterSet(BaseFilterSet):
         queryset=ObjectPermission.objects.all(),
         queryset=ObjectPermission.objects.all(),
         label=_('Permission (ID)'),
         label=_('Permission (ID)'),
     )
     )
+    notification_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='notification_groups',
+        queryset=NotificationGroup.objects.all(),
+        label=_('Notification group (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Group
         model = Group
@@ -67,6 +73,11 @@ class UserFilterSet(BaseFilterSet):
         queryset=ObjectPermission.objects.all(),
         queryset=ObjectPermission.objects.all(),
         label=_('Permission (ID)'),
         label=_('Permission (ID)'),
     )
     )
+    notification_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='notification_groups',
+        queryset=NotificationGroup.objects.all(),
+        label=_('Notification group (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = get_user_model()
         model = get_user_model()

+ 1 - 1
netbox/utilities/templates/buttons/bookmark.html

@@ -10,7 +10,7 @@
     </button>
     </button>
   {% else %}
   {% else %}
     <button type="submit" class="btn btn-cyan">
     <button type="submit" class="btn btn-cyan">
-      <i class="mdi mdi-bookmark-check"></i> {% trans "Bookmark" %}
+      <i class="mdi mdi-bookmark-plus"></i> {% trans "Bookmark" %}
     </button>
     </button>
   {% endif %}
   {% endif %}
 </form>
 </form>

+ 18 - 0
netbox/utilities/templates/buttons/subscribe.html

@@ -0,0 +1,18 @@
+{% load i18n %}
+{% if form_url %}
+  <form action="{{ form_url }}?return_url={{ return_url }}" method="post">
+    {% csrf_token %}
+    {% for field, value in form_data.items %}
+      <input type="hidden" name="{{ field }}" value="{{ value }}" />
+    {% endfor %}
+    {% if subscription %}
+      <button type="submit" class="btn btn-cyan">
+        <i class="mdi mdi-bell-minus"></i> {% trans "Unsubscribe" %}
+      </button>
+    {% else %}
+      <button type="submit" class="btn btn-cyan">
+        <i class="mdi mdi-bell-plus"></i> {% trans "Subscribe" %}
+      </button>
+    {% endif %}
+  </form>
+{% endif %}

+ 38 - 1
netbox/utilities/templatetags/buttons.py

@@ -3,7 +3,8 @@ from django.contrib.contenttypes.models import ContentType
 from django.urls import NoReverseMatch, reverse
 from django.urls import NoReverseMatch, reverse
 
 
 from core.models import ObjectType
 from core.models import ObjectType
-from extras.models import Bookmark, ExportTemplate
+from extras.models import Bookmark, ExportTemplate, Subscription
+from netbox.models.features import NotificationsMixin
 from utilities.querydict import prepare_cloned_fields
 from utilities.querydict import prepare_cloned_fields
 from utilities.views import get_viewname
 from utilities.views import get_viewname
 
 
@@ -17,6 +18,7 @@ __all__ = (
     'edit_button',
     'edit_button',
     'export_button',
     'export_button',
     'import_button',
     'import_button',
+    'subscribe_button',
     'sync_button',
     'sync_button',
 )
 )
 
 
@@ -94,6 +96,41 @@ def delete_button(instance):
     }
     }
 
 
 
 
+@register.inclusion_tag('buttons/subscribe.html', takes_context=True)
+def subscribe_button(context, instance):
+    # Skip for objects which don't support notifications
+    if not (issubclass(instance.__class__, NotificationsMixin)):
+        return {}
+
+    # Check if this user has already subscribed to the object
+    content_type = ContentType.objects.get_for_model(instance)
+    subscription = Subscription.objects.filter(
+        object_type=content_type,
+        object_id=instance.pk,
+        user=context['request'].user
+    ).first()
+
+    # Compile form URL & data
+    if subscription:
+        form_url = reverse('extras:subscription_delete', kwargs={'pk': subscription.pk})
+        form_data = {
+            'confirm': 'true',
+        }
+    else:
+        form_url = reverse('extras:subscription_add')
+        form_data = {
+            'object_type': content_type.pk,
+            'object_id': instance.pk,
+        }
+
+    return {
+        'subscription': subscription,
+        'form_url': form_url,
+        'form_data': form_data,
+        'return_url': instance.get_absolute_url(),
+    }
+
+
 @register.inclusion_tag('buttons/sync.html')
 @register.inclusion_tag('buttons/sync.html')
 def sync_button(instance):
 def sync_button(instance):
     viewname = get_viewname(instance, 'sync')
     viewname = get_viewname(instance, 'sync')

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä