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
|
||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
|
||||
path('notifications/', views.NotificationListView.as_view(), name='notifications'),
|
||||
path('subscriptions/', views.SubscriptionListView.as_view(), name='subscriptions'),
|
||||
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
|
||||
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
|
||||
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
|
||||
|
@ -21,8 +21,8 @@ from social_core.backends.utils import load_backends
|
||||
from account.models import UserToken
|
||||
from core.models import ObjectChange
|
||||
from core.tables import ObjectChangeTable
|
||||
from extras.models import Bookmark
|
||||
from extras.tables import BookmarkTable
|
||||
from extras.models import Bookmark, Notification, Subscription
|
||||
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
|
||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||
from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
@ -267,6 +267,36 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Notifications & subscriptions
|
||||
#
|
||||
|
||||
class NotificationListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
table = NotificationTable
|
||||
template_name = 'account/notifications.html'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Notification.objects.filter(user=request.user)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
'active_tab': 'notifications',
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
table = SubscriptionTable
|
||||
template_name = 'account/subscriptions.html'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
'active_tab': 'subscriptions',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# User views for token management
|
||||
#
|
||||
|
@ -7,6 +7,7 @@ from .serializers_.dashboard import *
|
||||
from .serializers_.events import *
|
||||
from .serializers_.exporttemplates import *
|
||||
from .serializers_.journaling import *
|
||||
from .serializers_.notifications import *
|
||||
from .serializers_.configcontexts import *
|
||||
from .serializers_.configtemplates import *
|
||||
from .serializers_.savedfilters import *
|
||||
|
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('saved-filters', views.SavedFilterViewSet)
|
||||
router.register('bookmarks', views.BookmarkViewSet)
|
||||
router.register('notifications', views.NotificationViewSet)
|
||||
router.register('notification-groups', views.NotificationGroupViewSet)
|
||||
router.register('subscriptions', views.SubscriptionViewSet)
|
||||
router.register('tags', views.TagViewSet)
|
||||
router.register('image-attachments', views.ImageAttachmentViewSet)
|
||||
router.register('journal-entries', views.JournalEntryViewSet)
|
||||
|
@ -140,6 +140,28 @@ class BookmarkViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.BookmarkFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Notifications & subscriptions
|
||||
#
|
||||
|
||||
class NotificationViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Notification.objects.all()
|
||||
serializer_class = serializers.NotificationSerializer
|
||||
|
||||
|
||||
class NotificationGroupViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = NotificationGroup.objects.all()
|
||||
serializer_class = serializers.NotificationGroupSerializer
|
||||
|
||||
|
||||
class SubscriptionViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Subscription.objects.all()
|
||||
serializer_class = serializers.SubscriptionSerializer
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -8,6 +8,7 @@ from core.models import DataSource, ObjectType
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group
|
||||
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .choices import *
|
||||
@ -26,6 +27,7 @@ __all__ = (
|
||||
'ImageAttachmentFilterSet',
|
||||
'JournalEntryFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'NotificationGroupFilterSet',
|
||||
'ObjectTypeFilterSet',
|
||||
'SavedFilterFilterSet',
|
||||
'ScriptFilterSet',
|
||||
@ -336,6 +338,35 @@ class BookmarkFilterSet(BaseFilterSet):
|
||||
fields = ('id', 'object_id')
|
||||
|
||||
|
||||
class NotificationGroupFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
# user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
# queryset=get_user_model().objects.all(),
|
||||
# label=_('User (ID)'),
|
||||
# )
|
||||
# group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
# queryset=Group.objects.all(),
|
||||
# label=_('Group (ID)'),
|
||||
# )
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = (
|
||||
'id', 'name', 'description',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
@ -343,3 +343,17 @@ class JournalEntryBulkEditForm(BulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
class NotificationGroupBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=NotificationGroup.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
|
||||
nullable_fields = ('description',)
|
||||
|
@ -23,6 +23,7 @@ __all__ = (
|
||||
'EventRuleImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
'JournalEntryImportForm',
|
||||
'NotificationGroupImportForm',
|
||||
'SavedFilterImportForm',
|
||||
'TagImportForm',
|
||||
'WebhookImportForm',
|
||||
@ -250,3 +251,10 @@ class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
fields = (
|
||||
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
|
||||
)
|
||||
|
||||
|
||||
class NotificationGroupImportForm(CSVModelForm):
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = ('name', 'description')
|
||||
|
@ -9,6 +9,7 @@ from extras.models import *
|
||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||
from netbox.forms.mixins import SavedFiltersMixin
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
|
||||
@ -28,6 +29,7 @@ __all__ = (
|
||||
'ImageAttachmentFilterForm',
|
||||
'JournalEntryFilterForm',
|
||||
'LocalConfigContextFilterForm',
|
||||
'NotificationGroupFilterForm',
|
||||
'SavedFilterFilterForm',
|
||||
'TagFilterForm',
|
||||
'WebhookFilterForm',
|
||||
@ -496,3 +498,16 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=get_user_model().objects.all(),
|
||||
required=False,
|
||||
label=_('User')
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Group.objects.all(),
|
||||
required=False,
|
||||
label=_('Group')
|
||||
)
|
||||
|
@ -12,6 +12,7 @@ from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, User
|
||||
from utilities.forms import add_blank_choice, get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||
@ -32,7 +33,9 @@ __all__ = (
|
||||
'ExportTemplateForm',
|
||||
'ImageAttachmentForm',
|
||||
'JournalEntryForm',
|
||||
'NotificationGroupForm',
|
||||
'SavedFilterForm',
|
||||
'SubscriptionForm',
|
||||
'TagForm',
|
||||
'WebhookForm',
|
||||
)
|
||||
@ -238,6 +241,34 @@ class BookmarkForm(forms.ModelForm):
|
||||
fields = ('object_type', 'object_id')
|
||||
|
||||
|
||||
class NotificationGroupForm(forms.ModelForm):
|
||||
groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Groups'),
|
||||
required=False,
|
||||
queryset=Group.objects.all()
|
||||
)
|
||||
users = DynamicModelMultipleChoiceField(
|
||||
label=_('Users'),
|
||||
required=False,
|
||||
queryset=User.objects.all()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = ('name', 'description', 'groups', 'users')
|
||||
|
||||
|
||||
class SubscriptionForm(forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
label=_('Object type'),
|
||||
queryset=ObjectType.objects.with_feature('notifications')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = ('object_type', 'object_id')
|
||||
|
||||
|
||||
class WebhookForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
|
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 .dashboard import *
|
||||
from .models import *
|
||||
from .notifications import *
|
||||
from .scripts import *
|
||||
from .search 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.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextModelQuerySet',
|
||||
'ConfigContextQuerySet',
|
||||
'NotificationQuerySet',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
|
||||
@ -145,3 +151,9 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
)
|
||||
|
||||
return base_query
|
||||
|
||||
|
||||
class NotificationQuerySet(RestrictedQuerySet):
|
||||
|
||||
def unread(self):
|
||||
return self.filter(read__isnull=True)
|
||||
|
@ -12,15 +12,16 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from extras.choices import NotificationEventChoices, NotificationKindChoices
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.events import process_event_rules
|
||||
from extras.models import EventRule
|
||||
from extras.models import EventRule, Notification, Subscription
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .events import enqueue_object, get_snapshots, serialize_for_event
|
||||
from .events import enqueue_object
|
||||
from .models import CustomField, TaggedItem
|
||||
from .validators import CustomValidator
|
||||
|
||||
@ -281,3 +282,30 @@ def process_job_end_event_rules(sender, **kwargs):
|
||||
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)
|
||||
|
||||
|
||||
#
|
||||
# Notifications
|
||||
#
|
||||
|
||||
@receiver(post_save)
|
||||
def notify_object_changed(sender, instance, **kwargs):
|
||||
ct = ContentType.objects.get_for_model(instance)
|
||||
|
||||
# Find any Subscriptions for this object
|
||||
subscriptions = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values('user')
|
||||
|
||||
# Delete any existing Notifications for the object
|
||||
subscribed_users = [sub['user'] for sub in subscriptions]
|
||||
Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete()
|
||||
|
||||
# Create Notifications for Subscribers
|
||||
notifications = [
|
||||
Notification(
|
||||
user_id=sub['user'],
|
||||
object=instance,
|
||||
event=NotificationEventChoices.OBJECT_CHANGED
|
||||
)
|
||||
for sub in subscriptions
|
||||
]
|
||||
Notification.objects.bulk_create(notifications)
|
||||
|
@ -19,9 +19,12 @@ __all__ = (
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
'NotificationGroupTable',
|
||||
'NotificationTable',
|
||||
'SavedFilterTable',
|
||||
'ReportResultsTable',
|
||||
'ScriptResultsTable',
|
||||
'SubscriptionTable',
|
||||
'TaggedItemTable',
|
||||
'TagTable',
|
||||
'WebhookTable',
|
||||
@ -263,6 +266,58 @@ class BookmarkTable(NetBoxTable):
|
||||
default_columns = ('object', 'object_type', 'created')
|
||||
|
||||
|
||||
class NotificationTable(NetBoxTable):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Types'),
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
)
|
||||
created = columns.DateTimeColumn(
|
||||
timespec='minutes',
|
||||
verbose_name=_('Created'),
|
||||
)
|
||||
read = columns.DateTimeColumn(
|
||||
timespec='minutes',
|
||||
verbose_name=_('Read'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Notification
|
||||
fields = ('pk', 'object', 'object_type', 'created', 'read')
|
||||
default_columns = ('object', 'object_type', 'created', 'read')
|
||||
|
||||
|
||||
class NotificationGroupTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = NotificationGroup
|
||||
fields = ('pk', 'name', 'description', 'groups', 'users')
|
||||
default_columns = ('name', 'description')
|
||||
|
||||
|
||||
class SubscriptionTable(NetBoxTable):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Types'),
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Subscription
|
||||
fields = ('pk', 'object', 'object_type', 'created')
|
||||
default_columns = ('object', 'object_type', 'created')
|
||||
|
||||
|
||||
class WebhookTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
|
@ -53,6 +53,24 @@ urlpatterns = [
|
||||
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
|
||||
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
|
||||
|
||||
# Notification groups
|
||||
path('notification-groups/', views.NotificationGroupListView.as_view(), name='notificationgroup_list'),
|
||||
path('notification-groups/add/', views.NotificationGroupEditView.as_view(), name='notificationgroup_add'),
|
||||
path('notification-groups/import/', views.NotificationGroupBulkImportView.as_view(), name='notificationgroup_import'),
|
||||
# path('notification-groups/edit/', views.NotificationGroupBulkEditView.as_view(), name='notificationgroup_bulk_edit'),
|
||||
path('notification-groups/delete/', views.NotificationGroupBulkDeleteView.as_view(), name='notificationgroup_bulk_delete'),
|
||||
path('notification-groups/<int:pk>/', include(get_model_urls('extras', 'notificationgroup'))),
|
||||
|
||||
# Notifications
|
||||
path('notifications/', views.NotificationsView.as_view(), name='notifications'),
|
||||
path('notifications/delete/', views.NotificationBulkDeleteView.as_view(), name='notification_bulk_delete'),
|
||||
path('notifications/<int:pk>/', include(get_model_urls('extras', 'notification'))),
|
||||
|
||||
# Subscriptions
|
||||
path('subscriptions/add/', views.SubscriptionCreateView.as_view(), name='subscription_add'),
|
||||
path('subscriptions/delete/', views.SubscriptionBulkDeleteView.as_view(), name='subscription_bulk_delete'),
|
||||
path('subscriptions/<int:pk>/', include(get_model_urls('extras', 'subscription'))),
|
||||
|
||||
# Webhooks
|
||||
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
|
||||
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
|
||||
|
@ -6,6 +6,7 @@ from django.db.models import Count, Q
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
|
||||
@ -356,6 +357,130 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
|
||||
return Bookmark.objects.filter(user=request.user)
|
||||
|
||||
|
||||
#
|
||||
# Notification groups
|
||||
#
|
||||
|
||||
class NotificationGroupListView(generic.ObjectListView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
filterset = filtersets.NotificationGroupFilterSet
|
||||
filterset_form = forms.NotificationGroupFilterForm
|
||||
table = tables.NotificationGroupTable
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup)
|
||||
class NotificationGroupView(generic.ObjectView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'edit')
|
||||
class NotificationGroupEditView(generic.ObjectEditView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
form = forms.NotificationGroupForm
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'delete')
|
||||
class NotificationGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
|
||||
|
||||
class NotificationGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
model_form = forms.NotificationGroupImportForm
|
||||
|
||||
|
||||
# class NotificationGroupBulkEditView(generic.BulkEditView):
|
||||
# queryset = NotificationGroup.objects.all()
|
||||
# filterset = filtersets.NotificationGroupFilterSet
|
||||
# table = tables.NotificationGroupTable
|
||||
# form = forms.NotificationGroupBulkEditForm
|
||||
|
||||
|
||||
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
filterset = filtersets.NotificationGroupFilterSet
|
||||
table = tables.NotificationGroupTable
|
||||
|
||||
|
||||
#
|
||||
# Notifications
|
||||
#
|
||||
|
||||
class NotificationsView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request):
|
||||
notifications = Notification.objects.filter(
|
||||
user=request.user,
|
||||
read__isnull=True
|
||||
)
|
||||
return render(request, 'htmx/notifications.html', {
|
||||
'notifications': notifications,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
form = forms.RenderMarkdownForm(request.POST)
|
||||
if not form.is_valid():
|
||||
HttpResponseBadRequest()
|
||||
rendered = render_markdown(form.cleaned_data['text'])
|
||||
|
||||
return HttpResponse(rendered)
|
||||
|
||||
|
||||
@register_model_view(Notification, 'read')
|
||||
class NotificationReadView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request, pk):
|
||||
request.user.notifications.filter(pk=pk).update(read=timezone.now())
|
||||
|
||||
notifications = request.user.notifications.unread()[:10]
|
||||
return render(request, 'htmx/notifications.html', {
|
||||
'notifications': notifications,
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Notification, 'delete')
|
||||
class NotificationDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Notification.objects.filter(user=request.user)
|
||||
|
||||
|
||||
class NotificationBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.NotificationTable
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Notification.objects.filter(user=request.user)
|
||||
|
||||
|
||||
#
|
||||
# Subscriptions
|
||||
#
|
||||
|
||||
class SubscriptionCreateView(generic.ObjectEditView):
|
||||
form = forms.SubscriptionForm
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
obj.user = request.user
|
||||
return obj
|
||||
|
||||
|
||||
@register_model_view(Subscription, 'delete')
|
||||
class SubscriptionDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
|
||||
class SubscriptionBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.SubscriptionTable
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
@ -29,6 +29,7 @@ class NetBoxFeatureSet(
|
||||
CustomValidationMixin,
|
||||
ExportTemplatesMixin,
|
||||
JournalingMixin,
|
||||
NotificationsMixin,
|
||||
TagsMixin,
|
||||
EventRulesMixin
|
||||
):
|
||||
|
@ -34,6 +34,7 @@ __all__ = (
|
||||
'ImageAttachmentsMixin',
|
||||
'JobsMixin',
|
||||
'JournalingMixin',
|
||||
'NotificationsMixin',
|
||||
'SyncedDataMixin',
|
||||
'TagsMixin',
|
||||
'register_models',
|
||||
@ -377,6 +378,25 @@ class BookmarksMixin(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class NotificationsMixin(models.Model):
|
||||
"""
|
||||
Enables support for user notifications.
|
||||
"""
|
||||
notifications = GenericRelation(
|
||||
to='extras.Notification',
|
||||
content_type_field='object_type',
|
||||
object_id_field='object_id'
|
||||
)
|
||||
subscriptions = GenericRelation(
|
||||
to='extras.Subscription',
|
||||
content_type_field='object_type',
|
||||
object_id_field='object_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class JobsMixin(models.Model):
|
||||
"""
|
||||
Enables support for job results.
|
||||
@ -582,13 +602,14 @@ FEATURES_MAP = {
|
||||
'custom_fields': CustomFieldsMixin,
|
||||
'custom_links': CustomLinksMixin,
|
||||
'custom_validation': CustomValidationMixin,
|
||||
'event_rules': EventRulesMixin,
|
||||
'export_templates': ExportTemplatesMixin,
|
||||
'image_attachments': ImageAttachmentsMixin,
|
||||
'jobs': JobsMixin,
|
||||
'journaling': JournalingMixin,
|
||||
'notifications': NotificationsMixin,
|
||||
'synced_data': SyncedDataMixin,
|
||||
'tags': TagsMixin,
|
||||
'event_rules': EventRulesMixin,
|
||||
}
|
||||
|
||||
registry['model_features'].update({
|
||||
|
@ -355,6 +355,7 @@ OPERATIONS_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Logging'),
|
||||
items=(
|
||||
get_model_item('extras', 'notificationgroup', _('Notification Groups')),
|
||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
|
||||
get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
|
||||
),
|
||||
|
@ -84,6 +84,11 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
|
||||
'extras.add_bookmark': ({'user': '$user'},),
|
||||
'extras.change_bookmark': ({'user': '$user'},),
|
||||
'extras.delete_bookmark': ({'user': '$user'},),
|
||||
# Permit users to manage their own subscriptions
|
||||
'extras.view_subscription': ({'user': '$user'},),
|
||||
'extras.add_subscription': ({'user': '$user'},),
|
||||
'extras.change_subscription': ({'user': '$user'},),
|
||||
'extras.delete_subscription': ({'user': '$user'},),
|
||||
# Permit users to manage their own API tokens
|
||||
'users.view_token': ({'user': '$user'},),
|
||||
'users.add_token': ({'user': '$user'},),
|
||||
|
@ -9,6 +9,12 @@
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'notifications' %} active{% endif %}" href="{% url 'account:notifications' %}">{% trans "Notifications" %}</a>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'subscriptions' %} active{% endif %}" href="{% url 'account:subscriptions' %}">{% trans "Subscriptions" %}</a>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
|
||||
</li>
|
||||
|
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 %}
|
||||
{% bookmark_button object %}
|
||||
{% endif %}
|
||||
{% if perms.extras.add_subscription %}
|
||||
{% subscribe_button object %}
|
||||
{% endif %}
|
||||
{% if request.user|can_add:object %}
|
||||
{% clone_button object %}
|
||||
{% 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 %}
|
||||
|
||||
{% 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">
|
||||
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
|
||||
<div class="d-xl-block ps-2">
|
||||
@ -29,6 +44,9 @@
|
||||
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
|
||||
</a>
|
||||
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
|
||||
</a>
|
||||
<a href="{% url 'account:preferences' %}" class="dropdown-item">
|
||||
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
|
||||
</a>
|
||||
|
@ -10,7 +10,7 @@
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-cyan">
|
||||
<i class="mdi mdi-bookmark-check"></i> {% trans "Bookmark" %}
|
||||
<i class="mdi mdi-bookmark-plus"></i> {% trans "Bookmark" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
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 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.views import get_viewname
|
||||
|
||||
@ -17,6 +17,7 @@ __all__ = (
|
||||
'edit_button',
|
||||
'export_button',
|
||||
'import_button',
|
||||
'subscribe_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')
|
||||
def sync_button(instance):
|
||||
viewname = get_viewname(instance, 'sync')
|
||||
|
Loading…
Reference in New Issue
Block a user