Skip to content

Commit

Permalink
Closes #15621: User notifications (#16800)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jeremystretch authored Jul 15, 2024
1 parent 1c2336b commit b0e7294
Show file tree
Hide file tree
Showing 59 changed files with 1,913 additions and 90 deletions.
17 changes: 17 additions & 0 deletions docs/models/extras/notification.md
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions docs/models/extras/notificationgroup.md
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions docs/models/extras/subscription.md
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions netbox/account/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
# 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'),
Expand Down
32 changes: 31 additions & 1 deletion netbox/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
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
Expand Down Expand Up @@ -267,6 +267,36 @@ def get_extra_context(self, request):
}


#
# 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
#
Expand Down
2 changes: 1 addition & 1 deletion netbox/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 33 additions & 0 deletions netbox/core/events.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 0 additions & 1 deletion netbox/core/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
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
Expand Down
1 change: 1 addition & 0 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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 *
Expand Down
82 changes: 82 additions & 0 deletions netbox/extras/api/serializers_/notifications.py
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
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)
Expand Down
21 changes: 21 additions & 0 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
2 changes: 2 additions & 0 deletions netbox/extras/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet):

WEBHOOK = 'webhook'
SCRIPT = 'script'
NOTIFICATION = 'notification'

CHOICES = (
(WEBHOOK, _('Webhook')),
(SCRIPT, _('Script')),
(NOTIFICATION, _('Notification')),
)
21 changes: 9 additions & 12 deletions netbox/extras/constants.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
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, '', [])

# Webhooks
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
Expand Down
Loading

0 comments on commit b0e7294

Please sign in to comment.