mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-09 00:58:16 -06:00
Initial work on #15621
This commit is contained in:
parent
5ac5135dbc
commit
865619baca
@ -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'),
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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 *
|
||||||
|
82
netbox/extras/api/serializers_/notifications.py
Normal file
82
netbox/extras/api/serializers_/notifications.py
Normal 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
|
@ -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)
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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',
|
||||||
|
@ -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',)
|
||||||
|
@ -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')
|
||||||
|
@ -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')
|
||||||
|
)
|
||||||
|
@ -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 = (
|
||||||
|
77
netbox/extras/migrations/0118_notifications.py
Normal file
77
netbox/extras/migrations/0118_notifications.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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 *
|
||||||
|
171
netbox/extras/models/notifications.py
Normal file
171
netbox/extras/models/notifications.py
Normal 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)
|
||||||
|
)
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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'),
|
||||||
|
@ -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'),
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -29,6 +29,7 @@ class NetBoxFeatureSet(
|
|||||||
CustomValidationMixin,
|
CustomValidationMixin,
|
||||||
ExportTemplatesMixin,
|
ExportTemplatesMixin,
|
||||||
JournalingMixin,
|
JournalingMixin,
|
||||||
|
NotificationsMixin,
|
||||||
TagsMixin,
|
TagsMixin,
|
||||||
EventRulesMixin
|
EventRulesMixin
|
||||||
):
|
):
|
||||||
|
@ -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({
|
||||||
|
@ -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=[]),
|
||||||
),
|
),
|
||||||
|
@ -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'},),
|
||||||
|
@ -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>
|
||||||
|
32
netbox/templates/account/notifications.html
Normal file
32
netbox/templates/account/notifications.html
Normal 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 %}
|
32
netbox/templates/account/subscriptions.html
Normal file
32
netbox/templates/account/subscriptions.html
Normal 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 %}
|
@ -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 %}
|
||||||
|
24
netbox/templates/htmx/notifications.html
Normal file
24
netbox/templates/htmx/notifications.html
Normal 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>
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
16
netbox/utilities/templates/buttons/subscribe.html
Normal file
16
netbox/utilities/templates/buttons/subscribe.html
Normal 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>
|
@ -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')
|
||||||
|
Loading…
Reference in New Issue
Block a user