Initial work on #15621

This commit is contained in:
Jeremy Stretch 2024-07-02 21:33:38 -04:00
parent 5ac5135dbc
commit 865619baca
33 changed files with 959 additions and 7 deletions

View File

@ -9,6 +9,8 @@ urlpatterns = [
# Account views # Account views
path('profile/', views.ProfileView.as_view(), name='profile'), path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), 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('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'), path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),

View File

@ -21,8 +21,8 @@ from social_core.backends.utils import load_backends
from account.models import UserToken from account.models import UserToken
from core.models import ObjectChange from core.models import ObjectChange
from core.tables import ObjectChangeTable from core.tables import ObjectChangeTable
from extras.models import Bookmark from extras.models import Bookmark, Notification, Subscription
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.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config from netbox.config import get_config
from netbox.views import generic 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 # User views for token management
# #

View File

@ -7,6 +7,7 @@ from .serializers_.dashboard import *
from .serializers_.events import * from .serializers_.events import *
from .serializers_.exporttemplates import * from .serializers_.exporttemplates import *
from .serializers_.journaling import * from .serializers_.journaling import *
from .serializers_.notifications import *
from .serializers_.configcontexts import * from .serializers_.configcontexts import *
from .serializers_.configtemplates import * from .serializers_.configtemplates import *
from .serializers_.savedfilters import * from .serializers_.savedfilters import *

View File

@ -0,0 +1,82 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from extras.models import Notification, 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

View File

@ -15,6 +15,9 @@ router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet) router.register('saved-filters', views.SavedFilterViewSet)
router.register('bookmarks', views.BookmarkViewSet) 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('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet) router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet) router.register('journal-entries', views.JournalEntryViewSet)

View File

@ -140,6 +140,28 @@ class BookmarkViewSet(NetBoxModelViewSet):
filterset_class = filtersets.BookmarkFilterSet 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 # Tags
# #

View File

@ -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 # Reports and Scripts
# #

View File

@ -8,6 +8,7 @@ from core.models import DataSource, ObjectType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.models import Group
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import * from .choices import *
@ -26,6 +27,7 @@ __all__ = (
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
'JournalEntryFilterSet', 'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'NotificationGroupFilterSet',
'ObjectTypeFilterSet', 'ObjectTypeFilterSet',
'SavedFilterFilterSet', 'SavedFilterFilterSet',
'ScriptFilterSet', 'ScriptFilterSet',
@ -336,6 +338,35 @@ class BookmarkFilterSet(BaseFilterSet):
fields = ('id', 'object_id') 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): class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -343,3 +343,17 @@ class JournalEntryBulkEditForm(BulkEditForm):
required=False required=False
) )
comments = CommentField() comments = CommentField()
class NotificationGroupBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=NotificationGroup.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
nullable_fields = ('description',)

View File

