Просмотр исходного кода

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 год назад
Родитель
Сommit
b0e7294bc1
59 измененных файлов с 1912 добавлено и 89 удалено
  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'
             - ImageAttachment: 'models/extras/imageattachment.md'
             - JournalEntry: 'models/extras/journalentry.md'
+            - Notification: 'models/extras/notification.md'
+            - NotificationGroup: 'models/extras/notificationgroup.md'
             - SavedFilter: 'models/extras/savedfilter.md'
             - StagedChange: 'models/extras/stagedchange.md'
+            - Subscription: 'models/extras/subscription.md'
             - Tag: 'models/extras/tag.md'
             - Webhook: 'models/extras/webhook.md'
         - IPAM:

+ 2 - 0
netbox/account/urls.py

@@ -9,6 +9,8 @@ urlpatterns = [
     # Account views
     path('profile/', views.ProfileView.as_view(), name='profile'),
     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('password/', views.ChangePasswordView.as_view(), name='change_password'),
     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.tables import ObjectChangeTable
 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.config import get_config
 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
 #

+ 1 - 1
netbox/core/apps.py

@@ -18,7 +18,7 @@ class CoreConfig(AppConfig):
     def ready(self):
         from core.api import schema  # noqa
         from netbox.models.features import register_models
-        from . import data_backends, search
+        from . import data_backends, events, search
 
         # Register 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.models import ObjectType
 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.constants import RQ_QUEUE_DEFAULT
 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_.exporttemplates import *
 from .serializers_.journaling import *
+from .serializers_.notifications import *
 from .serializers_.configcontexts import *
 from .serializers_.configtemplates 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('saved-filters', views.SavedFilterViewSet)
 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('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)

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

@@ -140,6 +140,27 @@ class BookmarkViewSet(NetBoxModelViewSet):
     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
 #

+ 2 - 0
netbox/extras/choices.py

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

+ 9 - 12
netbox/extras/constants.py

@@ -1,12 +1,6 @@
+from core.events import *
 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
 CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 
@@ -14,11 +8,14 @@ CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
 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

+ 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_rq import get_queue
 
-from core.choices import ObjectChangeActionChoices
+from core.events import *
 from core.models import Job
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
@@ -35,12 +35,12 @@ def serialize_for_event(instance):
     return serializer.data
 
 
-def get_snapshots(instance, action):
+def get_snapshots(instance, event_type):
     snapshots = {
         'prechange': getattr(instance, '_prechange_snapshot', 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
         if hasattr(instance, 'serialize_object'):
             snapshots['postchange'] = instance.serialize_object()
@@ -50,7 +50,7 @@ def get_snapshots(instance, action):
     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
     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}'
     if key in queue:
         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 action == ObjectChangeActionChoices.ACTION_DELETE:
-            queue[key]['event'] = action
+        if event_type == OBJECT_DELETED:
+            queue[key]['event_type'] = event_type
     else:
         queue[key] = {
-            'content_type': ContentType.objects.get_for_model(instance),
+            'object_type': ContentType.objects.get_for_model(instance),
             'object_id': instance.pk,
-            'event': action,
+            'event_type': event_type,
             'data': serialize_for_event(instance),
-            'snapshots': get_snapshots(instance, action),
+            'snapshots': get_snapshots(instance, event_type),
             'username': user.username,
             '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:
 
@@ -103,8 +100,8 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
             # Compile the task parameters
             params = {
                 "event_rule": event_rule,
-                "model_name": model_name,
-                "event": event,
+                "model_name": object_type.model,
+                "event_type": event_type,
                 "data": data,
                 "snapshots": snapshots,
                 "timestamp": timezone.now().isoformat(),
@@ -136,6 +133,15 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
                 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:
             raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
                 action_type=event_rule.action_type
@@ -151,27 +157,39 @@ def process_event_queue(events):
         'type_update': {},
         '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
-        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},
-                object_types=content_type,
+                object_types=object_type,
                 enabled=True
             )
-        event_rules = events_cache[action_flag][content_type]
+        event_rules = events_cache[action_flag][object_type]
 
         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 netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
+from users.models import Group, User
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .choices import *
@@ -26,6 +27,7 @@ __all__ = (
     'ImageAttachmentFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
+    'NotificationGroupFilterSet',
     'ObjectTypeFilterSet',
     'SavedFilterFilterSet',
     'ScriptFilterSet',
@@ -336,6 +338,49 @@ class BookmarkFilterSet(BaseFilterSet):
         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):
     q = django_filters.CharFilter(
         method='search',

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

@@ -18,6 +18,7 @@ __all__ = (
     'EventRuleBulkEditForm',
     'ExportTemplateBulkEditForm',
     'JournalEntryBulkEditForm',
+    'NotificationGroupBulkEditForm',
     'SavedFilterBulkEditForm',
     'TagBulkEditForm',
     'WebhookBulkEditForm',
@@ -343,3 +344,17 @@ class JournalEntryBulkEditForm(BulkEditForm):
         required=False
     )
     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.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import ObjectDoesNotExist
-from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectType
 from extras.choices import *
 from extras.models import *
 from netbox.forms import NetBoxModelImportForm
+from users.models import Group, User
 from utilities.forms import CSVModelForm
 from utilities.forms.fields import (
-    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
+    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField,
+    SlugField,
 )
 
 __all__ = (
@@ -23,6 +24,7 @@ __all__ = (
     'EventRuleImportForm',
     'ExportTemplateImportForm',
     'JournalEntryImportForm',
+    'NotificationGroupImportForm',
     'SavedFilterImportForm',
     'TagImportForm',
     'WebhookImportForm',
@@ -247,3 +249,24 @@ class JournalEntryImportForm(NetBoxModelImportForm):
         fields = (
             '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.mixins import SavedFiltersMixin
 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.fields import (
     ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
@@ -28,6 +29,7 @@ __all__ = (
     'ImageAttachmentFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
+    'NotificationGroupFilterForm',
     'SavedFilterFilterForm',
     'TagFilterForm',
     'WebhookFilterForm',
@@ -496,3 +498,16 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         required=False
     )
     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 netbox.forms import NetBoxModelForm
 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.fields import (
     CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
@@ -32,7 +33,9 @@ __all__ = (
     'ExportTemplateForm',
     'ImageAttachmentForm',
     'JournalEntryForm',
+    'NotificationGroupForm',
     'SavedFilterForm',
+    'SubscriptionForm',
     'TagForm',
     'WebhookForm',
 )
@@ -238,6 +241,43 @@ class BookmarkForm(forms.ModelForm):
         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):
 
     fieldsets = (
@@ -329,6 +369,18 @@ class EventRuleForm(NetBoxModelForm):
             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):
         super().__init__(*args, **kwargs)
         self.fields['action_object_type'].required = False
@@ -341,6 +393,8 @@ class EventRuleForm(NetBoxModelForm):
             self.init_webhook_choice()
         elif action_type == EventRuleActionChoices.SCRIPT:
             self.init_script_choice()
+        elif action_type == EventRuleActionChoices.NOTIFICATION:
+            self.init_notificationgroup_choice()
 
     def clean(self):
         super().clean()
@@ -357,6 +411,10 @@ class EventRuleForm(NetBoxModelForm):
                 for_concrete_model=False
             )
             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
 

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

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

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

@@ -54,6 +54,21 @@ class ExtrasQuery:
         return models.JournalEntry.objects.get(pk=id)
     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
     def tag(self, id: int) -> TagType:
         return models.Tag.objects.get(pk=id)

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

@@ -18,7 +18,10 @@ __all__ = (
     'ExportTemplateType',
     'ImageAttachmentType',
     'JournalEntryType',
+    'NotificationGroupType',
+    'NotificationType',
     'SavedFilterType',
+    'SubscriptionType',
     'TagType',
     'WebhookType',
 )
@@ -122,6 +125,23 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
     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(
     models.SavedFilter,
     exclude=['content_types',],
@@ -131,6 +151,14 @@ class SavedFilterType(ObjectType):
     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(
     models.Tag,
     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 .dashboard import *
 from .models import *
+from .notifications import *
 from .scripts import *
 from .search 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.querysets import RestrictedQuerySet
 
+__all__ = (
+    'ConfigContextModelQuerySet',
+    'ConfigContextQuerySet',
+    'NotificationQuerySet',
+)
+
 
 class ConfigContextQuerySet(RestrictedQuerySet):
 
@@ -145,3 +151,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
 
         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 core.choices import ObjectChangeActionChoices
+from core.events import *
 from core.models import ObjectChange, ObjectType
 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.models import EventRule
+from extras.models import EventRule, Notification, Subscription
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
+from netbox.registry import registry
 from netbox.signals import post_clean
 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 .validators import CustomValidator
 
@@ -72,17 +73,22 @@ def handle_changed_object(sender, instance, **kwargs):
 
     # Determine the type of change being made
     if kwargs.get('created'):
-        action = ObjectChangeActionChoices.ACTION_CREATE
+        event_type = OBJECT_CREATED
     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']:
         # m2m_changed with objects added or removed
         m2m_changed = True
-        action = ObjectChangeActionChoices.ACTION_UPDATE
+        event_type = OBJECT_UPDATED
     else:
         return
 
     # 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)
     # 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
@@ -106,13 +112,13 @@ def handle_changed_object(sender, instance, **kwargs):
 
     # Enqueue the object for event processing
     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)
 
     # Increment metric counters
-    if action == ObjectChangeActionChoices.ACTION_CREATE:
+    if event_type == OBJECT_CREATED:
         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()
 
 
@@ -168,7 +174,7 @@ def handle_deleted_object(sender, instance, **kwargs):
 
     # Enqueue the object for event processing
     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)
 
     # 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)
     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)
@@ -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)
     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 netbox.constants import EMPTY_TABLE_TEXT
 from netbox.tables import BaseTable, NetBoxTable, columns
+from .columns import NotificationActionsColumn
 
 __all__ = (
     'BookmarkTable',
@@ -19,21 +20,28 @@ __all__ = (
     'ExportTemplateTable',
     'ImageAttachmentTable',
     'JournalEntryTable',
+    'NotificationGroupTable',
+    'NotificationTable',
     'SavedFilterTable',
     'ReportResultsTable',
     'ScriptResultsTable',
+    'SubscriptionTable',
     'TaggedItemTable',
     'TagTable',
     'WebhookTable',
 )
 
-IMAGEATTACHMENT_IMAGE = '''
+IMAGEATTACHMENT_IMAGE = """
 {% if record.image %}
   <a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
 {% else %}
   &mdash;
 {% endif %}
-'''
+"""
+
+NOTIFICATION_ICON = """
+<span class="text-{{ value.color }} fs-3"><i class="{{ value.icon }}"></i></span>
+"""
 
 
 class CustomFieldTable(NetBoxTable):
@@ -263,6 +271,93 @@ class BookmarkTable(NetBoxTable):
         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):
     name = tables.Column(
         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 core.choices import ManagedFileRootPathChoices
+from core.events import *
 from core.models import ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.choices import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
+from users.models import Group, User
 from utilities.testing import APITestCase, APIViewTestCases
 
-User = get_user_model()
-
 
 class AppTest(APITestCase):
 
@@ -890,3 +890,196 @@ class ObjectTypeTest(APITestCase):
 
         url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
         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 rest_framework import status
 
-from core.choices import ObjectChangeActionChoices
+from core.events import *
 from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 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.webhooks import generate_signature, send_webhook
 from netbox.context_managers import event_tracking
@@ -132,7 +132,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         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['data']['id'], response.data['id'])
         self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
@@ -182,7 +182,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 3)
         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'], ObjectChangeActionChoices.ACTION_CREATE)
+            self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
             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)
         job = self.queue.jobs[0]
         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['data']['id'], site.pk)
         self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
@@ -269,7 +269,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 3)
         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'], ObjectChangeActionChoices.ACTION_UPDATE)
+            self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
             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)
         job = self.queue.jobs[0]
         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['data']['id'], site.pk)
         self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
@@ -328,7 +328,7 @@ class EventRuleTest(APITestCase):
         self.assertEqual(self.queue.count, 3)
         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'], ObjectChangeActionChoices.ACTION_DELETE)
+            self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
             self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
@@ -365,12 +365,12 @@ class EventRuleTest(APITestCase):
         # Enqueue a webhook for processing
         webhooks_queue = {}
         site = Site.objects.create(name='Site 1', slug='site-1')
-        enqueue_object(
+        enqueue_event(
             webhooks_queue,
             instance=site,
             user=self.user,
             request_id=request_id,
-            action=ObjectChangeActionChoices.ACTION_CREATE
+            event_type=OBJECT_CREATED
         )
         flush_events(list(webhooks_queue.values()))
 
@@ -378,7 +378,7 @@ class EventRuleTest(APITestCase):
         job = self.queue.jobs[0]
 
         # 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)
 
     def test_duplicate_triggers(self):
@@ -399,7 +399,7 @@ class EventRuleTest(APITestCase):
             site.save()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         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()
 
         # Test multiple updates
@@ -411,7 +411,7 @@ class EventRuleTest(APITestCase):
             site.save()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         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()
 
         # Test update & delete
@@ -422,5 +422,5 @@ class EventRuleTest(APITestCase):
             site.delete()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
         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()

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

@@ -1,7 +1,6 @@
 import uuid
 from datetime import datetime, timezone
 
-from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
@@ -15,13 +14,11 @@ from extras.choices import *
 from extras.filtersets import *
 from extras.models import *
 from tenancy.models import Tenant, TenantGroup
+from users.models import Group, User
 from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
-User = get_user_model()
-
-
 class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomField.objects.all()
     filterset = CustomFieldFilterSet
@@ -1370,3 +1367,65 @@ class ChangeLoggedFilterSetTestCase(TestCase):
         params = {'modified_by_request': self.create_update_request_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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.urls import reverse
 
@@ -6,10 +5,9 @@ from core.models import ObjectType
 from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.models import *
+from users.models import Group, User
 from utilities.testing import ViewTestCases, TestCase
 
-User = get_user_model()
-
 
 class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = CustomField
@@ -620,3 +618,166 @@ class CustomLinkTest(TestCase):
         response = self.client.get(site.get_absolute_url(), follow=True)
         self.assertEqual(response.status_code, 200)
         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/<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
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.views.generic import View
 
@@ -356,6 +357,139 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
         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
 #

+ 2 - 2
netbox/extras/webhooks.py

@@ -25,7 +25,7 @@ def generate_signature(request_body, secret):
 
 
 @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
     """
@@ -33,7 +33,7 @@ def send_webhook(event_rule, model_name, event, data, timestamp, username, reque
 
     # Prepare context data for headers & body templates
     context = {
-        'event': WEBHOOK_EVENT_TYPES[event],
+        'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type),
         'timestamp': timestamp,
         'model': model_name,
         '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,
     ExportTemplatesMixin,
     JournalingMixin,
+    NotificationsMixin,
     TagsMixin,
     EventRulesMixin
 ):

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

@@ -34,6 +34,7 @@ __all__ = (
     'ImageAttachmentsMixin',
     'JobsMixin',
     'JournalingMixin',
+    'NotificationsMixin',
     'SyncedDataMixin',
     'TagsMixin',
     'register_models',
@@ -377,6 +378,25 @@ class BookmarksMixin(models.Model):
         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):
     """
     Enables support for job results.
@@ -582,13 +602,14 @@ FEATURES_MAP = {
     'custom_fields': CustomFieldsMixin,
     'custom_links': CustomLinksMixin,
     'custom_validation': CustomValidationMixin,
+    'event_rules': EventRulesMixin,
     'export_templates': ExportTemplatesMixin,
     'image_attachments': ImageAttachmentsMixin,
     'jobs': JobsMixin,
     'journaling': JournalingMixin,
+    'notifications': NotificationsMixin,
     'synced_data': SyncedDataMixin,
     'tags': TagsMixin,
-    'event_rules': EventRulesMixin,
 }
 
 registry['model_features'].update({

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

@@ -355,6 +355,7 @@ OPERATIONS_MENU = Menu(
         MenuGroup(
             label=_('Logging'),
             items=(
+                get_model_item('extras', 'notificationgroup', _('Notification Groups')),
                 get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
                 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),
     'data_backends': dict(),
     'denormalized_fields': collections.defaultdict(list),
+    'events': dict(),
     'model_features': dict(),
     'models': collections.defaultdict(set),
     'plugins': dict(),

+ 10 - 0
netbox/netbox/settings.py

@@ -84,6 +84,16 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
     'extras.add_bookmark': ({'user': '$user'},),
     'extras.change_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
     'users.view_token': ({'user': '$user'},),
     'users.add_token': ({'user': '$user'},),

Разница между файлами не показана из-за своего большого размера
+ 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/markdown';
 @import 'custom/misc';
+@import 'custom/notifications';

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

@@ -9,6 +9,12 @@
     <li role="presentation" class="nav-item">
       <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
     </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">
       <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
     </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 %}
         {% bookmark_button object %}
       {% endif %}
+      {% if perms.extras.add_subscription and object.subscriptions %}
+        {% subscribe_button object %}
+      {% endif %}
       {% if request.user|can_add:object %}
         {% clone_button object %}
       {% 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 %}
 
 {% 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">
     <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">
@@ -29,6 +40,9 @@
       <a href="{% url 'account:bookmarks' %}" class="dropdown-item">
         <i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
       </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">
         <i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
       </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 core.models import ObjectType
+from extras.models import NotificationGroup
 from netbox.filtersets import BaseFilterSet
 from users.models import Group, ObjectPermission, Token
 from utilities.filters import ContentTypeFilter
@@ -32,6 +33,11 @@ class GroupFilterSet(BaseFilterSet):
         queryset=ObjectPermission.objects.all(),
         label=_('Permission (ID)'),
     )
+    notification_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='notification_groups',
+        queryset=NotificationGroup.objects.all(),
+        label=_('Notification group (ID)'),
+    )
 
     class Meta:
         model = Group
@@ -67,6 +73,11 @@ class UserFilterSet(BaseFilterSet):
         queryset=ObjectPermission.objects.all(),
         label=_('Permission (ID)'),
     )
+    notification_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='notification_groups',
+        queryset=NotificationGroup.objects.all(),
+        label=_('Notification group (ID)'),
+    )
 
     class Meta:
         model = get_user_model()

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

@@ -10,7 +10,7 @@
     </button>
   {% else %}
     <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>
   {% endif %}
 </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 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.views import get_viewname
 
@@ -17,6 +18,7 @@ __all__ = (
     'edit_button',
     'export_button',
     'import_button',
+    'subscribe_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')
 def sync_button(instance):
     viewname = get_viewname(instance, 'sync')

Некоторые файлы не были показаны из-за большого количества измененных файлов