diff --git a/docs/models/extras/notification.md b/docs/models/extras/notification.md new file mode 100644 index 000000000..e72a35bec --- /dev/null +++ b/docs/models/extras/notification.md @@ -0,0 +1,17 @@ +# Notification + +A notification alerts a user that a specific action has taken place in NetBox, such as an object being modified or a background job completing. A notification may be generated via a user's [subscription](./subscription.md) to a particular object, or by an event rule targeting a [notification group](./notificationgroup.md) of which the user is a member. + +## Fields + +### User + +The recipient of the notification. + +### Object + +The object to which the notification relates. + +### Event Type + +The type of event indicated by the notification. diff --git a/docs/models/extras/notificationgroup.md b/docs/models/extras/notificationgroup.md new file mode 100644 index 000000000..6463d137a --- /dev/null +++ b/docs/models/extras/notificationgroup.md @@ -0,0 +1,17 @@ +# Notification Group + +A set of NetBox users and/or groups of users identified as recipients for certain [notifications](./notification.md). + +## Fields + +### Name + +The name of the notification group. + +### Users + +One or more users directly designated as members of the notification group. + +### Groups + +All users of any selected groups are considered as members of the notification group. diff --git a/docs/models/extras/subscription.md b/docs/models/extras/subscription.md new file mode 100644 index 000000000..3fc4a1f11 --- /dev/null +++ b/docs/models/extras/subscription.md @@ -0,0 +1,15 @@ +# Subscription + +A record indicating that a user is to be notified of any changes to a particular NetBox object. A notification maps exactly one user to exactly one object. + +When an object to which a user is subscribed changes, a [notification](./notification.md) is generated for the user. + +## Fields + +### User + +The subscribed user. + +### Object + +The object to which the user is subscribed. diff --git a/mkdocs.yml b/mkdocs.yml index f90ef4dbe..4aab9d743 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -225,8 +225,11 @@ nav: - ExportTemplate: 'models/extras/exporttemplate.md' - ImageAttachment: 'models/extras/imageattachment.md' - JournalEntry: 'models/extras/journalentry.md' + - Notification: 'models/extras/notification.md' + - NotificationGroup: 'models/extras/notificationgroup.md' - SavedFilter: 'models/extras/savedfilter.md' - StagedChange: 'models/extras/stagedchange.md' + - Subscription: 'models/extras/subscription.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' - IPAM: diff --git a/netbox/account/urls.py b/netbox/account/urls.py index 1276dce40..d74677599 100644 --- a/netbox/account/urls.py +++ b/netbox/account/urls.py @@ -9,6 +9,8 @@ urlpatterns = [ # Account views path('profile/', views.ProfileView.as_view(), name='profile'), path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), + path('notifications/', views.NotificationListView.as_view(), name='notifications'), + path('subscriptions/', views.SubscriptionListView.as_view(), name='subscriptions'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'), diff --git a/netbox/account/views.py b/netbox/account/views.py index eabcfab51..a36d3380a 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -22,7 +22,7 @@ from account.models import UserToken from core.models import ObjectChange from core.tables import ObjectChangeTable from extras.models import Bookmark -from extras.tables import BookmarkTable +from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from netbox.views import generic @@ -267,6 +267,36 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView): } +# +# Notifications & subscriptions +# + +class NotificationListView(LoginRequiredMixin, generic.ObjectListView): + table = NotificationTable + template_name = 'account/notifications.html' + + def get_queryset(self, request): + return request.user.notifications.all() + + def get_extra_context(self, request): + return { + 'active_tab': 'notifications', + } + + +class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView): + table = SubscriptionTable + template_name = 'account/subscriptions.html' + + def get_queryset(self, request): + return request.user.subscriptions.all() + + def get_extra_context(self, request): + return { + 'active_tab': 'subscriptions', + } + + # # User views for token management # diff --git a/netbox/core/apps.py b/netbox/core/apps.py index b1103469c..855ac3170 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -18,7 +18,7 @@ class CoreConfig(AppConfig): def ready(self): from core.api import schema # noqa from netbox.models.features import register_models - from . import data_backends, search + from . import data_backends, events, search # Register models register_models(*self.get_models()) diff --git a/netbox/core/events.py b/netbox/core/events.py new file mode 100644 index 000000000..60c9a34a0 --- /dev/null +++ b/netbox/core/events.py @@ -0,0 +1,33 @@ +from django.utils.translation import gettext as _ + +from netbox.events import * + +__all__ = ( + 'JOB_COMPLETED', + 'JOB_ERRORED', + 'JOB_FAILED', + 'JOB_STARTED', + 'OBJECT_CREATED', + 'OBJECT_DELETED', + 'OBJECT_UPDATED', +) + +# Object events +OBJECT_CREATED = 'object_created' +OBJECT_UPDATED = 'object_updated' +OBJECT_DELETED = 'object_deleted' + +# Job events +JOB_STARTED = 'job_started' +JOB_COMPLETED = 'job_completed' +JOB_FAILED = 'job_failed' +JOB_ERRORED = 'job_errored' + +# Register core events +Event(name=OBJECT_CREATED, text=_('Object created')).register() +Event(name=OBJECT_UPDATED, text=_('Object updated')).register() +Event(name=OBJECT_DELETED, text=_('Object deleted')).register() +Event(name=JOB_STARTED, text=_('Job started')).register() +Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register() +Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register() +Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register() diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index b9f0d0b91..c5fbb918c 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -13,7 +13,6 @@ from django.utils.translation import gettext as _ from core.choices import JobStatusChoices from core.models import ObjectType from core.signals import job_end, job_start -from extras.constants import EVENT_JOB_END, EVENT_JOB_START from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from utilities.querysets import RestrictedQuerySet diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index ddd13815a..f1b0e0894 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -7,6 +7,7 @@ from .serializers_.dashboard import * from .serializers_.events import * from .serializers_.exporttemplates import * from .serializers_.journaling import * +from .serializers_.notifications import * from .serializers_.configcontexts import * from .serializers_.configtemplates import * from .serializers_.savedfilters import * diff --git a/netbox/extras/api/serializers_/notifications.py b/netbox/extras/api/serializers_/notifications.py new file mode 100644 index 000000000..62e1a8d63 --- /dev/null +++ b/netbox/extras/api/serializers_/notifications.py @@ -0,0 +1,82 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import Notification, NotificationGroup, Subscription +from netbox.api.fields import ContentTypeField, SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer +from users.api.serializers_.users import GroupSerializer, UserSerializer +from users.models import Group, User +from utilities.api import get_serializer_for_model + +__all__ = ( + 'NotificationSerializer', + 'NotificationGroupSerializer', + 'SubscriptionSerializer', +) + + +class NotificationSerializer(ValidatedModelSerializer): + object_type = ContentTypeField( + queryset=ObjectType.objects.with_feature('notifications'), + ) + object = serializers.SerializerMethodField(read_only=True) + user = UserSerializer(nested=True) + + class Meta: + model = Notification + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'read', 'event_type', + ] + brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user', 'read', 'event_type') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_object(self, instance): + serializer = get_serializer_for_model(instance.object) + context = {'request': self.context['request']} + return serializer(instance.object, nested=True, context=context).data + + +class NotificationGroupSerializer(ValidatedModelSerializer): + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=GroupSerializer, + nested=True, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=UserSerializer, + nested=True, + required=False, + many=True + ) + + class Meta: + model = NotificationGroup + fields = [ + 'id', 'url', 'display', 'display_url', 'name', 'description', 'groups', 'users', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class SubscriptionSerializer(ValidatedModelSerializer): + object_type = ContentTypeField( + queryset=ObjectType.objects.with_feature('notifications'), + ) + object = serializers.SerializerMethodField(read_only=True) + user = UserSerializer(nested=True) + + class Meta: + model = Subscription + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', + ] + brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_object(self, instance): + serializer = get_serializer_for_model(instance.object) + context = {'request': self.context['request']} + return serializer(instance.object, nested=True, context=context).data diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bc68103b7..bbcb8f0ef 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -15,6 +15,9 @@ router.register('custom-links', views.CustomLinkViewSet) router.register('export-templates', views.ExportTemplateViewSet) router.register('saved-filters', views.SavedFilterViewSet) router.register('bookmarks', views.BookmarkViewSet) +router.register('notifications', views.NotificationViewSet) +router.register('notification-groups', views.NotificationGroupViewSet) +router.register('subscriptions', views.SubscriptionViewSet) router.register('tags', views.TagViewSet) router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 34565384b..2369e8f10 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -140,6 +140,27 @@ class BookmarkViewSet(NetBoxModelViewSet): filterset_class = filtersets.BookmarkFilterSet +# +# Notifications & subscriptions +# + +class NotificationViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = Notification.objects.all() + serializer_class = serializers.NotificationSerializer + + +class NotificationGroupViewSet(NetBoxModelViewSet): + queryset = NotificationGroup.objects.all() + serializer_class = serializers.NotificationGroupSerializer + + +class SubscriptionViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = Subscription.objects.all() + serializer_class = serializers.SubscriptionSerializer + + # # Tags # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 8959ba0ab..387716c85 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet): WEBHOOK = 'webhook' SCRIPT = 'script' + NOTIFICATION = 'notification' CHOICES = ( (WEBHOOK, _('Webhook')), (SCRIPT, _('Script')), + (NOTIFICATION, _('Notification')), ) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 7162299e7..e8e2c6d8a 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,12 +1,6 @@ +from core.events import * from extras.choices import LogLevelChoices -# Events -EVENT_CREATE = 'create' -EVENT_UPDATE = 'update' -EVENT_DELETE = 'delete' -EVENT_JOB_START = 'job_start' -EVENT_JOB_END = 'job_end' - # Custom fields CUSTOMFIELD_EMPTY_VALUES = (None, '', []) @@ -14,11 +8,14 @@ CUSTOMFIELD_EMPTY_VALUES = (None, '', []) HTTP_CONTENT_TYPE_JSON = 'application/json' WEBHOOK_EVENT_TYPES = { - EVENT_CREATE: 'created', - EVENT_UPDATE: 'updated', - EVENT_DELETE: 'deleted', - EVENT_JOB_START: 'job_started', - EVENT_JOB_END: 'job_ended', + # Map registered event types to public webhook "event" equivalents + OBJECT_CREATED: 'created', + OBJECT_UPDATED: 'updated', + OBJECT_DELETED: 'deleted', + JOB_STARTED: 'job_started', + JOB_COMPLETED: 'job_ended', + JOB_FAILED: 'job_ended', + JOB_ERRORED: 'job_ended', } # Dashboard diff --git a/netbox/extras/events.py b/netbox/extras/events.py index 0b40116e8..9cf3220d0 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -8,7 +8,7 @@ from django.utils.module_loading import import_string from django.utils.translation import gettext as _ from django_rq import get_queue -from core.choices import ObjectChangeActionChoices +from core.events import * from core.models import Job from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT @@ -35,12 +35,12 @@ def serialize_for_event(instance): return serializer.data -def get_snapshots(instance, action): +def get_snapshots(instance, event_type): snapshots = { 'prechange': getattr(instance, '_prechange_snapshot', None), 'postchange': None, } - if action != ObjectChangeActionChoices.ACTION_DELETE: + if event_type != OBJECT_DELETED: # Use model's serialize_object() method if defined; fall back to serialize_object() utility function if hasattr(instance, 'serialize_object'): snapshots['postchange'] = instance.serialize_object() @@ -50,7 +50,7 @@ def get_snapshots(instance, action): return snapshots -def enqueue_object(queue, instance, user, request_id, action): +def enqueue_event(queue, instance, user, request_id, event_type): """ Enqueue a serialized representation of a created/updated/deleted object for the processing of events once the request has completed. @@ -65,27 +65,24 @@ def enqueue_object(queue, instance, user, request_id, action): key = f'{app_label}.{model_name}:{instance.pk}' if key in queue: queue[key]['data'] = serialize_for_event(instance) - queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange'] + queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange'] # If the object is being deleted, update any prior "update" event to "delete" - if action == ObjectChangeActionChoices.ACTION_DELETE: - queue[key]['event'] = action + if event_type == OBJECT_DELETED: + queue[key]['event_type'] = event_type else: queue[key] = { - 'content_type': ContentType.objects.get_for_model(instance), + 'object_type': ContentType.objects.get_for_model(instance), 'object_id': instance.pk, - 'event': action, + 'event_type': event_type, 'data': serialize_for_event(instance), - 'snapshots': get_snapshots(instance, action), + 'snapshots': get_snapshots(instance, event_type), 'username': user.username, 'request_id': request_id } -def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None): - if username: - user = get_user_model().objects.get(username=username) - else: - user = None +def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None): + user = get_user_model().objects.get(username=username) if username else None for event_rule in event_rules: @@ -103,8 +100,8 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna # Compile the task parameters params = { "event_rule": event_rule, - "model_name": model_name, - "event": event, + "model_name": object_type.model, + "event_type": event_type, "data": data, "snapshots": snapshots, "timestamp": timezone.now().isoformat(), @@ -136,6 +133,15 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna data=data ) + # Notification groups + elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION: + # Bulk-create notifications for all members of the notification group + event_rule.action_object.notify( + object_type=object_type, + object_id=data['id'], + event_type=event_type + ) + else: raise ValueError(_("Unknown action type for an event rule: {action_type}").format( action_type=event_rule.action_type @@ -151,27 +157,39 @@ def process_event_queue(events): 'type_update': {}, 'type_delete': {}, } + event_actions = { + # TODO: Add EventRule support for dynamically registered event types + OBJECT_CREATED: 'type_create', + OBJECT_UPDATED: 'type_update', + OBJECT_DELETED: 'type_delete', + JOB_STARTED: 'type_job_start', + JOB_COMPLETED: 'type_job_end', + # Map failed & errored jobs to type_job_end + JOB_FAILED: 'type_job_end', + JOB_ERRORED: 'type_job_end', + } - for data in events: - action_flag = { - ObjectChangeActionChoices.ACTION_CREATE: 'type_create', - ObjectChangeActionChoices.ACTION_UPDATE: 'type_update', - ObjectChangeActionChoices.ACTION_DELETE: 'type_delete', - }[data['event']] - content_type = data['content_type'] + for event in events: + action_flag = event_actions[event['event_type']] + object_type = event['object_type'] # Cache applicable Event Rules - if content_type not in events_cache[action_flag]: - events_cache[action_flag][content_type] = EventRule.objects.filter( + if object_type not in events_cache[action_flag]: + events_cache[action_flag][object_type] = EventRule.objects.filter( **{action_flag: True}, - object_types=content_type, + object_types=object_type, enabled=True ) - event_rules = events_cache[action_flag][content_type] + event_rules = events_cache[action_flag][object_type] process_event_rules( - event_rules, content_type.model, data['event'], data['data'], data['username'], - snapshots=data['snapshots'], request_id=data['request_id'] + event_rules=event_rules, + object_type=object_type, + event_type=event['event_type'], + data=event['data'], + username=event['username'], + snapshots=event['snapshots'], + request_id=event['request_id'] ) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf0275c2d..f34270f07 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -8,6 +8,7 @@ from core.models import DataSource, ObjectType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup +from users.models import Group, User from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from virtualization.models import Cluster, ClusterGroup, ClusterType from .choices import * @@ -26,6 +27,7 @@ __all__ = ( 'ImageAttachmentFilterSet', 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', + 'NotificationGroupFilterSet', 'ObjectTypeFilterSet', 'SavedFilterFilterSet', 'ScriptFilterSet', @@ -336,6 +338,49 @@ class BookmarkFilterSet(BaseFilterSet): fields = ('id', 'object_id') +class NotificationGroupFilterSet(ChangeLoggedModelFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + user_id = django_filters.ModelMultipleChoiceFilter( + field_name='users', + queryset=User.objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='users__username', + queryset=User.objects.all(), + to_field_name='username', + label=_('User (name)'), + ) + group_id = django_filters.ModelMultipleChoiceFilter( + field_name='groups', + queryset=Group.objects.all(), + label=_('Group (ID)'), + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label=_('Group (name)'), + ) + + class Meta: + model = NotificationGroup + fields = ( + 'id', 'name', 'description', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index acb564b30..f785eaaf4 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -18,6 +18,7 @@ __all__ = ( 'EventRuleBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', + 'NotificationGroupBulkEditForm', 'SavedFilterBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', @@ -343,3 +344,17 @@ class JournalEntryBulkEditForm(BulkEditForm): required=False ) comments = CommentField() + + +class NotificationGroupBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=NotificationGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + nullable_fields = ('description',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 15966c07e..2ebba365a 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -3,16 +3,17 @@ import re from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from extras.choices import * from extras.models import * from netbox.forms import NetBoxModelImportForm +from users.models import Group, User from utilities.forms import CSVModelForm from utilities.forms.fields import ( - CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField, + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField, + SlugField, ) __all__ = ( @@ -23,6 +24,7 @@ __all__ = ( 'EventRuleImportForm', 'ExportTemplateImportForm', 'JournalEntryImportForm', + 'NotificationGroupImportForm', 'SavedFilterImportForm', 'TagImportForm', 'WebhookImportForm', @@ -247,3 +249,24 @@ class JournalEntryImportForm(NetBoxModelImportForm): fields = ( 'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags' ) + + +class NotificationGroupImportForm(CSVModelForm): + users = CSVModelMultipleChoiceField( + label=_('Users'), + queryset=User.objects.all(), + required=False, + to_field_name='username', + help_text=_('User names separated by commas, encased with double quotes') + ) + groups = CSVModelMultipleChoiceField( + label=_('Groups'), + queryset=Group.objects.all(), + required=False, + to_field_name='name', + help_text=_('Group names separated by commas, encased with double quotes') + ) + + class Meta: + model = NotificationGroup + fields = ('name', 'description', 'users', 'groups') diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index e29fd549d..a446af632 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -9,6 +9,7 @@ from extras.models import * from netbox.forms.base import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from tenancy.models import Tenant, TenantGroup +from users.models import Group from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ( ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, @@ -28,6 +29,7 @@ __all__ = ( 'ImageAttachmentFilterForm', 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', + 'NotificationGroupFilterForm', 'SavedFilterFilterForm', 'TagFilterForm', 'WebhookFilterForm', @@ -496,3 +498,16 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): required=False ) tag = TagFilterField(model) + + +class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm): + user_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label=_('User') + ) + group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Group') + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 19823e9a4..a8406b671 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -12,6 +12,7 @@ from extras.choices import * from extras.models import * from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup +from users.models import Group, User from utilities.forms import add_blank_choice, get_field_value from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, @@ -32,7 +33,9 @@ __all__ = ( 'ExportTemplateForm', 'ImageAttachmentForm', 'JournalEntryForm', + 'NotificationGroupForm', 'SavedFilterForm', + 'SubscriptionForm', 'TagForm', 'WebhookForm', ) @@ -238,6 +241,43 @@ class BookmarkForm(forms.ModelForm): fields = ('object_type', 'object_id') +class NotificationGroupForm(forms.ModelForm): + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), + required=False, + queryset=Group.objects.all() + ) + users = DynamicModelMultipleChoiceField( + label=_('Users'), + required=False, + queryset=User.objects.all() + ) + + class Meta: + model = NotificationGroup + fields = ('name', 'description', 'groups', 'users') + + def clean(self): + super().clean() + + # At least one User or Group must be assigned + if not self.cleaned_data['groups'] and not self.cleaned_data['users']: + raise forms.ValidationError(_("A notification group specify at least one user or group.")) + + return self.cleaned_data + + +class SubscriptionForm(forms.ModelForm): + object_type = ContentTypeChoiceField( + label=_('Object type'), + queryset=ObjectType.objects.with_feature('notifications') + ) + + class Meta: + model = Subscription + fields = ('object_type', 'object_id') + + class WebhookForm(NetBoxModelForm): fieldsets = ( @@ -329,6 +369,18 @@ class EventRuleForm(NetBoxModelForm): initial=initial ) + def init_notificationgroup_choice(self): + initial = None + if self.instance.action_type == EventRuleActionChoices.NOTIFICATION: + notificationgroup_id = get_field_value(self, 'action_object_id') + initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None + self.fields['action_choice'] = DynamicModelChoiceField( + label=_('Notification group'), + queryset=NotificationGroup.objects.all(), + required=True, + initial=initial + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['action_object_type'].required = False @@ -341,6 +393,8 @@ class EventRuleForm(NetBoxModelForm): self.init_webhook_choice() elif action_type == EventRuleActionChoices.SCRIPT: self.init_script_choice() + elif action_type == EventRuleActionChoices.NOTIFICATION: + self.init_notificationgroup_choice() def clean(self): super().clean() @@ -357,6 +411,10 @@ class EventRuleForm(NetBoxModelForm): for_concrete_model=False ) self.cleaned_data['action_object_id'] = action_choice.id + # Notification + elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION: + self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice) + self.cleaned_data['action_object_id'] = action_choice.id return self.cleaned_data diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 7451eef8a..ff2e6a0f1 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -13,6 +13,7 @@ __all__ = ( 'ExportTemplateFilter', 'ImageAttachmentFilter', 'JournalEntryFilter', + 'NotificationGroupFilter', 'SavedFilterFilter', 'TagFilter', 'WebhookFilter', @@ -67,6 +68,12 @@ class JournalEntryFilter(BaseFilterMixin): pass +@strawberry_django.filter(models.NotificationGroup, lookups=True) +@autotype_decorator(filtersets.NotificationGroupFilterSet) +class NotificationGroupFilter(BaseFilterMixin): + pass + + @strawberry_django.filter(models.SavedFilter, lookups=True) @autotype_decorator(filtersets.SavedFilterFilterSet) class SavedFilterFilter(BaseFilterMixin): diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index f78285035..7e509c0e0 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -54,6 +54,21 @@ class ExtrasQuery: return models.JournalEntry.objects.get(pk=id) journal_entry_list: List[JournalEntryType] = strawberry_django.field() + @strawberry.field + def notification(self, id: int) -> NotificationType: + return models.Notification.objects.get(pk=id) + notification_list: List[NotificationType] = strawberry_django.field() + + @strawberry.field + def notification_group(self, id: int) -> NotificationGroupType: + return models.NotificationGroup.objects.get(pk=id) + notification_group_list: List[NotificationGroupType] = strawberry_django.field() + + @strawberry.field + def subscription(self, id: int) -> SubscriptionType: + return models.Subscription.objects.get(pk=id) + subscription_list: List[SubscriptionType] = strawberry_django.field() + @strawberry.field def tag(self, id: int) -> TagType: return models.Tag.objects.get(pk=id) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 1f3bfcdb9..a43f80cc3 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -18,7 +18,10 @@ __all__ = ( 'ExportTemplateType', 'ImageAttachmentType', 'JournalEntryType', + 'NotificationGroupType', + 'NotificationType', 'SavedFilterType', + 'SubscriptionType', 'TagType', 'WebhookType', ) @@ -122,6 +125,23 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None +@strawberry_django.type( + models.Notification, + # filters=NotificationFilter +) +class NotificationType(ObjectType): + user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None + + +@strawberry_django.type( + models.NotificationGroup, + filters=NotificationGroupFilter +) +class NotificationGroupType(ObjectType): + users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]] + groups: List[Annotated["GroupType", strawberry.lazy('users.graphql.types')]] + + @strawberry_django.type( models.SavedFilter, exclude=['content_types',], @@ -131,6 +151,14 @@ class SavedFilterType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None +@strawberry_django.type( + models.Subscription, + # filters=NotificationFilter +) +class SubscriptionType(ObjectType): + user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None + + @strawberry_django.type( models.Tag, exclude=['extras_taggeditem_items', ], diff --git a/netbox/extras/migrations/0118_notifications.py b/netbox/extras/migrations/0118_notifications.py new file mode 100644 index 000000000..08904ebb5 --- /dev/null +++ b/netbox/extras/migrations/0118_notifications.py @@ -0,0 +1,78 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0117_customfield_uniqueness'), + ('users', '0009_update_group_perms'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('groups', models.ManyToManyField(blank=True, related_name='notification_groups', to='users.group')), + ('users', models.ManyToManyField(blank=True, related_name='notification_groups', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'notification group', + 'verbose_name_plural': 'notification groups', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('object_id', models.PositiveBigIntegerField()), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'subscription', + 'verbose_name_plural': 'subscriptions', + 'ordering': ('-created', 'user'), + }, + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('read', models.DateTimeField(blank=True, null=True)), + ('object_id', models.PositiveBigIntegerField()), + ('event_type', models.CharField(max_length=50)), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'notification', + 'verbose_name_plural': 'notifications', + 'ordering': ('-created', 'pk'), + 'indexes': [models.Index(fields=['object_type', 'object_id'], name='extras_noti_object__be74d5_idx')], + }, + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_notification_unique_per_object_and_user'), + ), + migrations.AddIndex( + model_name='subscription', + index=models.Index(fields=['object_type', 'object_id'], name='extras_subs_object__37ef68_idx'), + ), + migrations.AddConstraint( + model_name='subscription', + constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_subscription_unique_per_object_and_user'), + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 0413d1b91..e85721034 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -2,6 +2,7 @@ from .configs import * from .customfields import * from .dashboard import * from .models import * +from .notifications import * from .scripts import * from .search import * from .staging import * diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py new file mode 100644 index 000000000..dba059ea7 --- /dev/null +++ b/netbox/extras/models/notifications.py @@ -0,0 +1,222 @@ +from functools import cached_property + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from core.models import ObjectType +from extras.querysets import NotificationQuerySet +from netbox.models import ChangeLoggedModel +from netbox.registry import registry +from users.models import User +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Notification', + 'NotificationGroup', + 'Subscription', +) + + +def get_event_type_choices(): + """ + Compile a list of choices from all registered event types + """ + return [ + (name, event.text) + for name, event in registry['events'].items() + ] + + +class Notification(models.Model): + """ + A notification message for a User relating to a specific object in NetBox. + """ + created = models.DateTimeField( + verbose_name=_('created'), + auto_now_add=True + ) + read = models.DateTimeField( + verbose_name=_('read'), + null=True, + blank=True + ) + user = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='notifications' + ) + object_type = models.ForeignKey( + to='contenttypes.ContentType', + on_delete=models.PROTECT + ) + object_id = models.PositiveBigIntegerField() + object = GenericForeignKey( + ct_field='object_type', + fk_field='object_id' + ) + event_type = models.CharField( + verbose_name=_('event'), + max_length=50, + choices=get_event_type_choices + ) + + objects = NotificationQuerySet.as_manager() + + class Meta: + ordering = ('-created', 'pk') + indexes = ( + models.Index(fields=('object_type', 'object_id')), + ) + constraints = ( + models.UniqueConstraint( + fields=('object_type', 'object_id', 'user'), + name='%(app_label)s_%(class)s_unique_per_object_and_user' + ), + ) + verbose_name = _('notification') + verbose_name_plural = _('notifications') + + def __str__(self): + if self.object: + return str(self.object) + return super().__str__() + + def get_absolute_url(self): + return reverse('account:notifications') + + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ObjectType.objects.with_feature('notifications'): + raise ValidationError( + _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type) + ) + + @cached_property + def event(self): + """ + Returns the registered Event which triggered this Notification. + """ + return registry['events'].get(self.event_type) + + +class NotificationGroup(ChangeLoggedModel): + """ + A collection of users and/or groups to be informed for certain notifications. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + groups = models.ManyToManyField( + to='users.Group', + verbose_name=_('groups'), + blank=True, + related_name='notification_groups' + ) + users = models.ManyToManyField( + to='users.User', + verbose_name=_('users'), + blank=True, + related_name='notification_groups' + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('name',) + verbose_name = _('notification group') + verbose_name_plural = _('notification groups') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:notificationgroup', args=[self.pk]) + + @cached_property + def members(self): + """ + Return all Users who belong to this notification group. + """ + return self.users.union( + User.objects.filter(groups__in=self.groups.all()) + ).order_by('username') + + def notify(self, **kwargs): + """ + Bulk-create Notifications for all members of this group. + """ + Notification.objects.bulk_create([ + Notification(user=member, **kwargs) + for member in self.members + ]) + notify.alters_data = True + + +class Subscription(models.Model): + """ + A User's subscription to a particular object, to be notified of changes. + """ + created = models.DateTimeField( + verbose_name=_('created'), + auto_now_add=True + ) + user = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='subscriptions' + ) + object_type = models.ForeignKey( + to='contenttypes.ContentType', + on_delete=models.PROTECT + ) + object_id = models.PositiveBigIntegerField() + object = GenericForeignKey( + ct_field='object_type', + fk_field='object_id' + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('-created', 'user') + indexes = ( + models.Index(fields=('object_type', 'object_id')), + ) + constraints = ( + models.UniqueConstraint( + fields=('object_type', 'object_id', 'user'), + name='%(app_label)s_%(class)s_unique_per_object_and_user' + ), + ) + verbose_name = _('subscription') + verbose_name_plural = _('subscriptions') + + def __str__(self): + if self.object: + return str(self.object) + return super().__str__() + + def get_absolute_url(self): + return reverse('account:subscriptions') + + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ObjectType.objects.with_feature('notifications'): + raise ValidationError( + _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type) + ) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 3ee9d73e8..9b3722eef 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -5,6 +5,12 @@ from extras.models.tags import TaggedItem from utilities.query_functions import EmptyGroupByJSONBAgg from utilities.querysets import RestrictedQuerySet +__all__ = ( + 'ConfigContextModelQuerySet', + 'ConfigContextQuerySet', + 'NotificationQuerySet', +) + class ConfigContextQuerySet(RestrictedQuerySet): @@ -145,3 +151,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) return base_query + + +class NotificationQuerySet(RestrictedQuerySet): + + def unread(self): + """ + Return only unread notifications. + """ + return self.filter(read__isnull=True) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 53ec39cac..6f63e121e 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -10,17 +10,18 @@ from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates from core.choices import ObjectChangeActionChoices +from core.events import * from core.models import ObjectChange, ObjectType from core.signals import job_end, job_start -from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.events import process_event_rules -from extras.models import EventRule +from extras.models import EventRule, Notification, Subscription from netbox.config import get_config from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin +from netbox.registry import registry from netbox.signals import post_clean from utilities.exceptions import AbortRequest -from .events import enqueue_object, get_snapshots, serialize_for_event +from .events import enqueue_event from .models import CustomField, TaggedItem from .validators import CustomValidator @@ -72,17 +73,22 @@ def handle_changed_object(sender, instance, **kwargs): # Determine the type of change being made if kwargs.get('created'): - action = ObjectChangeActionChoices.ACTION_CREATE + event_type = OBJECT_CREATED elif 'created' in kwargs: - action = ObjectChangeActionChoices.ACTION_UPDATE + event_type = OBJECT_UPDATED elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']: # m2m_changed with objects added or removed m2m_changed = True - action = ObjectChangeActionChoices.ACTION_UPDATE + event_type = OBJECT_UPDATED else: return # Create/update an ObjectChange record for this change + action = { + OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE, + OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE, + OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE, + }[event_type] objectchange = instance.to_objectchange(action) # If this is a many-to-many field change, check for a previous ObjectChange instance recorded # for this object by this request and update it @@ -106,13 +112,13 @@ def handle_changed_object(sender, instance, **kwargs): # Enqueue the object for event processing queue = events_queue.get() - enqueue_object(queue, instance, request.user, request.id, action) + enqueue_event(queue, instance, request.user, request.id, event_type) events_queue.set(queue) # Increment metric counters - if action == ObjectChangeActionChoices.ACTION_CREATE: + if event_type == OBJECT_CREATED: model_inserts.labels(instance._meta.model_name).inc() - elif action == ObjectChangeActionChoices.ACTION_UPDATE: + elif event_type == OBJECT_UPDATED: model_updates.labels(instance._meta.model_name).inc() @@ -168,7 +174,7 @@ def handle_deleted_object(sender, instance, **kwargs): # Enqueue the object for event processing queue = events_queue.get() - enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) + enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED) events_queue.set(queue) # Increment metric counters @@ -270,7 +276,13 @@ def process_job_start_event_rules(sender, **kwargs): """ event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type) username = sender.user.username if sender.user else None - process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username) + process_event_rules( + event_rules=event_rules, + object_type=sender.object_type, + event_type=JOB_STARTED, + data=sender.data, + username=username + ) @receiver(job_end) @@ -280,4 +292,39 @@ def process_job_end_event_rules(sender, **kwargs): """ event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type) username = sender.user.username if sender.user else None - process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username) + process_event_rules( + event_rules=event_rules, + object_type=sender.object_type, + event_type=JOB_COMPLETED, + data=sender.data, + username=username + ) + + +# +# Notifications +# + +@receiver(post_save) +def notify_object_changed(sender, instance, created, raw, **kwargs): + if created or raw: + return + + # Skip unsupported object types + ct = ContentType.objects.get_for_model(instance) + if ct.model not in registry['model_features']['notifications'].get(ct.app_label, []): + return + + # Find all subscribed Users + subscribed_users = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values_list('user', flat=True) + if not subscribed_users: + return + + # Delete any existing Notifications for the object + Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete() + + # Create Notifications for Subscribers + Notification.objects.bulk_create([ + Notification(user_id=user, object=instance, event_type=OBJECT_UPDATED) + for user in subscribed_users + ]) diff --git a/netbox/extras/tables/columns.py b/netbox/extras/tables/columns.py new file mode 100644 index 000000000..9b6aadcbf --- /dev/null +++ b/netbox/extras/tables/columns.py @@ -0,0 +1,13 @@ +from django.utils.translation import gettext as _ + +from netbox.tables.columns import ActionsColumn, ActionsItem + +__all__ = ( + 'NotificationActionsColumn', +) + + +class NotificationActionsColumn(ActionsColumn): + actions = { + 'dismiss': ActionsItem(_('Dismiss'), 'trash-can-outline', 'delete', 'danger'), + } diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d919ff1d5..db4472313 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ from extras.models import * from netbox.constants import EMPTY_TABLE_TEXT from netbox.tables import BaseTable, NetBoxTable, columns +from .columns import NotificationActionsColumn __all__ = ( 'BookmarkTable', @@ -19,21 +20,28 @@ __all__ = ( 'ExportTemplateTable', 'ImageAttachmentTable', 'JournalEntryTable', + 'NotificationGroupTable', + 'NotificationTable', 'SavedFilterTable', 'ReportResultsTable', 'ScriptResultsTable', + 'SubscriptionTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', ) -IMAGEATTACHMENT_IMAGE = ''' +IMAGEATTACHMENT_IMAGE = """ {% if record.image %} {{ record }} {% else %} — {% endif %} -''' +""" + +NOTIFICATION_ICON = """ + +""" 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'), diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 5d243ae1a..a1c75ac28 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -7,15 +7,15 @@ from django.utils.timezone import make_aware from rest_framework import status from core.choices import ManagedFileRootPathChoices +from core.events import * from core.models import ObjectType from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from extras.choices import * from extras.models import * from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar +from users.models import Group, User from utilities.testing import APITestCase, APIViewTestCases -User = get_user_model() - class AppTest(APITestCase): @@ -890,3 +890,196 @@ class ObjectTypeTest(APITestCase): url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk}) self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) + + +class SubscriptionTest(APIViewTestCases.APIViewTestCase): + model = Subscription + brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user'] + + @classmethod + def setUpTestData(cls): + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + User(username='User 4'), + ) + User.objects.bulk_create(users) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + subscriptions = ( + Subscription( + object=sites[0], + user=users[0], + ), + Subscription( + object=sites[1], + user=users[1], + ), + Subscription( + object=sites[2], + user=users[2], + ), + ) + Subscription.objects.bulk_create(subscriptions) + + cls.create_data = [ + { + 'object_type': 'dcim.site', + 'object_id': sites[0].pk, + 'user': users[3].pk, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[1].pk, + 'user': users[3].pk, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[2].pk, + 'user': users[3].pk, + }, + ] + + +class NotificationGroupTest(APIViewTestCases.APIViewTestCase): + model = NotificationGroup + brief_fields = ['description', 'display', 'id', 'name', 'url'] + create_data = [ + { + 'object_types': ['dcim.site'], + 'name': 'Custom Link 4', + 'enabled': True, + 'link_text': 'Link 4', + 'link_url': 'http://example.com/?4', + }, + { + 'object_types': ['dcim.site'], + 'name': 'Custom Link 5', + 'enabled': True, + 'link_text': 'Link 5', + 'link_url': 'http://example.com/?5', + }, + { + 'object_types': ['dcim.site'], + 'name': 'Custom Link 6', + 'enabled': False, + 'link_text': 'Link 6', + 'link_url': 'http://example.com/?6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + notification_groups = ( + NotificationGroup(name='Notification Group 1'), + NotificationGroup(name='Notification Group 2'), + NotificationGroup(name='Notification Group 3'), + ) + NotificationGroup.objects.bulk_create(notification_groups) + for i, notification_group in enumerate(notification_groups): + notification_group.users.add(users[i]) + notification_group.groups.add(groups[i]) + + cls.create_data = [ + { + 'name': 'Notification Group 4', + 'description': 'Foo', + 'users': [users[0].pk], + 'groups': [groups[0].pk], + }, + { + 'name': 'Notification Group 5', + 'description': 'Bar', + 'users': [users[1].pk], + 'groups': [groups[1].pk], + }, + { + 'name': 'Notification Group 6', + 'description': 'Baz', + 'users': [users[2].pk], + 'groups': [groups[2].pk], + }, + ] + + +class NotificationTest(APIViewTestCases.APIViewTestCase): + model = Notification + brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user'] + + @classmethod + def setUpTestData(cls): + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + User(username='User 4'), + ) + User.objects.bulk_create(users) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + notifications = ( + Notification( + object=sites[0], + event_type=OBJECT_CREATED, + user=users[0], + ), + Notification( + object=sites[1], + event_type=OBJECT_UPDATED, + user=users[1], + ), + Notification( + object=sites[2], + event_type=OBJECT_DELETED, + user=users[2], + ), + ) + Notification.objects.bulk_create(notifications) + + cls.create_data = [ + { + 'object_type': 'dcim.site', + 'object_id': sites[0].pk, + 'user': users[3].pk, + 'event_type': OBJECT_CREATED, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[1].pk, + 'user': users[3].pk, + 'event_type': OBJECT_UPDATED, + }, + { + 'object_type': 'dcim.site', + 'object_id': sites[2].pk, + 'user': users[3].pk, + 'event_type': OBJECT_DELETED, + }, + ] diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index e05152e36..ac36ef1d9 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -9,12 +9,12 @@ from django.urls import reverse from requests import Session from rest_framework import status -from core.choices import ObjectChangeActionChoices +from core.events import * from core.models import ObjectType from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import EventRuleActionChoices -from extras.events import enqueue_object, flush_events, serialize_for_event +from extras.events import enqueue_event, flush_events, serialize_for_event from extras.models import EventRule, Tag, Webhook from extras.webhooks import generate_signature, send_webhook from netbox.context_managers import event_tracking @@ -132,7 +132,7 @@ class EventRuleTest(APITestCase): self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True)) - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) self.assertEqual(job.kwargs['model_name'], 'site') self.assertEqual(job.kwargs['data']['id'], response.data['id']) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) @@ -182,7 +182,7 @@ class EventRuleTest(APITestCase): self.assertEqual(self.queue.count, 3) for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True)) - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) self.assertEqual(job.kwargs['model_name'], 'site') self.assertEqual(job.kwargs['data']['id'], response.data[i]['id']) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) @@ -213,7 +213,7 @@ class EventRuleTest(APITestCase): self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True)) - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) self.assertEqual(job.kwargs['model_name'], 'site') self.assertEqual(job.kwargs['data']['id'], site.pk) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) @@ -269,7 +269,7 @@ class EventRuleTest(APITestCase): self.assertEqual(self.queue.count, 3) for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True)) - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) self.assertEqual(job.kwargs['model_name'], 'site') self.assertEqual(job.kwargs['data']['id'], data[i]['id']) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) @@ -295,7 +295,7 @@ class EventRuleTest(APITestCase): self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True)) - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) self.assertEqual(job.kwargs['model_name'], 'site') self.assertEqual(job.kwargs['data']['id'], site.pk) self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1') @@ -328,7 +328,7 @@ class EventRuleTest(APITestCase): self.assertEqual(self.queue.count, 3) for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True)) - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) self.assertEqual(job.kwargs['model_name'], 'site') self.assertEqual(job.kwargs['data']['id'], sites[i].pk) self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) @@ -365,12 +365,12 @@ class EventRuleTest(APITestCase): # Enqueue a webhook for processing webhooks_queue = {} site = Site.objects.create(name='Site 1', slug='site-1') - enqueue_object( + enqueue_event( webhooks_queue, instance=site, user=self.user, request_id=request_id, - action=ObjectChangeActionChoices.ACTION_CREATE + event_type=OBJECT_CREATED ) flush_events(list(webhooks_queue.values())) @@ -378,7 +378,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] # Patch the Session object with our dummy_send() method, then process the webhook for sending - with patch.object(Session, 'send', dummy_send) as mock_send: + with patch.object(Session, 'send', dummy_send): send_webhook(**job.kwargs) def test_duplicate_triggers(self): @@ -399,7 +399,7 @@ class EventRuleTest(APITestCase): site.save() self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue") job = self.queue.get_jobs()[0] - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) self.queue.empty() # Test multiple updates @@ -411,7 +411,7 @@ class EventRuleTest(APITestCase): site.save() self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue") job = self.queue.get_jobs()[0] - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) self.queue.empty() # Test update & delete @@ -422,5 +422,5 @@ class EventRuleTest(APITestCase): site.delete() self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue") job = self.queue.get_jobs()[0] - self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) self.queue.empty() diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 5c737f7cf..bf34f96b8 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1,7 +1,6 @@ import uuid from datetime import datetime, timezone -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -15,13 +14,11 @@ from extras.choices import * from extras.filtersets import * from extras.models import * from tenancy.models import Tenant, TenantGroup +from users.models import Group, User from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags from virtualization.models import Cluster, ClusterGroup, ClusterType -User = get_user_model() - - class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet @@ -1370,3 +1367,65 @@ class ChangeLoggedFilterSetTestCase(TestCase): params = {'modified_by_request': self.create_update_request_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.queryset.count(), 4) + + +class NotificationGroupTestCase(TestCase, BaseFilterSetTests): + queryset = NotificationGroup.objects.all() + filterset = NotificationGroupFilterSet + + @classmethod + def setUpTestData(cls): + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + notification_groups = ( + NotificationGroup(name='Notification Group 1'), + NotificationGroup(name='Notification Group 2'), + NotificationGroup(name='Notification Group 3'), + ) + NotificationGroup.objects.bulk_create(notification_groups) + notification_groups[0].users.add(users[0]) + notification_groups[1].users.add(users[1]) + notification_groups[2].users.add(users[2]) + notification_groups[0].groups.add(groups[0]) + notification_groups[1].groups.add(groups[1]) + notification_groups[2].groups.add(groups[2]) + + def test_user(self): + users = User.objects.filter(username__startswith='User') + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + groups = Group.objects.all() + params = {'group': [groups[0].name, groups[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index cbede195b..552c0f57a 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -6,10 +5,9 @@ from core.models import ObjectType from dcim.models import DeviceType, Manufacturer, Site from extras.choices import * from extras.models import * +from users.models import Group, User from utilities.testing import ViewTestCases, TestCase -User = get_user_model() - class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomField @@ -620,3 +618,166 @@ class CustomLinkTest(TestCase): response = self.client.get(site.get_absolute_url(), follow=True) self.assertEqual(response.status_code, 200) self.assertIn(f'FOO {site.name} BAR', str(response.content)) + + +class SubscriptionTestCase( + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): + model = Subscription + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), + ) + Site.objects.bulk_create(sites) + + cls.form_data = { + 'object_type': site_ct.pk, + 'object_id': sites[3].pk, + } + + def setUp(self): + super().setUp() + + sites = Site.objects.all() + user = self.user + + subscriptions = ( + Subscription(object=sites[0], user=user), + Subscription(object=sites[1], user=user), + Subscription(object=sites[2], user=user), + ) + Subscription.objects.bulk_create(subscriptions) + + def _get_url(self, action, instance=None): + if action == 'list': + return reverse('account:subscriptions') + return super()._get_url(action, instance) + + def test_list_objects_anonymous(self): + self.client.logout() + url = reverse('account:subscriptions') + login_url = reverse('login') + self.assertRedirects(self.client.get(url), f'{login_url}?next={url}') + + def test_list_objects_with_permission(self): + return + + def test_list_objects_with_constrained_permission(self): + return + + +class NotificationGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = NotificationGroup + + @classmethod + def setUpTestData(cls): + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + notification_groups = ( + NotificationGroup(name='Notification Group 1'), + NotificationGroup(name='Notification Group 2'), + NotificationGroup(name='Notification Group 3'), + ) + NotificationGroup.objects.bulk_create(notification_groups) + for i, notification_group in enumerate(notification_groups): + notification_group.users.add(users[i]) + notification_group.groups.add(groups[i]) + + cls.form_data = { + 'name': 'Notification Group X', + 'description': 'Blah', + 'users': [users[0].pk, users[1].pk], + 'groups': [groups[0].pk, groups[1].pk], + } + + cls.csv_data = ( + 'name,description,users,groups', + 'Notification Group 4,Foo,"User 1,User 2","Group 1,Group 2"', + 'Notification Group 5,Bar,"User 1,User 2","Group 1,Group 2"', + 'Notification Group 6,Baz,"User 1,User 2","Group 1,Group 2"', + ) + + cls.csv_update_data = ( + "id,name", + f"{notification_groups[0].pk},Notification Group 7", + f"{notification_groups[1].pk},Notification Group 8", + f"{notification_groups[2].pk},Notification Group 9", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class NotificationTestCase( + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): + model = Notification + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + Site(name='Site 4', slug='site-4'), + ) + Site.objects.bulk_create(sites) + + cls.form_data = { + 'object_type': site_ct.pk, + 'object_id': sites[3].pk, + } + + def setUp(self): + super().setUp() + + sites = Site.objects.all() + user = self.user + + notifications = ( + Notification(object=sites[0], user=user), + Notification(object=sites[1], user=user), + Notification(object=sites[2], user=user), + ) + Notification.objects.bulk_create(notifications) + + def _get_url(self, action, instance=None): + if action == 'list': + return reverse('account:notifications') + return super()._get_url(action, instance) + + def test_list_objects_anonymous(self): + self.client.logout() + url = reverse('account:notifications') + login_url = reverse('login') + self.assertRedirects(self.client.get(url), f'{login_url}?next={url}') + + def test_list_objects_with_permission(self): + return + + def test_list_objects_with_constrained_permission(self): + return diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f2e11e71e..6d515cf5f 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -53,6 +53,24 @@ urlpatterns = [ path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'), path('bookmarks//', 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//', 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//', 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//', 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'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 4d3332405..d3e346feb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -6,6 +6,7 @@ from django.db.models import Count, Q from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext as _ from django.views.generic import View @@ -356,6 +357,139 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView): return Bookmark.objects.filter(user=request.user) +# +# Notification groups +# + +class NotificationGroupListView(generic.ObjectListView): + queryset = NotificationGroup.objects.all() + filterset = filtersets.NotificationGroupFilterSet + filterset_form = forms.NotificationGroupFilterForm + table = tables.NotificationGroupTable + + +@register_model_view(NotificationGroup) +class NotificationGroupView(generic.ObjectView): + queryset = NotificationGroup.objects.all() + + +@register_model_view(NotificationGroup, 'edit') +class NotificationGroupEditView(generic.ObjectEditView): + queryset = NotificationGroup.objects.all() + form = forms.NotificationGroupForm + + +@register_model_view(NotificationGroup, 'delete') +class NotificationGroupDeleteView(generic.ObjectDeleteView): + queryset = NotificationGroup.objects.all() + + +class NotificationGroupBulkImportView(generic.BulkImportView): + queryset = NotificationGroup.objects.all() + model_form = forms.NotificationGroupImportForm + + +class NotificationGroupBulkEditView(generic.BulkEditView): + queryset = NotificationGroup.objects.all() + filterset = filtersets.NotificationGroupFilterSet + table = tables.NotificationGroupTable + form = forms.NotificationGroupBulkEditForm + + +class NotificationGroupBulkDeleteView(generic.BulkDeleteView): + queryset = NotificationGroup.objects.all() + filterset = filtersets.NotificationGroupFilterSet + table = tables.NotificationGroupTable + + +# +# Notifications +# + +class NotificationsView(LoginRequiredMixin, View): + """ + HTMX-only user-specific notifications list. + """ + def get(self, request): + return render(request, 'htmx/notifications.html', { + 'notifications': request.user.notifications.unread(), + 'total_count': request.user.notifications.count(), + }) + + +@register_model_view(Notification, 'read') +class NotificationReadView(LoginRequiredMixin, View): + """ + Mark the Notification read and redirect the user to its attached object. + """ + def get(self, request, pk): + notification = get_object_or_404(request.user.notifications, pk=pk) + notification.read = timezone.now() + notification.save() + + return redirect(notification.object.get_absolute_url()) + + +@register_model_view(Notification, 'dismiss') +class NotificationDismissView(LoginRequiredMixin, View): + """ + A convenience view which allows deleting notifications with one click. + """ + def get(self, request, pk): + notification = get_object_or_404(request.user.notifications, pk=pk) + notification.delete() + + if htmx_partial(request): + return render(request, 'htmx/notifications.html', { + 'notifications': request.user.notifications.unread()[:10], + }) + + return redirect('account:notifications') + + +@register_model_view(Notification, 'delete') +class NotificationDeleteView(generic.ObjectDeleteView): + + def get_queryset(self, request): + return Notification.objects.filter(user=request.user) + + +class NotificationBulkDeleteView(generic.BulkDeleteView): + table = tables.NotificationTable + + def get_queryset(self, request): + return Notification.objects.filter(user=request.user) + + +# +# Subscriptions +# + +class SubscriptionCreateView(generic.ObjectEditView): + form = forms.SubscriptionForm + + def get_queryset(self, request): + return Subscription.objects.filter(user=request.user) + + def alter_object(self, obj, request, url_args, url_kwargs): + obj.user = request.user + return obj + + +@register_model_view(Subscription, 'delete') +class SubscriptionDeleteView(generic.ObjectDeleteView): + + def get_queryset(self, request): + return Subscription.objects.filter(user=request.user) + + +class SubscriptionBulkDeleteView(generic.BulkDeleteView): + table = tables.SubscriptionTable + + def get_queryset(self, request): + return Subscription.objects.filter(user=request.user) + + # # Webhooks # diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 53ec161d7..889c97ac2 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -25,7 +25,7 @@ def generate_signature(request_body, secret): @job('default') -def send_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None): +def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None): """ Make a POST request to the defined Webhook """ @@ -33,7 +33,7 @@ def send_webhook(event_rule, model_name, event, data, timestamp, username, reque # Prepare context data for headers & body templates context = { - 'event': WEBHOOK_EVENT_TYPES[event], + 'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type), 'timestamp': timestamp, 'model': model_name, 'username': username, diff --git a/netbox/netbox/events.py b/netbox/netbox/events.py new file mode 100644 index 000000000..15691aafb --- /dev/null +++ b/netbox/netbox/events.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +from netbox.registry import registry + +EVENT_TYPE_INFO = 'info' +EVENT_TYPE_SUCCESS = 'success' +EVENT_TYPE_WARNING = 'warning' +EVENT_TYPE_DANGER = 'danger' + +__all__ = ( + 'EVENT_TYPE_DANGER', + 'EVENT_TYPE_INFO', + 'EVENT_TYPE_SUCCESS', + 'EVENT_TYPE_WARNING', + 'Event', +) + + +@dataclass +class Event: + name: str + text: str + type: str = EVENT_TYPE_INFO + + def __str__(self): + return self.text + + def register(self): + registry['events'][self.name] = self + + def color(self): + return { + EVENT_TYPE_INFO: 'blue', + EVENT_TYPE_SUCCESS: 'green', + EVENT_TYPE_WARNING: 'orange', + EVENT_TYPE_DANGER: 'red', + }.get(self.type) + + def icon(self): + return { + EVENT_TYPE_INFO: 'mdi mdi-information', + EVENT_TYPE_SUCCESS: 'mdi mdi-check-circle', + EVENT_TYPE_WARNING: 'mdi mdi-alert-box', + EVENT_TYPE_DANGER: 'mdi mdi-alert-octagon', + }.get(self.type) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 2c262b258..4ba5f60da 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -29,6 +29,7 @@ class NetBoxFeatureSet( CustomValidationMixin, ExportTemplatesMixin, JournalingMixin, + NotificationsMixin, TagsMixin, EventRulesMixin ): diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 0393bf25d..b270382d3 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -34,6 +34,7 @@ __all__ = ( 'ImageAttachmentsMixin', 'JobsMixin', 'JournalingMixin', + 'NotificationsMixin', 'SyncedDataMixin', 'TagsMixin', 'register_models', @@ -377,6 +378,25 @@ class BookmarksMixin(models.Model): abstract = True +class NotificationsMixin(models.Model): + """ + Enables support for user notifications. + """ + notifications = GenericRelation( + to='extras.Notification', + content_type_field='object_type', + object_id_field='object_id' + ) + subscriptions = GenericRelation( + to='extras.Subscription', + content_type_field='object_type', + object_id_field='object_id' + ) + + class Meta: + abstract = True + + class JobsMixin(models.Model): """ Enables support for job results. @@ -582,13 +602,14 @@ FEATURES_MAP = { 'custom_fields': CustomFieldsMixin, 'custom_links': CustomLinksMixin, 'custom_validation': CustomValidationMixin, + 'event_rules': EventRulesMixin, 'export_templates': ExportTemplatesMixin, 'image_attachments': ImageAttachmentsMixin, 'jobs': JobsMixin, 'journaling': JournalingMixin, + 'notifications': NotificationsMixin, 'synced_data': SyncedDataMixin, 'tags': TagsMixin, - 'event_rules': EventRulesMixin, } registry['model_features'].update({ diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 65277c0f4..d9edab36b 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -355,6 +355,7 @@ OPERATIONS_MENU = Menu( MenuGroup( label=_('Logging'), items=( + get_model_item('extras', 'notificationgroup', _('Notification Groups')), get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']), get_model_item('core', 'objectchange', _('Change Log'), actions=[]), ), diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index d783647ec..44cdfb92b 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -25,6 +25,7 @@ registry = Registry({ 'counter_fields': collections.defaultdict(dict), 'data_backends': dict(), 'denormalized_fields': collections.defaultdict(list), + 'events': dict(), 'model_features': dict(), 'models': collections.defaultdict(set), 'plugins': dict(), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7eddf67e0..64fb24f09 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -84,6 +84,16 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { 'extras.add_bookmark': ({'user': '$user'},), 'extras.change_bookmark': ({'user': '$user'},), 'extras.delete_bookmark': ({'user': '$user'},), + # Permit users to manage their own notifications + 'extras.view_notification': ({'user': '$user'},), + 'extras.add_notification': ({'user': '$user'},), + 'extras.change_notification': ({'user': '$user'},), + 'extras.delete_notification': ({'user': '$user'},), + # Permit users to manage their own subscriptions + 'extras.view_subscription': ({'user': '$user'},), + 'extras.add_subscription': ({'user': '$user'},), + 'extras.change_subscription': ({'user': '$user'},), + 'extras.delete_subscription': ({'user': '$user'},), # Permit users to manage their own API tokens 'users.view_token': ({'user': '$user'},), 'users.add_token': ({'user': '$user'},), diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 36ed4defc..755e51f17 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/styles/custom/_notifications.scss b/netbox/project-static/styles/custom/_notifications.scss new file mode 100644 index 000000000..4777362aa --- /dev/null +++ b/netbox/project-static/styles/custom/_notifications.scss @@ -0,0 +1,9 @@ +@use 'sass:map'; + +// Mute read notifications +tr[data-read=True] { + td { + background-color: var(--#{$prefix}bg-surface-secondary); + color: $text-muted; + } +} diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index af2905312..0e1b44d59 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -24,3 +24,4 @@ @import 'custom/interfaces'; @import 'custom/markdown'; @import 'custom/misc'; +@import 'custom/notifications'; diff --git a/netbox/templates/account/base.html b/netbox/templates/account/base.html index 51076f781..41fe4665e 100644 --- a/netbox/templates/account/base.html +++ b/netbox/templates/account/base.html @@ -9,6 +9,12 @@ + + diff --git a/netbox/templates/account/notifications.html b/netbox/templates/account/notifications.html new file mode 100644 index 000000000..5a471ef25 --- /dev/null +++ b/netbox/templates/account/notifications.html @@ -0,0 +1,32 @@ +{% extends 'account/base.html' %} +{% load buttons %} +{% load helpers %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block title %}{% trans "Notifications" %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} + + + {# Table #} +
+
+
+
+ {% include 'htmx/table.html' %} +
+
+
+
+ + {# Form buttons #} +
+ {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/account/subscriptions.html b/netbox/templates/account/subscriptions.html new file mode 100644 index 000000000..d97053d63 --- /dev/null +++ b/netbox/templates/account/subscriptions.html @@ -0,0 +1,32 @@ +{% extends 'account/base.html' %} +{% load buttons %} +{% load helpers %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block title %}{% trans "Subscriptions" %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} + + + {# Table #} +
+
+
+
+ {% include 'htmx/table.html' %} +
+
+
+
+ + {# Form buttons #} +
+ {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/notificationgroup.html b/netbox/templates/extras/notificationgroup.html new file mode 100644 index 000000000..ab514f8bf --- /dev/null +++ b/netbox/templates/extras/notificationgroup.html @@ -0,0 +1,57 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Notification Group" %}
+ + + + + + + + + +
{% trans "Name" %} + {{ object.name }} +
{% trans "Description" %} + {{ object.description|placeholder }} +
+
+ {% plugin_left_page object %} +
+
+
+
{% trans "Groups" %}
+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None assigned" %}
+ {% endfor %} +
+
+
+
{% trans "Users" %}
+
+ {% for user in object.users.all %} + {{ user }} + {% empty %} +
{% trans "None assigned" %}
+ {% endfor %} +
+
+ {% plugin_right_page object %} +
+
+
+ {% plugin_full_width_page object %} +
+ +{% endblock %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index e0995f360..c2a7de201 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -77,6 +77,9 @@ Context: {% if perms.extras.add_bookmark and object.bookmarks %} {% bookmark_button object %} {% endif %} + {% if perms.extras.add_subscription and object.subscriptions %} + {% subscribe_button object %} + {% endif %} {% if request.user|can_add:object %} {% clone_button object %} {% endif %} diff --git a/netbox/templates/htmx/notifications.html b/netbox/templates/htmx/notifications.html new file mode 100644 index 000000000..bd2de4e6e --- /dev/null +++ b/netbox/templates/htmx/notifications.html @@ -0,0 +1,33 @@ +{% load i18n %} +
+ {% for notification in notifications %} +
+
+
+ +
+
+ {{ notification.object }} +
{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}
+
+
+ + + +
+
+
+ {% empty %} + + {% endfor %} + {% if total_count %} + + {% trans "All notifications" %} + {% badge total_count %} + + {% endif %} +
+{% include 'inc/notification_bell.html' %} + diff --git a/netbox/templates/inc/notification_bell.html b/netbox/templates/inc/notification_bell.html new file mode 100644 index 000000000..5140a2f3f --- /dev/null +++ b/netbox/templates/inc/notification_bell.html @@ -0,0 +1,9 @@ +{% if notifications %} + + + +{% else %} + + + +{% endif %} diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index 30ea63059..ab2c31239 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -2,6 +2,17 @@ {% load navigation %} {% if request.user.is_authenticated %} + {# Notifications #} + {% with notifications=request.user.notifications.unread.exists %} + + {% endwith %} + + {# User menu #}