@ -23,6 +23,7 @@ __all__ = (
'EventRuleImportForm', 'EventRuleImportForm',
'ExportTemplateImportForm', 'ExportTemplateImportForm',
'JournalEntryImportForm', 'JournalEntryImportForm',
'NotificationGroupImportForm',
'SavedFilterImportForm', 'SavedFilterImportForm',
'TagImportForm', 'TagImportForm',
'WebhookImportForm', 'WebhookImportForm',
@ -250,3 +251,10 @@ class JournalEntryImportForm(NetBoxModelImportForm):
fields = ( fields = (
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags' 'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
) )
class NotificationGroupImportForm(CSVModelForm):
class Meta:
model = NotificationGroup
fields = ('name', 'description')

View File

@ -9,6 +9,7 @@ from extras.models import *
from netbox.forms.base import NetBoxModelFilterSetForm from netbox.forms.base import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin from netbox.forms.mixins import SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup 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 import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
@ -28,6 +29,7 @@ __all__ = (
'ImageAttachmentFilterForm', 'ImageAttachmentFilterForm',
'JournalEntryFilterForm', 'JournalEntryFilterForm',
'LocalConfigContextFilterForm', 'LocalConfigContextFilterForm',
'NotificationGroupFilterForm',
'SavedFilterFilterForm', 'SavedFilterFilterForm',
'TagFilterForm', 'TagFilterForm',
'WebhookFilterForm', 'WebhookFilterForm',
@ -496,3 +498,16 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
group_id = DynamicModelMultipleChoiceField(
queryset=Group.objects.all(),
required=False,
label=_('Group')
)

View File

@ -12,6 +12,7 @@ from extras.choices import *
from extras.models import * from extras.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup 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 import add_blank_choice, get_field_value
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
@ -32,7 +33,9 @@ __all__ = (
'ExportTemplateForm', 'ExportTemplateForm',
'ImageAttachmentForm', 'ImageAttachmentForm',
'JournalEntryForm', 'JournalEntryForm',
'NotificationGroupForm',
'SavedFilterForm', 'SavedFilterForm',
'SubscriptionForm',
'TagForm', 'TagForm',
'WebhookForm', 'WebhookForm',
) )
@ -238,6 +241,34 @@ class BookmarkForm(forms.ModelForm):
fields = ('object_type', 'object_id') 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): class WebhookForm(NetBoxModelForm):
fieldsets = ( fieldsets = (

View File

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

View File

@ -2,6 +2,7 @@ from .configs import *
from .customfields import * from .customfields import *
from .dashboard import * from .dashboard import *
from .models import * from .models import *
from .notifications import *
from .scripts import * from .scripts import *
from .search import * from .search import *
from .staging import * from .staging import *

View File

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

View File

@ -5,6 +5,12 @@ from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg from utilities.query_functions import EmptyGroupByJSONBAgg
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
__all__ = (
'ConfigContextModelQuerySet',
'ConfigContextQuerySet',
'NotificationQuerySet',
)
class ConfigContextQuerySet(RestrictedQuerySet): class ConfigContextQuerySet(RestrictedQuerySet):
@ -145,3 +151,9 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
) )
return base_query return base_query
class NotificationQuerySet(RestrictedQuerySet):
def unread(self):
return self.filter(read__isnull=True)

View File

@ -12,15 +12,16 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType from core.models import ObjectChange, ObjectType
from core.signals import job_end, job_start 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.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules 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.config import get_config
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.exceptions import AbortRequest 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 .models import CustomField, TaggedItem
from .validators import CustomValidator 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) 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 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, 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)

View File

