From 865619baca1141cd14c4cd4c7e6aa428671e64ca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jul 2024 21:33:38 -0400 Subject: [PATCH] Initial work on #15621 --- netbox/account/urls.py | 2 + netbox/account/views.py | 34 +++- netbox/extras/api/serializers.py | 1 + .../extras/api/serializers_/notifications.py | 82 +++++++++ netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 22 +++ netbox/extras/choices.py | 35 ++++ netbox/extras/filtersets.py | 31 ++++ netbox/extras/forms/bulk_edit.py | 14 ++ netbox/extras/forms/bulk_import.py | 8 + netbox/extras/forms/filtersets.py | 15 ++ netbox/extras/forms/model_forms.py | 31 ++++ .../extras/migrations/0118_notifications.py | 77 ++++++++ netbox/extras/models/__init__.py | 1 + netbox/extras/models/notifications.py | 171 ++++++++++++++++++ netbox/extras/querysets.py | 12 ++ netbox/extras/signals.py | 32 +++- netbox/extras/tables/tables.py | 55 ++++++ netbox/extras/urls.py | 18 ++ netbox/extras/views.py | 125 +++++++++++++ netbox/netbox/models/__init__.py | 1 + netbox/netbox/models/features.py | 23 ++- netbox/netbox/navigation/menu.py | 1 + netbox/netbox/settings.py | 5 + netbox/templates/account/base.html | 6 + netbox/templates/account/notifications.html | 32 ++++ netbox/templates/account/subscriptions.html | 32 ++++ netbox/templates/generic/object.html | 3 + netbox/templates/htmx/notifications.html | 24 +++ netbox/templates/inc/user_menu.html | 18 ++ .../utilities/templates/buttons/bookmark.html | 2 +- .../templates/buttons/subscribe.html | 16 ++ netbox/utilities/templatetags/buttons.py | 34 +++- 33 files changed, 959 insertions(+), 7 deletions(-) create mode 100644 netbox/extras/api/serializers_/notifications.py create mode 100644 netbox/extras/migrations/0118_notifications.py create mode 100644 netbox/extras/models/notifications.py create mode 100644 netbox/templates/account/notifications.html create mode 100644 netbox/templates/account/subscriptions.html create mode 100644 netbox/templates/htmx/notifications.html create mode 100644 netbox/utilities/templates/buttons/subscribe.html 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 5220c6fe8..6654c0a30 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -21,8 +21,8 @@ from social_core.backends.utils import load_backends 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.models import Bookmark, Notification, Subscription +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 Notification.objects.filter(user=request.user) + + 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 Subscription.objects.filter(user=request.user) + + def get_extra_context(self, request): + return { + 'active_tab': 'subscriptions', + } + + # # User views for token management # 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..1d78f930c --- /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, 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', 'kind', 'event', + ] + brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user', 'kind', 'event') + + @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 = Notification + fields = [ + 'id', 'url', 'display', 'name', 'description', 'object', '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_id', 'object_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 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..2e650b561 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -140,6 +140,28 @@ 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): + metadata_class = ContentTypeMetadata + 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..d5444eae5 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -148,6 +148,41 @@ class JournalEntryKindChoices(ChoiceSet): ] +# +# Notifications +# + +class NotificationKindChoices(ChoiceSet): + key = 'Notification.kind' + + KIND_INFO = 'info' + KIND_SUCCESS = 'success' + KIND_WARNING = 'warning' + KIND_DANGER = 'danger' + + CHOICES = [ + (KIND_INFO, _('Info'), 'cyan'), + (KIND_SUCCESS, _('Success'), 'green'), + (KIND_WARNING, _('Warning'), 'yellow'), + (KIND_DANGER, _('Danger'), 'red'), + ] + + +# TODO: Support dynamic entries from plugins +class NotificationEventChoices(ChoiceSet): + key = 'Notification.event' + + OBJECT_CREATED = 'object_created' + OBJECT_CHANGED = 'object_changed' + OBJECT_DELETED = 'object_deleted' + + CHOICES = [ + (OBJECT_CREATED, _('Object created')), + (OBJECT_CHANGED, _('Object changed')), + (OBJECT_DELETED, _('Object deleted')), + ] + + # # Reports and Scripts # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf0275c2d..b2bb5ac99 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 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,35 @@ class BookmarkFilterSet(BaseFilterSet): fields = ('id', 'object_id') +class NotificationGroupFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + # user_id = django_filters.ModelMultipleChoiceFilter( + # queryset=get_user_model().objects.all(), + # label=_('User (ID)'), + # ) + # group_id = django_filters.ModelMultipleChoiceFilter( + # queryset=Group.objects.all(), + # label=_('Group (ID)'), + # ) + + 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..6c912b5df 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -343,3 +343,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 cf022ba0e..8391a665d 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -23,6 +23,7 @@ __all__ = ( 'EventRuleImportForm', 'ExportTemplateImportForm', 'JournalEntryImportForm', + 'NotificationGroupImportForm', 'SavedFilterImportForm', 'TagImportForm', 'WebhookImportForm', @@ -250,3 +251,10 @@ class JournalEntryImportForm(NetBoxModelImportForm): fields = ( 'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags' ) + + +class NotificationGroupImportForm(CSVModelForm): + + class Meta: + model = NotificationGroup + fields = ('name', 'description') 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..9f8fd8799 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,34 @@ 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') + + +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 = ( diff --git a/netbox/extras/migrations/0118_notifications.py b/netbox/extras/migrations/0118_notifications.py new file mode 100644 index 000000000..e81fa43ec --- /dev/null +++ b/netbox/extras/migrations/0118_notifications.py @@ -0,0 +1,77 @@ +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)), + ('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, 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(null=True)), + ('object_id', models.PositiveBigIntegerField()), + ('kind', models.CharField(default='info', max_length=30)), + ('event', models.CharField(max_length=30)), + ('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..318c77283 --- /dev/null +++ b/netbox/extras/models/notifications.py @@ -0,0 +1,171 @@ +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.utils.translation import gettext_lazy as _ + +from core.models import ObjectType +from extras.choices import * +from extras.querysets import NotificationQuerySet +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Notification', + 'NotificationGroup', + 'Subscription', +) + + +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 + ) + 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' + ) + kind = models.CharField( + verbose_name=_('kind'), + max_length=30, + choices=NotificationKindChoices, + default=NotificationKindChoices.KIND_INFO + ) + event = models.CharField( + verbose_name=_('event'), + max_length=30, + choices=NotificationEventChoices + ) + + 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 self.object.get_absolute_url() + + 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) + ) + + +class NotificationGroup(models.Model): + """ + 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') + + +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 + ) + 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 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..a0ea9a3c2 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,9 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) return base_query + + +class NotificationQuerySet(RestrictedQuerySet): + + def unread(self): + return self.filter(read__isnull=True) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 53ec39cac..69b939e53 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -12,15 +12,16 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates from core.choices import ObjectChangeActionChoices from core.models import ObjectChange, ObjectType from core.signals import job_end, job_start +from extras.choices import NotificationEventChoices, NotificationKindChoices 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.signals import post_clean from utilities.exceptions import AbortRequest -from .events import enqueue_object, get_snapshots, serialize_for_event +from .events import enqueue_object from .models import CustomField, TaggedItem from .validators import CustomValidator @@ -281,3 +282,30 @@ 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) + + +# +# Notifications +# + +@receiver(post_save) +def notify_object_changed(sender, instance, **kwargs): + ct = ContentType.objects.get_for_model(instance) + + # Find any Subscriptions for this object + subscriptions = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values('user') + + # Delete any existing Notifications for the object + subscribed_users = [sub['user'] for sub in subscriptions] + Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete() + + # Create Notifications for Subscribers + notifications = [ + Notification( + user_id=sub['user'], + object=instance, + event=NotificationEventChoices.OBJECT_CHANGED + ) + for sub in subscriptions + ] + Notification.objects.bulk_create(notifications) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d919ff1d5..747964b43 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -19,9 +19,12 @@ __all__ = ( 'ExportTemplateTable', 'ImageAttachmentTable', 'JournalEntryTable', + 'NotificationGroupTable', + 'NotificationTable', 'SavedFilterTable', 'ReportResultsTable', 'ScriptResultsTable', + 'SubscriptionTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', @@ -263,6 +266,58 @@ class BookmarkTable(NetBoxTable): default_columns = ('object', 'object_type', 'created') +class NotificationTable(NetBoxTable): + object_type = columns.ContentTypeColumn( + verbose_name=_('Object Types'), + ) + object = tables.Column( + verbose_name=_('Object'), + linkify=True + ) + created = columns.DateTimeColumn( + timespec='minutes', + verbose_name=_('Created'), + ) + read = columns.DateTimeColumn( + timespec='minutes', + verbose_name=_('Read'), + ) + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = Notification + fields = ('pk', 'object', 'object_type', 'created', 'read') + default_columns = ('object', 'object_type', 'created', 'read') + + +class NotificationGroupTable(NetBoxTable): + + class Meta(NetBoxTable.Meta): + model = NotificationGroup + fields = ('pk', 'name', 'description', 'groups', 'users') + default_columns = ('name', 'description') + + +class SubscriptionTable(NetBoxTable): + object_type = columns.ContentTypeColumn( + verbose_name=_('Object Types'), + ) + object = tables.Column( + verbose_name=_('Object'), + linkify=True + ) + actions = columns.ActionsColumn( + actions=('delete',) + ) + + class Meta(NetBoxTable.Meta): + model = Subscription + fields = ('pk', 'object', 'object_type', 'created') + default_columns = ('object', 'object_type', 'created') + + class WebhookTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f2e11e71e..effddf20d 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..fd836ed55 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,130 @@ 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): + + def get(self, request): + notifications = Notification.objects.filter( + user=request.user, + read__isnull=True + ) + return render(request, 'htmx/notifications.html', { + 'notifications': notifications, + }) + + def post(self, request): + form = forms.RenderMarkdownForm(request.POST) + if not form.is_valid(): + HttpResponseBadRequest() + rendered = render_markdown(form.cleaned_data['text']) + + return HttpResponse(rendered) + + +@register_model_view(Notification, 'read') +class NotificationReadView(LoginRequiredMixin, View): + + def get(self, request, pk): + request.user.notifications.filter(pk=pk).update(read=timezone.now()) + + notifications = request.user.notifications.unread()[:10] + return render(request, 'htmx/notifications.html', { + 'notifications': 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/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 6db7ac14c..993564e7b 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/settings.py b/netbox/netbox/settings.py index 42ae8cb3e..8bd039e78 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -84,6 +84,11 @@ 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 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/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/generic/object.html b/netbox/templates/generic/object.html index bf0e1ae9a..4935bfd91 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 %} + {% 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..bd9141327 --- /dev/null +++ b/netbox/templates/htmx/notifications.html @@ -0,0 +1,24 @@ +{% load i18n %} +
+ {% for notification in notifications %} +
+
+
+ {{ notification.object }} +
{{ notification.get_event_display }} {{ notification.created|timesince }} {% trans "ago" %}
+
+
+ + + +
+
+
+ {% empty %} + + {% endfor %} +
+ +{% trans "All notifications" %} diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index 30ea63059..de9dcac8e 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -2,6 +2,21 @@ {% load navigation %} {% if request.user.is_authenticated %} + {# Notifications #} + {% with notifications=request.user.notifications.unread.exists %} + + {% endwith %} + + {# User menu #}