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
This commit is contained in:
Jeremy Stretch 2024-07-15 14:24:11 -04:00 committed by GitHub
parent 1c2336be60
commit b0e7294bc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 1912 additions and 89 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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:

View File

@ -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'),

View File

@ -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
#

View File

@ -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
netbox/core/events.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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 *

View File

@ -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

View File

@ -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)

View File

@ -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
#

View File

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

View File

@ -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

View File

@ -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']
)

View File

@ -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',

View File

@ -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',)

View File

@ -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')

View File

@ -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')
)

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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', ],

View File

@ -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'),
),
]

View File

@ -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 *

View File

@ -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)
)

View File

@ -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)

View File

@ -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
])

View File

@ -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'),
}

View File

@ -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'),

View File

@ -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,
},
]

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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'),

View File

@ -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
#

View File

@ -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
netbox/netbox/events.py Normal file
View File

@ -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)

View File

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

View File

@ -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({

View File

@ -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=[]),
),

View File

@ -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(),

View File

@ -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'},),

Binary file not shown.

View File

@ -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;
}
}

View File

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

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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' %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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()

View File

@ -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>

View File

@ -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 %}

View File

@ -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')