@ -19,9 +19,12 @@ __all__ = (
'ExportTemplateTable', 'ExportTemplateTable',
'ImageAttachmentTable', 'ImageAttachmentTable',
'JournalEntryTable', 'JournalEntryTable',
'NotificationGroupTable',
'NotificationTable',
'SavedFilterTable', 'SavedFilterTable',
'ReportResultsTable', 'ReportResultsTable',
'ScriptResultsTable', 'ScriptResultsTable',
'SubscriptionTable',
'TaggedItemTable', 'TaggedItemTable',
'TagTable', 'TagTable',
'WebhookTable', 'WebhookTable',
@ -263,6 +266,58 @@ class BookmarkTable(NetBoxTable):
default_columns = ('object', 'object_type', 'created') 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): class WebhookTable(NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'), verbose_name=_('Name'),

View File

@ -53,6 +53,24 @@ urlpatterns = [
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'), path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))), path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
# Notification groups
path('notification-groups/', views.NotificationGroupListView.as_view(), name='notificationgroup_list'),
path('notification-groups/add/', views.NotificationGroupEditView.as_view(), name='notificationgroup_add'),
path('notification-groups/import/', views.NotificationGroupBulkImportView.as_view(), name='notificationgroup_import'),
# path('notification-groups/edit/', views.NotificationGroupBulkEditView.as_view(), name='notificationgroup_bulk_edit'),
path('notification-groups/delete/', views.NotificationGroupBulkDeleteView.as_view(), name='notificationgroup_bulk_delete'),
path('notification-groups/<int:pk>/', include(get_model_urls('extras', 'notificationgroup'))),
# Notifications
path('notifications/', views.NotificationsView.as_view(), name='notifications'),
path('notifications/delete/', views.NotificationBulkDeleteView.as_view(), name='notification_bulk_delete'),
path('notifications/<int:pk>/', include(get_model_urls('extras', 'notification'))),
# Subscriptions
path('subscriptions/add/', views.SubscriptionCreateView.as_view(), name='subscription_add'),
path('subscriptions/delete/', views.SubscriptionBulkDeleteView.as_view(), name='subscription_bulk_delete'),
path('subscriptions/<int:pk>/', include(get_model_urls('extras', 'subscription'))),
# Webhooks # Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

View File

@ -6,6 +6,7 @@ from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
@ -356,6 +357,130 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
return Bookmark.objects.filter(user=request.user) 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 # Webhooks
# #

View File

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

View File

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

View File

@ -355,6 +355,7 @@ OPERATIONS_MENU = Menu(
MenuGroup( MenuGroup(
label=_('Logging'), label=_('Logging'),
items=( items=(
get_model_item('extras', 'notificationgroup', _('Notification Groups')),
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']), get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
get_model_item('core', 'objectchange', _('Change Log'), actions=[]), get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
), ),

View File

@ -84,6 +84,11 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
'extras.add_bookmark': ({'user': '$user'},), 'extras.add_bookmark': ({'user': '$user'},),
'extras.change_bookmark': ({'user': '$user'},), 'extras.change_bookmark': ({'user': '$user'},),
'extras.delete_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 # Permit users to manage their own API tokens
'users.view_token': ({'user': '$user'},), 'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},), 'users.add_token': ({'user': '$user'},),

View File

@ -9,6 +9,12 @@
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a> <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
</li> </li>
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'notifications' %} active{% endif %}" href="{% url 'account:notifications' %}">{% trans "Notifications" %}</a>
</li>
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'subscriptions' %} active{% endif %}" href="{% url 'account:subscriptions' %}">{% trans "Subscriptions" %}</a>
</li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a> <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
</li> </li>

View File

@ -0,0 +1,32 @@
{% extends 'account/base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}{% trans "Notifications" %}{% endblock %}
{% block content %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'account:notifications' %}" />
{# Table #}
<div class="row">
<div class="col col-md-12">
<div class="card">
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
{# Form buttons #}
<div class="btn-list d-print-none mt-2">
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends 'account/base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}{% trans "Subscriptions" %}{% endblock %}
{% block content %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'account:subscriptions' %}" />
{# Table #}
<div class="row">
<div class="col col-md-12">
<div class="card">
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
{# Form buttons #}
<div class="btn-list d-print-none mt-2">
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
</form>
{% endblock %}

View File

@ -77,6 +77,9 @@ Context:
{% if perms.extras.add_bookmark and object.bookmarks %} {% if perms.extras.add_bookmark and object.bookmarks %}
{% bookmark_button object %} {% bookmark_button object %}
{% endif %} {% endif %}
{% if perms.extras.add_subscription %}
{% subscribe_button object %}
{% endif %}
{% if request.user|can_add:object %} {% if request.user|can_add:object %}
{% clone_button object %} {% clone_button object %}
{% endif %} {% endif %}

View File

@ -0,0 +1,24 @@
{% load i18n %}
<div class="list-group list-group-flush list-group-hoverable" style="min-width: 300px">
{% for notification in notifications %}
<div class="list-group-item p-2">
<div class="row align-items-center">
<div class="col text-truncate">
<a href="{{ notification.get_absolute_url }}" class="text-body d-block">{{ notification.object }}</a>
<div class="d-block text-secondary fs-5">{{ notification.get_event_display }} {{ notification.created|timesince }} {% trans "ago" %}</div>
</div>
<div class="col-auto">
<a href="#" hx-get="{% url 'extras:notification_read' pk=notification.pk %}" hx-target="closest .notifications" class="list-group-item-actions text-secondary" title="{% trans "Mark read" %}">
<i class="mdi mdi-close"></i>
</a>
</div>
</div>
</div>
{% empty %}
<div class="dropdown-item text-muted">
{% trans "No unread notifications" %}
</div>
{% endfor %}
</div>
<hr class="dropdown-divider" />
<a href="{% url 'account:notifications' %}" class="dropdown-item">{% trans "All notifications" %}</a>

View File

@ -2,6 +2,21 @@
{% load navigation %} {% load navigation %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{# Notifications #}
{% with notifications=request.user.notifications.unread.exists %}
<div class="nav-item dropdown">
<a href="#" class="nav-link" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="Notifications">
{% if notifications %}
<span class="text-primary"><i class="mdi mdi-bell-badge"></i></span>
{% else %}
<i class="mdi mdi-bell"></i>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow notifications"></div>
</div>
{% endwith %}
{# User menu #}
<div class="nav-item dropdown"> <div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu"> <a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<div class="d-xl-block ps-2"> <div class="d-xl-block ps-2">
@ -29,6 +44,9 @@
<a href="{% url 'account:bookmarks' %}" class="dropdown-item"> <a href="{% url 'account:bookmarks' %}" class="dropdown-item">
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %} <i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
</a> </a>
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
<i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
</a>
<a href="{% url 'account:preferences' %}" class="dropdown-item"> <a href="{% url 'account:preferences' %}" class="dropdown-item">
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %} <i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
</a> </a>

View File

@ -10,7 +10,7 @@
</button> </button>
{% else %} {% else %}
<button type="submit" class="btn btn-cyan"> <button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bookmark-check"></i> {% trans "Bookmark" %} <i class="mdi mdi-bookmark-plus"></i> {% trans "Bookmark" %}
</button> </button>
{% endif %} {% endif %}
</form> </form>

View File

@ -0,0 +1,16 @@
{% load i18n %}
<form action="{{ form_url }}?return_url={{ return_url }}" method="post">
{% csrf_token %}
{% for field, value in form_data.items %}
<input type="hidden" name="{{ field }}" value="{{ value }}" />
{% endfor %}
{% if subscription %}
<button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bell-minus"></i> {% trans "Unsubscribe" %}
</button>
{% else %}
<button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bell-plus"></i> {% trans "Subscribe" %}
</button>
{% endif %}
</form>

View File

@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from core.models import ObjectType from core.models import ObjectType
from extras.models import Bookmark, ExportTemplate from extras.models import Bookmark, ExportTemplate, Subscription
from utilities.querydict import prepare_cloned_fields from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname from utilities.views import get_viewname
@ -17,6 +17,7 @@ __all__ = (
'edit_button', 'edit_button',
'export_button', 'export_button',
'import_button', 'import_button',
'subscribe_button',
'sync_button', 'sync_button',
) )
@ -94,6 +95,37 @@ def delete_button(instance):
} }
@register.inclusion_tag('buttons/subscribe.html', takes_context=True)
def subscribe_button(context, instance):
# Check if this user has already subscribed to the object
content_type = ContentType.objects.get_for_model(instance)
subscription = Subscription.objects.filter(
object_type=content_type,
object_id=instance.pk,
user=context['request'].user
).first()
# Compile form URL & data
if subscription:
form_url = reverse('extras:subscription_delete', kwargs={'pk': subscription.pk})
form_data = {
'confirm': 'true',
}
else:
form_url = reverse('extras:subscription_add')
form_data = {
'object_type': content_type.pk,
'object_id': instance.pk,
}
return {
'subscription': subscription,
'form_url': form_url,
'form_data': form_data,
'return_url': instance.get_absolute_url(),
}
@register.inclusion_tag('buttons/sync.html') @register.inclusion_tag('buttons/sync.html')
def sync_button(instance): def sync_button(instance):
viewname = get_viewname(instance, 'sync') viewname = get_viewname(instance, 'sync')