mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-13 16:47:34 -06:00
* Initial work on #15621 * Signal receiver should ignore models which don't support notifications * Flesh out NotificationGroup functionality * Add NotificationGroup filters for users & groups * Separate read & dimiss actions * Enable one-click dismissals from notifications list * Include total notification count in dropdown * Drop 'kind' field from Notification model * Register event types in the registry; add colors & icons * Enable event rules to target notification groups * Define dynamic choices for Notification.event_name * Move event registration to core * Add more job events * Misc cleanup * Misc cleanup * Correct absolute URLs for notifications & subscriptions * Optimize subscriber notifications * Use core event types when queuing events * Standardize queued event attribute to event_type; change content_type to object_type * Rename Notification.event_name to event_type * Restore NotificationGroupBulkEditView * Add API tests * Add view & filterset tests * Add model documentation * Fix tests * Update notification bell when notifications have been cleared * Ensure subscribe button appears only on relevant models * Notifications/subscriptions cannot be ordered by object * Misc cleanup * Add event icon & type to notifications table * Adjust icon sizing * Mute color of read notifications * Misc cleanup
This commit is contained in:
parent
1c2336be60
commit
b0e7294bc1
17
docs/models/extras/notification.md
Normal file
17
docs/models/extras/notification.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Notification
|
||||
|
||||
A notification alerts a user that a specific action has taken place in NetBox, such as an object being modified or a background job completing. A notification may be generated via a user's [subscription](./subscription.md) to a particular object, or by an event rule targeting a [notification group](./notificationgroup.md) of which the user is a member.
|
||||
|
||||
## Fields
|
||||
|
||||
### User
|
||||
|
||||
The recipient of the notification.
|
||||
|
||||
### Object
|
||||
|
||||
The object to which the notification relates.
|
||||
|
||||
### Event Type
|
||||
|
||||
The type of event indicated by the notification.
|
17
docs/models/extras/notificationgroup.md
Normal file
17
docs/models/extras/notificationgroup.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Notification Group
|
||||
|
||||
A set of NetBox users and/or groups of users identified as recipients for certain [notifications](./notification.md).
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
The name of the notification group.
|
||||
|
||||
### Users
|
||||
|
||||
One or more users directly designated as members of the notification group.
|
||||
|
||||
### Groups
|
||||
|
||||
All users of any selected groups are considered as members of the notification group.
|
15
docs/models/extras/subscription.md
Normal file
15
docs/models/extras/subscription.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Subscription
|
||||
|
||||
A record indicating that a user is to be notified of any changes to a particular NetBox object. A notification maps exactly one user to exactly one object.
|
||||
|
||||
When an object to which a user is subscribed changes, a [notification](./notification.md) is generated for the user.
|
||||
|
||||
## Fields
|
||||
|
||||
### User
|
||||
|
||||
The subscribed user.
|
||||
|
||||
### Object
|
||||
|
||||
The object to which the user is subscribed.
|
@ -225,8 +225,11 @@ nav:
|
||||
- ExportTemplate: 'models/extras/exporttemplate.md'
|
||||
- ImageAttachment: 'models/extras/imageattachment.md'
|
||||
- JournalEntry: 'models/extras/journalentry.md'
|
||||
- Notification: 'models/extras/notification.md'
|
||||
- NotificationGroup: 'models/extras/notificationgroup.md'
|
||||
- SavedFilter: 'models/extras/savedfilter.md'
|
||||
- StagedChange: 'models/extras/stagedchange.md'
|
||||
- Subscription: 'models/extras/subscription.md'
|
||||
- Tag: 'models/extras/tag.md'
|
||||
- Webhook: 'models/extras/webhook.md'
|
||||
- IPAM:
|
||||
|
@ -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'),
|
||||
|
@ -22,7 +22,7 @@ from account.models import UserToken
|
||||
from core.models import ObjectChange
|
||||
from core.tables import ObjectChangeTable
|
||||
from extras.models import Bookmark
|
||||
from extras.tables import BookmarkTable
|
||||
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
|
||||
from netbox.authentication import get_auth_backend_display, get_saml_idps
|
||||
from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
@ -267,6 +267,36 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Notifications & subscriptions
|
||||
#
|
||||
|
||||
class NotificationListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
table = NotificationTable
|
||||
template_name = 'account/notifications.html'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return request.user.notifications.all()
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
'active_tab': 'notifications',
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView):
|
||||
table = SubscriptionTable
|
||||
template_name = 'account/subscriptions.html'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return request.user.subscriptions.all()
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
'active_tab': 'subscriptions',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# User views for token management
|
||||
#
|
||||
|
@ -18,7 +18,7 @@ class CoreConfig(AppConfig):
|
||||
def ready(self):
|
||||
from core.api import schema # noqa
|
||||
from netbox.models.features import register_models
|
||||
from . import data_backends, search
|
||||
from . import data_backends, events, search
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
33
netbox/core/events.py
Normal file
33
netbox/core/events.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.events import *
|
||||
|
||||
__all__ = (
|
||||
'JOB_COMPLETED',
|
||||
'JOB_ERRORED',
|
||||
'JOB_FAILED',
|
||||
'JOB_STARTED',
|
||||
'OBJECT_CREATED',
|
||||
'OBJECT_DELETED',
|
||||
'OBJECT_UPDATED',
|
||||
)
|
||||
|
||||
# Object events
|
||||
OBJECT_CREATED = 'object_created'
|
||||
OBJECT_UPDATED = 'object_updated'
|
||||
OBJECT_DELETED = 'object_deleted'
|
||||
|
||||
# Job events
|
||||
JOB_STARTED = 'job_started'
|
||||
JOB_COMPLETED = 'job_completed'
|
||||
JOB_FAILED = 'job_failed'
|
||||
JOB_ERRORED = 'job_errored'
|
||||
|
||||
# Register core events
|
||||
Event(name=OBJECT_CREATED, text=_('Object created')).register()
|
||||
Event(name=OBJECT_UPDATED, text=_('Object updated')).register()
|
||||
Event(name=OBJECT_DELETED, text=_('Object deleted')).register()
|
||||
Event(name=JOB_STARTED, text=_('Job started')).register()
|
||||
Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register()
|
||||
Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register()
|
||||
Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register()
|
@ -13,7 +13,6 @@ from django.utils.translation import gettext as _
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
@ -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, NotificationGroup, Subscription
|
||||
from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import ValidatedModelSerializer
|
||||
from users.api.serializers_.users import GroupSerializer, UserSerializer
|
||||
from users.models import Group, User
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
__all__ = (
|
||||
'NotificationSerializer',
|
||||
'NotificationGroupSerializer',
|
||||
'SubscriptionSerializer',
|
||||
)
|
||||
|
||||
|
||||
class NotificationSerializer(ValidatedModelSerializer):
|
||||
object_type = ContentTypeField(
|
||||
queryset=ObjectType.objects.with_feature('notifications'),
|
||||
)
|
||||
object = serializers.SerializerMethodField(read_only=True)
|
||||
user = UserSerializer(nested=True)
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'read', 'event_type',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user', 'read', 'event_type')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_object(self, instance):
|
||||
serializer = get_serializer_for_model(instance.object)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(instance.object, nested=True, context=context).data
|
||||
|
||||
|
||||
class NotificationGroupSerializer(ValidatedModelSerializer):
|
||||
groups = SerializedPKRelatedField(
|
||||
queryset=Group.objects.all(),
|
||||
serializer=GroupSerializer,
|
||||
nested=True,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
users = SerializedPKRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
serializer=UserSerializer,
|
||||
nested=True,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = [
|
||||
'id', 'url', 'display', 'display_url', 'name', 'description', 'groups', 'users',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class SubscriptionSerializer(ValidatedModelSerializer):
|
||||
object_type = ContentTypeField(
|
||||
queryset=ObjectType.objects.with_feature('notifications'),
|
||||
)
|
||||
object = serializers.SerializerMethodField(read_only=True)
|
||||
user = UserSerializer(nested=True)
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = [
|
||||
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_object(self, instance):
|
||||
serializer = get_serializer_for_model(instance.object)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(instance.object, nested=True, context=context).data
|
@ -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,27 @@ class BookmarkViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.BookmarkFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Notifications & subscriptions
|
||||
#
|
||||
|
||||
class NotificationViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Notification.objects.all()
|
||||
serializer_class = serializers.NotificationSerializer
|
||||
|
||||
|
||||
class NotificationGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
serializer_class = serializers.NotificationGroupSerializer
|
||||
|
||||
|
||||
class SubscriptionViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Subscription.objects.all()
|
||||
serializer_class = serializers.SubscriptionSerializer
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet):
|
||||
|
||||
WEBHOOK = 'webhook'
|
||||
SCRIPT = 'script'
|
||||
NOTIFICATION = 'notification'
|
||||
|
||||
CHOICES = (
|
||||
(WEBHOOK, _('Webhook')),
|
||||
(SCRIPT, _('Script')),
|
||||
(NOTIFICATION, _('Notification')),
|
||||
)
|
||||
|
@ -1,12 +1,6 @@
|
||||
from core.events import *
|
||||
from extras.choices import LogLevelChoices
|
||||
|
||||
# Events
|
||||
EVENT_CREATE = 'create'
|
||||
EVENT_UPDATE = 'update'
|
||||
EVENT_DELETE = 'delete'
|
||||
EVENT_JOB_START = 'job_start'
|
||||
EVENT_JOB_END = 'job_end'
|
||||
|
||||
# Custom fields
|
||||
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
|
||||
|
||||
@ -14,11 +8,14 @@ CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
|
||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
WEBHOOK_EVENT_TYPES = {
|
||||
EVENT_CREATE: 'created',
|
||||
EVENT_UPDATE: 'updated',
|
||||
EVENT_DELETE: 'deleted',
|
||||
EVENT_JOB_START: 'job_started',
|
||||
EVENT_JOB_END: 'job_ended',
|
||||
# Map registered event types to public webhook "event" equivalents
|
||||
OBJECT_CREATED: 'created',
|
||||
OBJECT_UPDATED: 'updated',
|
||||
OBJECT_DELETED: 'deleted',
|
||||
JOB_STARTED: 'job_started',
|
||||
JOB_COMPLETED: 'job_ended',
|
||||
JOB_FAILED: 'job_ended',
|
||||
JOB_ERRORED: 'job_ended',
|
||||
}
|
||||
|
||||
# Dashboard
|
||||
|
@ -8,7 +8,7 @@ from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django_rq import get_queue
|
||||
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.events import *
|
||||
from core.models import Job
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
@ -35,12 +35,12 @@ def serialize_for_event(instance):
|
||||
return serializer.data
|
||||
|
||||
|
||||
def get_snapshots(instance, action):
|
||||
def get_snapshots(instance, event_type):
|
||||
snapshots = {
|
||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||
'postchange': None,
|
||||
}
|
||||
if action != ObjectChangeActionChoices.ACTION_DELETE:
|
||||
if event_type != OBJECT_DELETED:
|
||||
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
|
||||
if hasattr(instance, 'serialize_object'):
|
||||
snapshots['postchange'] = instance.serialize_object()
|
||||
@ -50,7 +50,7 @@ def get_snapshots(instance, action):
|
||||
return snapshots
|
||||
|
||||
|
||||
def enqueue_object(queue, instance, user, request_id, action):
|
||||
def enqueue_event(queue, instance, user, request_id, event_type):
|
||||
"""
|
||||
Enqueue a serialized representation of a created/updated/deleted object for the processing of
|
||||
events once the request has completed.
|
||||
@ -65,27 +65,24 @@ def enqueue_object(queue, instance, user, request_id, action):
|
||||
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||
if key in queue:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
|
||||
# If the object is being deleted, update any prior "update" event to "delete"
|
||||
if action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||
queue[key]['event'] = action
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['event_type'] = event_type
|
||||
else:
|
||||
queue[key] = {
|
||||
'content_type': ContentType.objects.get_for_model(instance),
|
||||
'object_type': ContentType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event': action,
|
||||
'event_type': event_type,
|
||||
'data': serialize_for_event(instance),
|
||||
'snapshots': get_snapshots(instance, action),
|
||||
'snapshots': get_snapshots(instance, event_type),
|
||||
'username': user.username,
|
||||
'request_id': request_id
|
||||
}
|
||||
|
||||
|
||||
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
|
||||
if username:
|
||||
user = get_user_model().objects.get(username=username)
|
||||
else:
|
||||
user = None
|
||||
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None):
|
||||
user = get_user_model().objects.get(username=username) if username else None
|
||||
|
||||
for event_rule in event_rules:
|
||||
|
||||
@ -103,8 +100,8 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
|
||||
# Compile the task parameters
|
||||
params = {
|
||||
"event_rule": event_rule,
|
||||
"model_name": model_name,
|
||||
"event": event,
|
||||
"model_name": object_type.model,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
"snapshots": snapshots,
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
@ -136,6 +133,15 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
|
||||
data=data
|
||||
)
|
||||
|
||||
# Notification groups
|
||||
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
|
||||
# Bulk-create notifications for all members of the notification group
|
||||
event_rule.action_object.notify(
|
||||
object_type=object_type,
|
||||
object_id=data['id'],
|
||||
event_type=event_type
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
|
||||
action_type=event_rule.action_type
|
||||
@ -151,27 +157,39 @@ def process_event_queue(events):
|
||||
'type_update': {},
|
||||
'type_delete': {},
|
||||
}
|
||||
event_actions = {
|
||||
# TODO: Add EventRule support for dynamically registered event types
|
||||
OBJECT_CREATED: 'type_create',
|
||||
OBJECT_UPDATED: 'type_update',
|
||||
OBJECT_DELETED: 'type_delete',
|
||||
JOB_STARTED: 'type_job_start',
|
||||
JOB_COMPLETED: 'type_job_end',
|
||||
# Map failed & errored jobs to type_job_end
|
||||
JOB_FAILED: 'type_job_end',
|
||||
JOB_ERRORED: 'type_job_end',
|
||||
}
|
||||
|
||||
for data in events:
|
||||
action_flag = {
|
||||
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
||||
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
||||
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
||||
}[data['event']]
|
||||
content_type = data['content_type']
|
||||
for event in events:
|
||||
action_flag = event_actions[event['event_type']]
|
||||
object_type = event['object_type']
|
||||
|
||||
# Cache applicable Event Rules
|
||||
if content_type not in events_cache[action_flag]:
|
||||
events_cache[action_flag][content_type] = EventRule.objects.filter(
|
||||
if object_type not in events_cache[action_flag]:
|
||||
events_cache[action_flag][object_type] = EventRule.objects.filter(
|
||||
**{action_flag: True},
|
||||
object_types=content_type,
|
||||
object_types=object_type,
|
||||
enabled=True
|
||||
)
|
||||
event_rules = events_cache[action_flag][content_type]
|
||||
event_rules = events_cache[action_flag][object_type]
|
||||
|
||||
process_event_rules(
|
||||
event_rules, content_type.model, data['event'], data['data'], data['username'],
|
||||
snapshots=data['snapshots'], request_id=data['request_id']
|
||||
event_rules=event_rules,
|
||||
object_type=object_type,
|
||||
event_type=event['event_type'],
|
||||
data=event['data'],
|
||||
username=event['username'],
|
||||
snapshots=event['snapshots'],
|
||||
request_id=event['request_id']
|
||||
)
|
||||
|
||||
|
||||
|
@ -8,6 +8,7 @@ from core.models import DataSource, ObjectType
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, User
|
||||
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .choices import *
|
||||
@ -26,6 +27,7 @@ __all__ = (
|
||||
'ImageAttachmentFilterSet',
|
||||
'JournalEntryFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'NotificationGroupFilterSet',
|
||||
'ObjectTypeFilterSet',
|
||||
'SavedFilterFilterSet',
|
||||
'ScriptFilterSet',
|
||||
@ -336,6 +338,49 @@ class BookmarkFilterSet(BaseFilterSet):
|
||||
fields = ('id', 'object_id')
|
||||
|
||||
|
||||
class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='users',
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='users__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label=_('User (name)'),
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='groups',
|
||||
queryset=Group.objects.all(),
|
||||
label=_('Group (ID)'),
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='groups__name',
|
||||
queryset=Group.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Group (name)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = (
|
||||
'id', 'name', 'description',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
@ -18,6 +18,7 @@ __all__ = (
|
||||
'EventRuleBulkEditForm',
|
||||
'ExportTemplateBulkEditForm',
|
||||
'JournalEntryBulkEditForm',
|
||||
'NotificationGroupBulkEditForm',
|
||||
'SavedFilterBulkEditForm',
|
||||
'TagBulkEditForm',
|
||||
'WebhookBulkEditForm',
|
||||
@ -343,3 +344,17 @@ class JournalEntryBulkEditForm(BulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
class NotificationGroupBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=NotificationGroup.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
|
||||
nullable_fields = ('description',)
|
||||
|
@ -3,16 +3,17 @@ import re
|
||||
from django import forms
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from users.models import Group, User
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField,
|
||||
SlugField,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@ -23,6 +24,7 @@ __all__ = (
|
||||
'EventRuleImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
'JournalEntryImportForm',
|
||||
'NotificationGroupImportForm',
|
||||
'SavedFilterImportForm',
|
||||
'TagImportForm',
|
||||
'WebhookImportForm',
|
||||
@ -247,3 +249,24 @@ class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
fields = (
|
||||
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
|
||||
)
|
||||
|
||||
|
||||
class NotificationGroupImportForm(CSVModelForm):
|
||||
users = CSVModelMultipleChoiceField(
|
||||
label=_('Users'),
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
to_field_name='username',
|
||||
help_text=_('User names separated by commas, encased with double quotes')
|
||||
)
|
||||
groups = CSVModelMultipleChoiceField(
|
||||
label=_('Groups'),
|
||||
queryset=Group.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Group names separated by commas, encased with double quotes')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = ('name', 'description', 'users', 'groups')
|
||||
|
@ -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,43 @@ class BookmarkForm(forms.ModelForm):
|
||||
fields = ('object_type', 'object_id')
|
||||
|
||||
|
||||
class NotificationGroupForm(forms.ModelForm):
|
||||
groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Groups'),
|
||||
required=False,
|
||||
queryset=Group.objects.all()
|
||||
)
|
||||
users = DynamicModelMultipleChoiceField(
|
||||
label=_('Users'),
|
||||
required=False,
|
||||
queryset=User.objects.all()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NotificationGroup
|
||||
fields = ('name', 'description', 'groups', 'users')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# At least one User or Group must be assigned
|
||||
if not self.cleaned_data['groups'] and not self.cleaned_data['users']:
|
||||
raise forms.ValidationError(_("A notification group specify at least one user or group."))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class SubscriptionForm(forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
label=_('Object type'),
|
||||
queryset=ObjectType.objects.with_feature('notifications')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Subscription
|
||||
fields = ('object_type', 'object_id')
|
||||
|
||||
|
||||
class WebhookForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
@ -329,6 +369,18 @@ class EventRuleForm(NetBoxModelForm):
|
||||
initial=initial
|
||||
)
|
||||
|
||||
def init_notificationgroup_choice(self):
|
||||
initial = None
|
||||
if self.instance.action_type == EventRuleActionChoices.NOTIFICATION:
|
||||
notificationgroup_id = get_field_value(self, 'action_object_id')
|
||||
initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None
|
||||
self.fields['action_choice'] = DynamicModelChoiceField(
|
||||
label=_('Notification group'),
|
||||
queryset=NotificationGroup.objects.all(),
|
||||
required=True,
|
||||
initial=initial
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['action_object_type'].required = False
|
||||
@ -341,6 +393,8 @@ class EventRuleForm(NetBoxModelForm):
|
||||
self.init_webhook_choice()
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
self.init_script_choice()
|
||||
elif action_type == EventRuleActionChoices.NOTIFICATION:
|
||||
self.init_notificationgroup_choice()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@ -357,6 +411,10 @@ class EventRuleForm(NetBoxModelForm):
|
||||
for_concrete_model=False
|
||||
)
|
||||
self.cleaned_data['action_object_id'] = action_choice.id
|
||||
# Notification
|
||||
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION:
|
||||
self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
|
||||
self.cleaned_data['action_object_id'] = action_choice.id
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
@ -13,6 +13,7 @@ __all__ = (
|
||||
'ExportTemplateFilter',
|
||||
'ImageAttachmentFilter',
|
||||
'JournalEntryFilter',
|
||||
'NotificationGroupFilter',
|
||||
'SavedFilterFilter',
|
||||
'TagFilter',
|
||||
'WebhookFilter',
|
||||
@ -67,6 +68,12 @@ class JournalEntryFilter(BaseFilterMixin):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.filter(models.NotificationGroup, lookups=True)
|
||||
@autotype_decorator(filtersets.NotificationGroupFilterSet)
|
||||
class NotificationGroupFilter(BaseFilterMixin):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.filter(models.SavedFilter, lookups=True)
|
||||
@autotype_decorator(filtersets.SavedFilterFilterSet)
|
||||
class SavedFilterFilter(BaseFilterMixin):
|
||||
|
@ -54,6 +54,21 @@ class ExtrasQuery:
|
||||
return models.JournalEntry.objects.get(pk=id)
|
||||
journal_entry_list: List[JournalEntryType] = strawberry_django.field()
|
||||
|
||||
@strawberry.field
|
||||
def notification(self, id: int) -> NotificationType:
|
||||
return models.Notification.objects.get(pk=id)
|
||||
notification_list: List[NotificationType] = strawberry_django.field()
|
||||
|
||||
@strawberry.field
|
||||
def notification_group(self, id: int) -> NotificationGroupType:
|
||||
return models.NotificationGroup.objects.get(pk=id)
|
||||
notification_group_list: List[NotificationGroupType] = strawberry_django.field()
|
||||
|
||||
@strawberry.field
|
||||
def subscription(self, id: int) -> SubscriptionType:
|
||||
return models.Subscription.objects.get(pk=id)
|
||||
subscription_list: List[SubscriptionType] = strawberry_django.field()
|
||||
|
||||
@strawberry.field
|
||||
def tag(self, id: int) -> TagType:
|
||||
return models.Tag.objects.get(pk=id)
|
||||
|
@ -18,7 +18,10 @@ __all__ = (
|
||||
'ExportTemplateType',
|
||||
'ImageAttachmentType',
|
||||
'JournalEntryType',
|
||||
'NotificationGroupType',
|
||||
'NotificationType',
|
||||
'SavedFilterType',
|
||||
'SubscriptionType',
|
||||
'TagType',
|
||||
'WebhookType',
|
||||
)
|
||||
@ -122,6 +125,23 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
|
||||
created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.Notification,
|
||||
# filters=NotificationFilter
|
||||
)
|
||||
class NotificationType(ObjectType):
|
||||
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.NotificationGroup,
|
||||
filters=NotificationGroupFilter
|
||||
)
|
||||
class NotificationGroupType(ObjectType):
|
||||
users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]]
|
||||
groups: List[Annotated["GroupType", strawberry.lazy('users.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.SavedFilter,
|
||||
exclude=['content_types',],
|
||||
@ -131,6 +151,14 @@ class SavedFilterType(ObjectType):
|
||||
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.Subscription,
|
||||
# filters=NotificationFilter
|
||||
)
|
||||
class SubscriptionType(ObjectType):
|
||||
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.Tag,
|
||||
exclude=['extras_taggeditem_items', ],
|
||||
|
78
netbox/extras/migrations/0118_notifications.py
Normal file
78
netbox/extras/migrations/0118_notifications.py
Normal file
@ -0,0 +1,78 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0117_customfield_uniqueness'),
|
||||
('users', '0009_update_group_perms'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('groups', models.ManyToManyField(blank=True, related_name='notification_groups', to='users.group')),
|
||||
('users', models.ManyToManyField(blank=True, related_name='notification_groups', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'notification group',
|
||||
'verbose_name_plural': 'notification groups',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'subscription',
|
||||
'verbose_name_plural': 'subscriptions',
|
||||
'ordering': ('-created', 'user'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('read', models.DateTimeField(blank=True, null=True)),
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('event_type', models.CharField(max_length=50)),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'notification',
|
||||
'verbose_name_plural': 'notifications',
|
||||
'ordering': ('-created', 'pk'),
|
||||
'indexes': [models.Index(fields=['object_type', 'object_id'], name='extras_noti_object__be74d5_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='notification',
|
||||
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_notification_unique_per_object_and_user'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='subscription',
|
||||
index=models.Index(fields=['object_type', 'object_id'], name='extras_subs_object__37ef68_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='subscription',
|
||||
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_subscription_unique_per_object_and_user'),
|
||||
),
|
||||
]
|
@ -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 *
|
||||
|
222
netbox/extras/models/notifications.py
Normal file
222
netbox/extras/models/notifications.py
Normal file
@ -0,0 +1,222 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.querysets import NotificationQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.registry import registry
|
||||
from users.models import User
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'Notification',
|
||||
'NotificationGroup',
|
||||
'Subscription',
|
||||
)
|
||||
|
||||
|
||||
def get_event_type_choices():
|
||||
"""
|
||||
Compile a list of choices from all registered event types
|
||||
"""
|
||||
return [
|
||||
(name, event.text)
|
||||
for name, event in registry['events'].items()
|
||||
]
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
"""
|
||||
A notification message for a User relating to a specific object in NetBox.
|
||||
"""
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
)
|
||||
read = models.DateTimeField(
|
||||
verbose_name=_('read'),
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='notifications'
|
||||
)
|
||||
object_type = models.ForeignKey(
|
||||
to='contenttypes.ContentType',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
object_id = models.PositiveBigIntegerField()
|
||||
object = GenericForeignKey(
|
||||
ct_field='object_type',
|
||||
fk_field='object_id'
|
||||
)
|
||||
event_type = models.CharField(
|
||||
verbose_name=_('event'),
|
||||
max_length=50,
|
||||
choices=get_event_type_choices
|
||||
)
|
||||
|
||||
objects = NotificationQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created', 'pk')
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id')),
|
||||
)
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('object_type', 'object_id', 'user'),
|
||||
name='%(app_label)s_%(class)s_unique_per_object_and_user'
|
||||
),
|
||||
)
|
||||
verbose_name = _('notification')
|
||||
verbose_name_plural = _('notifications')
|
||||
|
||||
def __str__(self):
|
||||
if self.object:
|
||||
return str(self.object)
|
||||
return super().__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('account:notifications')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the assigned object type
|
||||
if self.object_type not in ObjectType.objects.with_feature('notifications'):
|
||||
raise ValidationError(
|
||||
_("Objects of this type ({type}) do not support notifications.").format(type=self.object_type)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def event(self):
|
||||
"""
|
||||
Returns the registered Event which triggered this Notification.
|
||||
"""
|
||||
return registry['events'].get(self.event_type)
|
||||
|
||||
|
||||
class NotificationGroup(ChangeLoggedModel):
|
||||
"""
|
||||
A collection of users and/or groups to be informed for certain notifications.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
groups = models.ManyToManyField(
|
||||
to='users.Group',
|
||||
verbose_name=_('groups'),
|
||||
blank=True,
|
||||
related_name='notification_groups'
|
||||
)
|
||||
users = models.ManyToManyField(
|
||||
to='users.User',
|
||||
verbose_name=_('users'),
|
||||
blank=True,
|
||||
related_name='notification_groups'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('notification group')
|
||||
verbose_name_plural = _('notification groups')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:notificationgroup', args=[self.pk])
|
||||
|
||||
@cached_property
|
||||
def members(self):
|
||||
"""
|
||||
Return all Users who belong to this notification group.
|
||||
"""
|
||||
return self.users.union(
|
||||
User.objects.filter(groups__in=self.groups.all())
|
||||
).order_by('username')
|
||||
|
||||
def notify(self, **kwargs):
|
||||
"""
|
||||
Bulk-create Notifications for all members of this group.
|
||||
"""
|
||||
Notification.objects.bulk_create([
|
||||
Notification(user=member, **kwargs)
|
||||
for member in self.members
|
||||
])
|
||||
notify.alters_data = True
|
||||
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
A User's subscription to a particular object, to be notified of changes.
|
||||
"""
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_('created'),
|
||||
auto_now_add=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='subscriptions'
|
||||
)
|
||||
object_type = models.ForeignKey(
|
||||
to='contenttypes.ContentType',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
object_id = models.PositiveBigIntegerField()
|
||||
object = GenericForeignKey(
|
||||
ct_field='object_type',
|
||||
fk_field='object_id'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created', 'user')
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id')),
|
||||
)
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('object_type', 'object_id', 'user'),
|
||||
name='%(app_label)s_%(class)s_unique_per_object_and_user'
|
||||
),
|
||||
)
|
||||
verbose_name = _('subscription')
|
||||
verbose_name_plural = _('subscriptions')
|
||||
|
||||
def __str__(self):
|
||||
if self.object:
|
||||
return str(self.object)
|
||||
return super().__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('account:subscriptions')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the assigned object type
|
||||
if self.object_type not in ObjectType.objects.with_feature('notifications'):
|
||||
raise ValidationError(
|
||||
_("Objects of this type ({type}) do not support notifications.").format(type=self.object_type)
|
||||
)
|
@ -5,6 +5,12 @@ from extras.models.tags import TaggedItem
|
||||
from utilities.query_functions import EmptyGroupByJSONBAgg
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextModelQuerySet',
|
||||
'ConfigContextQuerySet',
|
||||
'NotificationQuerySet',
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
|
||||
@ -145,3 +151,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
)
|
||||
|
||||
return base_query
|
||||
|
||||
|
||||
class NotificationQuerySet(RestrictedQuerySet):
|
||||
|
||||
def unread(self):
|
||||
"""
|
||||
Return only unread notifications.
|
||||
"""
|
||||
return self.filter(read__isnull=True)
|
||||
|
@ -10,17 +10,18 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.events import *
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.events import process_event_rules
|
||||
from extras.models import EventRule
|
||||
from extras.models import EventRule, Notification, Subscription
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from netbox.registry import registry
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .events import enqueue_object, get_snapshots, serialize_for_event
|
||||
from .events import enqueue_event
|
||||
from .models import CustomField, TaggedItem
|
||||
from .validators import CustomValidator
|
||||
|
||||
@ -72,17 +73,22 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
|
||||
# Determine the type of change being made
|
||||
if kwargs.get('created'):
|
||||
action = ObjectChangeActionChoices.ACTION_CREATE
|
||||
event_type = OBJECT_CREATED
|
||||
elif 'created' in kwargs:
|
||||
action = ObjectChangeActionChoices.ACTION_UPDATE
|
||||
event_type = OBJECT_UPDATED
|
||||
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
|
||||
# m2m_changed with objects added or removed
|
||||
m2m_changed = True
|
||||
action = ObjectChangeActionChoices.ACTION_UPDATE
|
||||
event_type = OBJECT_UPDATED
|
||||
else:
|
||||
return
|
||||
|
||||
# Create/update an ObjectChange record for this change
|
||||
action = {
|
||||
OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE,
|
||||
OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE,
|
||||
}[event_type]
|
||||
objectchange = instance.to_objectchange(action)
|
||||
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
|
||||
# for this object by this request and update it
|
||||
@ -106,13 +112,13 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
|
||||
# Enqueue the object for event processing
|
||||
queue = events_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, action)
|
||||
enqueue_event(queue, instance, request.user, request.id, event_type)
|
||||
events_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
if event_type == OBJECT_CREATED:
|
||||
model_inserts.labels(instance._meta.model_name).inc()
|
||||
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
|
||||
elif event_type == OBJECT_UPDATED:
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
@ -168,7 +174,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
|
||||
# Enqueue the object for event processing
|
||||
queue = events_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
|
||||
events_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
@ -270,7 +276,13 @@ def process_job_start_event_rules(sender, **kwargs):
|
||||
"""
|
||||
event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=sender.object_type,
|
||||
event_type=JOB_STARTED,
|
||||
data=sender.data,
|
||||
username=username
|
||||
)
|
||||
|
||||
|
||||
@receiver(job_end)
|
||||
@ -280,4 +292,39 @@ def process_job_end_event_rules(sender, **kwargs):
|
||||
"""
|
||||
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=sender.object_type,
|
||||
event_type=JOB_COMPLETED,
|
||||
data=sender.data,
|
||||
username=username
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Notifications
|
||||
#
|
||||
|
||||
@receiver(post_save)
|
||||
def notify_object_changed(sender, instance, created, raw, **kwargs):
|
||||
if created or raw:
|
||||
return
|
||||
|
||||
# Skip unsupported object types
|
||||
ct = ContentType.objects.get_for_model(instance)
|
||||
if ct.model not in registry['model_features']['notifications'].get(ct.app_label, []):
|
||||
return
|
||||
|
||||
# Find all subscribed Users
|
||||
subscribed_users = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values_list('user', flat=True)
|
||||
if not subscribed_users:
|
||||
return
|
||||
|
||||
# Delete any existing Notifications for the object
|
||||
Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete()
|
||||
|
||||
# Create Notifications for Subscribers
|
||||
Notification.objects.bulk_create([
|
||||
Notification(user_id=user, object=instance, event_type=OBJECT_UPDATED)
|
||||
for user in subscribed_users
|
||||
])
|
||||
|
13
netbox/extras/tables/columns.py
Normal file
13
netbox/extras/tables/columns.py
Normal file
@ -0,0 +1,13 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.tables.columns import ActionsColumn, ActionsItem
|
||||
|
||||
__all__ = (
|
||||
'NotificationActionsColumn',
|
||||
)
|
||||
|
||||
|
||||
class NotificationActionsColumn(ActionsColumn):
|
||||
actions = {
|
||||
'dismiss': ActionsItem(_('Dismiss'), 'trash-can-outline', 'delete', 'danger'),
|
||||
}
|
@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from extras.models import *
|
||||
from netbox.constants import EMPTY_TABLE_TEXT
|
||||
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||
from .columns import NotificationActionsColumn
|
||||
|
||||
__all__ = (
|
||||
'BookmarkTable',
|
||||
@ -19,21 +20,28 @@ __all__ = (
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
'NotificationGroupTable',
|
||||
'NotificationTable',
|
||||
'SavedFilterTable',
|
||||
'ReportResultsTable',
|
||||
'ScriptResultsTable',
|
||||
'SubscriptionTable',
|
||||
'TaggedItemTable',
|
||||
'TagTable',
|
||||
'WebhookTable',
|
||||
)
|
||||
|
||||
IMAGEATTACHMENT_IMAGE = '''
|
||||
IMAGEATTACHMENT_IMAGE = """
|
||||
{% if record.image %}
|
||||
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
'''
|
||||
"""
|
||||
|
||||
NOTIFICATION_ICON = """
|
||||
<span class="text-{{ value.color }} fs-3"><i class="{{ value.icon }}"></i></span>
|
||||
"""
|
||||
|
||||
|
||||
class CustomFieldTable(NetBoxTable):
|
||||
@ -263,6 +271,93 @@ class BookmarkTable(NetBoxTable):
|
||||
default_columns = ('object', 'object_type', 'created')
|
||||
|
||||
|
||||
class SubscriptionTable(NetBoxTable):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Type'),
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
user = tables.Column(
|
||||
verbose_name=_('User'),
|
||||
linkify=True
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Subscription
|
||||
fields = ('pk', 'object', 'object_type', 'created', 'user')
|
||||
default_columns = ('object', 'object_type', 'created')
|
||||
|
||||
|
||||
class NotificationTable(NetBoxTable):
|
||||
icon = columns.TemplateColumn(
|
||||
template_code=NOTIFICATION_ICON,
|
||||
accessor=tables.A('event'),
|
||||
attrs={
|
||||
'td': {'class': 'w-1'},
|
||||
'th': {'class': 'w-1'},
|
||||
},
|
||||
verbose_name=''
|
||||
)
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Type'),
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify={
|
||||
'viewname': 'extras:notification_read',
|
||||
'args': [tables.A('pk')],
|
||||
},
|
||||
orderable=False
|
||||
)
|
||||
created = columns.DateTimeColumn(
|
||||
timespec='minutes',
|
||||
verbose_name=_('Created'),
|
||||
)
|
||||
read = columns.DateTimeColumn(
|
||||
timespec='minutes',
|
||||
verbose_name=_('Read'),
|
||||
)
|
||||
user = tables.Column(
|
||||
verbose_name=_('User'),
|
||||
linkify=True
|
||||
)
|
||||
actions = NotificationActionsColumn(
|
||||
actions=('dismiss',)
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Notification
|
||||
fields = ('pk', 'icon', 'object', 'object_type', 'event_type', 'created', 'read', 'user')
|
||||
default_columns = ('icon', 'object', 'object_type', 'event_type', 'created')
|
||||
row_attrs = {
|
||||
'data-read': lambda record: bool(record.read),
|
||||
}
|
||||
|
||||
|
||||
class NotificationGroupTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Name')
|
||||
)
|
||||
users = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
groups = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = NotificationGroup
|
||||
fields = ('pk', 'name', 'description', 'groups', 'users')
|
||||
default_columns = ('name', 'description', 'groups', 'users')
|
||||
|
||||
|
||||
class WebhookTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
|
@ -7,15 +7,15 @@ from django.utils.timezone import make_aware
|
||||
from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.events import *
|
||||
from core.models import ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
|
||||
from users.models import Group, User
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
|
||||
@ -890,3 +890,196 @@ class ObjectTypeTest(APITestCase):
|
||||
|
||||
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
|
||||
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SubscriptionTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Subscription
|
||||
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
users = (
|
||||
User(username='User 1'),
|
||||
User(username='User 2'),
|
||||
User(username='User 3'),
|
||||
User(username='User 4'),
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
subscriptions = (
|
||||
Subscription(
|
||||
object=sites[0],
|
||||
user=users[0],
|
||||
),
|
||||
Subscription(
|
||||
object=sites[1],
|
||||
user=users[1],
|
||||
),
|
||||
Subscription(
|
||||
object=sites[2],
|
||||
user=users[2],
|
||||
),
|
||||
)
|
||||
Subscription.objects.bulk_create(subscriptions)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'object_type': 'dcim.site',
|
||||
'object_id': sites[0].pk,
|
||||
'user': users[3].pk,
|
||||
},
|
||||
{
|
||||
'object_type': 'dcim.site',
|
||||
'object_id': sites[1].pk,
|
||||
'user': users[3].pk,
|
||||
},
|
||||
{
|
||||
'object_type': 'dcim.site',
|
||||
'object_id': sites[2].pk,
|
||||
'user': users[3].pk,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = NotificationGroup
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'object_types': ['dcim.site'],
|
||||
'name': 'Custom Link 4',
|
||||
'enabled': True,
|
||||
'link_text': 'Link 4',
|
||||
'link_url': 'http://example.com/?4',
|
||||
},
|
||||
{
|
||||
'object_types': ['dcim.site'],
|
||||
'name': 'Custom Link 5',
|
||||
'enabled': True,
|
||||
'link_text': 'Link 5',
|
||||
'link_url': 'http://example.com/?5',
|
||||
},
|
||||
{
|
||||
'object_types': ['dcim.site'],
|
||||
'name': 'Custom Link 6',
|
||||
'enabled': False,
|
||||
'link_text': 'Link 6',
|
||||
'link_url': 'http://example.com/?6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
users = (
|
||||
User(username='User 1'),
|
||||
User(username='User 2'),
|
||||
User(username='User 3'),
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
groups = (
|
||||
Group(name='Group 1'),
|
||||
Group(name='Group 2'),
|
||||
Group(name='Group 3'),
|
||||
)
|
||||
Group.objects.bulk_create(groups)
|
||||
|
||||
notification_groups = (
|
||||
NotificationGroup(name='Notification Group 1'),
|
||||
NotificationGroup(name='Notification Group 2'),
|
||||
NotificationGroup(name='Notification Group 3'),
|
||||
)
|
||||
NotificationGroup.objects.bulk_create(notification_groups)
|
||||
for i, notification_group in enumerate(notification_groups):
|
||||
notification_group.users.add(users[i])
|
||||
notification_group.groups.add(groups[i])
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Notification Group 4',
|
||||
'description': 'Foo',
|
||||
'users': [users[0].pk],
|
||||
'groups': [groups[0].pk],
|
||||
},
|
||||
{
|
||||
'name': 'Notification Group 5',
|
||||
'description': 'Bar',
|
||||
'users': [users[1].pk],
|
||||
'groups': [groups[1].pk],
|
||||
},
|
||||
{
|
||||
'name': 'Notification Group 6',
|
||||
'description': 'Baz',
|
||||
'users': [users[2].pk],
|
||||
'groups': [groups[2].pk],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class NotificationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Notification
|
||||
brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
users = (
|
||||
User(username='User 1'),
|
||||
User(username='User 2'),
|
||||
User(username='User 3'),
|
||||
User(username='User 4'),
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
notifications = (
|
||||
Notification(
|
||||
object=sites[0],
|
||||
event_type=OBJECT_CREATED,
|
||||
user=users[0],
|
||||
),
|
||||
Notification(
|
||||
object=sites[1],
|
||||
event_type=OBJECT_UPDATED,
|
||||
user=users[1],
|
||||
),
|
||||
Notification(
|
||||
object=sites[2],
|
||||
event_type=OBJECT_DELETED,
|
||||
user=users[2],
|
||||
),
|
||||
)
|
||||
Notification.objects.bulk_create(notifications)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'object_type': 'dcim.site',
|
||||
'object_id': sites[0].pk,
|
||||
'user': users[3].pk,
|
||||
'event_type': OBJECT_CREATED,
|
||||
},
|
||||
{
|
||||
'object_type': 'dcim.site',
|
||||
'object_id': sites[1].pk,
|
||||
'user': users[3].pk,
|
||||
'event_type': OBJECT_UPDATED,
|
||||
},
|
||||
{
|
||||
'object_type': 'dcim.site',
|
||||
'object_id': sites[2].pk,
|
||||
'user': users[3].pk,
|
||||
'event_type': OBJECT_DELETED,
|
||||
},
|
||||
]
|
||||
|
@ -9,12 +9,12 @@ from django.urls import reverse
|
||||
from requests import Session
|
||||
from rest_framework import status
|
||||
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.events import *
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.choices import EventRuleActionChoices
|
||||
from extras.events import enqueue_object, flush_events, serialize_for_event
|
||||
from extras.events import enqueue_event, flush_events, serialize_for_event
|
||||
from extras.models import EventRule, Tag, Webhook
|
||||
from extras.webhooks import generate_signature, send_webhook
|
||||
from netbox.context_managers import event_tracking
|
||||
@ -132,7 +132,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
|
||||
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
|
||||
@ -182,7 +182,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(self.queue.count, 3)
|
||||
for i, job in enumerate(self.queue.jobs):
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
|
||||
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
|
||||
@ -213,7 +213,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], site.pk)
|
||||
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
|
||||
@ -269,7 +269,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(self.queue.count, 3)
|
||||
for i, job in enumerate(self.queue.jobs):
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
|
||||
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
|
||||
@ -295,7 +295,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], site.pk)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
|
||||
@ -328,7 +328,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(self.queue.count, 3)
|
||||
for i, job in enumerate(self.queue.jobs):
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
|
||||
@ -365,12 +365,12 @@ class EventRuleTest(APITestCase):
|
||||
# Enqueue a webhook for processing
|
||||
webhooks_queue = {}
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
enqueue_object(
|
||||
enqueue_event(
|
||||
webhooks_queue,
|
||||
instance=site,
|
||||
user=self.user,
|
||||
request_id=request_id,
|
||||
action=ObjectChangeActionChoices.ACTION_CREATE
|
||||
event_type=OBJECT_CREATED
|
||||
)
|
||||
flush_events(list(webhooks_queue.values()))
|
||||
|
||||
@ -378,7 +378,7 @@ class EventRuleTest(APITestCase):
|
||||
job = self.queue.jobs[0]
|
||||
|
||||
# Patch the Session object with our dummy_send() method, then process the webhook for sending
|
||||
with patch.object(Session, 'send', dummy_send) as mock_send:
|
||||
with patch.object(Session, 'send', dummy_send):
|
||||
send_webhook(**job.kwargs)
|
||||
|
||||
def test_duplicate_triggers(self):
|
||||
@ -399,7 +399,7 @@ class EventRuleTest(APITestCase):
|
||||
site.save()
|
||||
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
|
||||
job = self.queue.get_jobs()[0]
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
|
||||
self.queue.empty()
|
||||
|
||||
# Test multiple updates
|
||||
@ -411,7 +411,7 @@ class EventRuleTest(APITestCase):
|
||||
site.save()
|
||||
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
|
||||
job = self.queue.get_jobs()[0]
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
|
||||
self.queue.empty()
|
||||
|
||||
# Test update & delete
|
||||
@ -422,5 +422,5 @@ class EventRuleTest(APITestCase):
|
||||
site.delete()
|
||||
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
|
||||
job = self.queue.get_jobs()[0]
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
|
||||
self.queue.empty()
|
||||
|
@ -1,7 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
@ -15,13 +14,11 @@ from extras.choices import *
|
||||
from extras.filtersets import *
|
||||
from extras.models import *
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, User
|
||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = CustomField.objects.all()
|
||||
filterset = CustomFieldFilterSet
|
||||
@ -1370,3 +1367,65 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
||||
params = {'modified_by_request': self.create_update_request_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.queryset.count(), 4)
|
||||
|
||||
|
||||
class NotificationGroupTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
filterset = NotificationGroupFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
users = (
|
||||
User(username='User 1'),
|
||||
User(username='User 2'),
|
||||
User(username='User 3'),
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
|
||||
groups = (
|
||||
Group(name='Group 1'),
|
||||
Group(name='Group 2'),
|
||||
Group(name='Group 3'),
|
||||
)
|
||||
Group.objects.bulk_create(groups)
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
notification_groups = (
|
||||
NotificationGroup(name='Notification Group 1'),
|
||||
NotificationGroup(name='Notification Group 2'),
|
||||
NotificationGroup(name='Notification Group 3'),
|
||||
)
|
||||
NotificationGroup.objects.bulk_create(notification_groups)
|
||||
notification_groups[0].users.add(users[0])
|
||||
notification_groups[1].users.add(users[1])
|
||||
notification_groups[2].users.add(users[2])
|
||||
notification_groups[0].groups.add(groups[0])
|
||||
notification_groups[1].groups.add(groups[1])
|
||||
notification_groups[2].groups.add(groups[2])
|
||||
|
||||
def test_user(self):
|
||||
users = User.objects.filter(username__startswith='User')
|
||||
params = {'user': [users[0].username, users[1].username]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_group(self):
|
||||
groups = Group.objects.all()
|
||||
params = {'group': [groups[0].name, groups[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group_id': [groups[0].pk, groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
@ -1,4 +1,3 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
@ -6,10 +5,9 @@ from core.models import ObjectType
|
||||
from dcim.models import DeviceType, Manufacturer, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from users.models import Group, User
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = CustomField
|
||||
@ -620,3 +618,166 @@ class CustomLinkTest(TestCase):
|
||||
response = self.client.get(site.get_absolute_url(), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(f'FOO {site.name} BAR', str(response.content))
|
||||
|
||||
|
||||
class SubscriptionTestCase(
|
||||
ViewTestCases.CreateObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
model = Subscription
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
Site(name='Site 4', slug='site-4'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
cls.form_data = {
|
||||
'object_type': site_ct.pk,
|
||||
'object_id': sites[3].pk,
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
sites = Site.objects.all()
|
||||
user = self.user
|
||||
|
||||
subscriptions = (
|
||||
Subscription(object=sites[0], user=user),
|
||||
Subscription(object=sites[1], user=user),
|
||||
Subscription(object=sites[2], user=user),
|
||||
)
|
||||
Subscription.objects.bulk_create(subscriptions)
|
||||
|
||||
def _get_url(self, action, instance=None):
|
||||
if action == 'list':
|
||||
return reverse('account:subscriptions')
|
||||
return super()._get_url(action, instance)
|
||||
|
||||
def test_list_objects_anonymous(self):
|
||||
self.client.logout()
|
||||
url = reverse('account:subscriptions')
|
||||
login_url = reverse('login')
|
||||
self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
|
||||
|
||||
def test_list_objects_with_permission(self):
|
||||
return
|
||||
|
||||
def test_list_objects_with_constrained_permission(self):
|
||||
return
|
||||
|
||||
|
||||
class NotificationGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = NotificationGroup
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
users = (
|
||||
User(username='User 1'),
|
||||
User(username='User 2'),
|
||||
User(username='User 3'),
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
groups = (
|
||||
Group(name='Group 1'),
|
||||
Group(name='Group 2'),
|
||||
Group(name='Group 3'),
|
||||
)
|
||||
Group.objects.bulk_create(groups)
|
||||
|
||||
notification_groups = (
|
||||
NotificationGroup(name='Notification Group 1'),
|
||||
NotificationGroup(name='Notification Group 2'),
|
||||
NotificationGroup(name='Notification Group 3'),
|
||||
)
|
||||
NotificationGroup.objects.bulk_create(notification_groups)
|
||||
for i, notification_group in enumerate(notification_groups):
|
||||
notification_group.users.add(users[i])
|
||||
notification_group.groups.add(groups[i])
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Notification Group X',
|
||||
'description': 'Blah',
|
||||
'users': [users[0].pk, users[1].pk],
|
||||
'groups': [groups[0].pk, groups[1].pk],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,description,users,groups',
|
||||
'Notification Group 4,Foo,"User 1,User 2","Group 1,Group 2"',
|
||||
'Notification Group 5,Bar,"User 1,User 2","Group 1,Group 2"',
|
||||
'Notification Group 6,Baz,"User 1,User 2","Group 1,Group 2"',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name",
|
||||
f"{notification_groups[0].pk},Notification Group 7",
|
||||
f"{notification_groups[1].pk},Notification Group 8",
|
||||
f"{notification_groups[2].pk},Notification Group 9",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
|
||||
class NotificationTestCase(
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
model = Notification
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
Site(name='Site 4', slug='site-4'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
cls.form_data = {
|
||||
'object_type': site_ct.pk,
|
||||
'object_id': sites[3].pk,
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
sites = Site.objects.all()
|
||||
user = self.user
|
||||
|
||||
notifications = (
|
||||
Notification(object=sites[0], user=user),
|
||||
Notification(object=sites[1], user=user),
|
||||
Notification(object=sites[2], user=user),
|
||||
)
|
||||
Notification.objects.bulk_create(notifications)
|
||||
|
||||
def _get_url(self, action, instance=None):
|
||||
if action == 'list':
|
||||
return reverse('account:notifications')
|
||||
return super()._get_url(action, instance)
|
||||
|
||||
def test_list_objects_anonymous(self):
|
||||
self.client.logout()
|
||||
url = reverse('account:notifications')
|
||||
login_url = reverse('login')
|
||||
self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
|
||||
|
||||
def test_list_objects_with_permission(self):
|
||||
return
|
||||
|
||||
def test_list_objects_with_constrained_permission(self):
|
||||
return
|
||||
|
@ -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,139 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
|
||||
return Bookmark.objects.filter(user=request.user)
|
||||
|
||||
|
||||
#
|
||||
# Notification groups
|
||||
#
|
||||
|
||||
class NotificationGroupListView(generic.ObjectListView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
filterset = filtersets.NotificationGroupFilterSet
|
||||
filterset_form = forms.NotificationGroupFilterForm
|
||||
table = tables.NotificationGroupTable
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup)
|
||||
class NotificationGroupView(generic.ObjectView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'edit')
|
||||
class NotificationGroupEditView(generic.ObjectEditView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
form = forms.NotificationGroupForm
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'delete')
|
||||
class NotificationGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
|
||||
|
||||
class NotificationGroupBulkImportView(generic.BulkImportView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
model_form = forms.NotificationGroupImportForm
|
||||
|
||||
|
||||
class NotificationGroupBulkEditView(generic.BulkEditView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
filterset = filtersets.NotificationGroupFilterSet
|
||||
table = tables.NotificationGroupTable
|
||||
form = forms.NotificationGroupBulkEditForm
|
||||
|
||||
|
||||
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
filterset = filtersets.NotificationGroupFilterSet
|
||||
table = tables.NotificationGroupTable
|
||||
|
||||
|
||||
#
|
||||
# Notifications
|
||||
#
|
||||
|
||||
class NotificationsView(LoginRequiredMixin, View):
|
||||
"""
|
||||
HTMX-only user-specific notifications list.
|
||||
"""
|
||||
def get(self, request):
|
||||
return render(request, 'htmx/notifications.html', {
|
||||
'notifications': request.user.notifications.unread(),
|
||||
'total_count': request.user.notifications.count(),
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Notification, 'read')
|
||||
class NotificationReadView(LoginRequiredMixin, View):
|
||||
"""
|
||||
Mark the Notification read and redirect the user to its attached object.
|
||||
"""
|
||||
def get(self, request, pk):
|
||||
notification = get_object_or_404(request.user.notifications, pk=pk)
|
||||
notification.read = timezone.now()
|
||||
notification.save()
|
||||
|
||||
return redirect(notification.object.get_absolute_url())
|
||||
|
||||
|
||||
@register_model_view(Notification, 'dismiss')
|
||||
class NotificationDismissView(LoginRequiredMixin, View):
|
||||
"""
|
||||
A convenience view which allows deleting notifications with one click.
|
||||
"""
|
||||
def get(self, request, pk):
|
||||
notification = get_object_or_404(request.user.notifications, pk=pk)
|
||||
notification.delete()
|
||||
|
||||
if htmx_partial(request):
|
||||
return render(request, 'htmx/notifications.html', {
|
||||
'notifications': request.user.notifications.unread()[:10],
|
||||
})
|
||||
|
||||
return redirect('account:notifications')
|
||||
|
||||
|
||||
@register_model_view(Notification, 'delete')
|
||||
class NotificationDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Notification.objects.filter(user=request.user)
|
||||
|
||||
|
||||
class NotificationBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.NotificationTable
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Notification.objects.filter(user=request.user)
|
||||
|
||||
|
||||
#
|
||||
# Subscriptions
|
||||
#
|
||||
|
||||
class SubscriptionCreateView(generic.ObjectEditView):
|
||||
form = forms.SubscriptionForm
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
obj.user = request.user
|
||||
return obj
|
||||
|
||||
|
||||
@register_model_view(Subscription, 'delete')
|
||||
class SubscriptionDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
|
||||
class SubscriptionBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.SubscriptionTable
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Subscription.objects.filter(user=request.user)
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
@ -25,7 +25,7 @@ def generate_signature(request_body, secret):
|
||||
|
||||
|
||||
@job('default')
|
||||
def send_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
|
||||
def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
@ -33,7 +33,7 @@ def send_webhook(event_rule, model_name, event, data, timestamp, username, reque
|
||||
|
||||
# Prepare context data for headers & body templates
|
||||
context = {
|
||||
'event': WEBHOOK_EVENT_TYPES[event],
|
||||
'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type),
|
||||
'timestamp': timestamp,
|
||||
'model': model_name,
|
||||
'username': username,
|
||||
|
45
netbox/netbox/events.py
Normal file
45
netbox/netbox/events.py
Normal file
@ -0,0 +1,45 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from netbox.registry import registry
|
||||
|
||||
EVENT_TYPE_INFO = 'info'
|
||||
EVENT_TYPE_SUCCESS = 'success'
|
||||
EVENT_TYPE_WARNING = 'warning'
|
||||
EVENT_TYPE_DANGER = 'danger'
|
||||
|
||||
__all__ = (
|
||||
'EVENT_TYPE_DANGER',
|
||||
'EVENT_TYPE_INFO',
|
||||
'EVENT_TYPE_SUCCESS',
|
||||
'EVENT_TYPE_WARNING',
|
||||
'Event',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
name: str
|
||||
text: str
|
||||
type: str = EVENT_TYPE_INFO
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
def register(self):
|
||||
registry['events'][self.name] = self
|
||||
|
||||
def color(self):
|
||||
return {
|
||||
EVENT_TYPE_INFO: 'blue',
|
||||
EVENT_TYPE_SUCCESS: 'green',
|
||||
EVENT_TYPE_WARNING: 'orange',
|
||||
EVENT_TYPE_DANGER: 'red',
|
||||
}.get(self.type)
|
||||
|
||||
def icon(self):
|
||||
return {
|
||||
EVENT_TYPE_INFO: 'mdi mdi-information',
|
||||
EVENT_TYPE_SUCCESS: 'mdi mdi-check-circle',
|
||||
EVENT_TYPE_WARNING: 'mdi mdi-alert-box',
|
||||
EVENT_TYPE_DANGER: 'mdi mdi-alert-octagon',
|
||||
}.get(self.type)
|
@ -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=[]),
|
||||
),
|
||||
|
@ -25,6 +25,7 @@ registry = Registry({
|
||||
'counter_fields': collections.defaultdict(dict),
|
||||
'data_backends': dict(),
|
||||
'denormalized_fields': collections.defaultdict(list),
|
||||
'events': dict(),
|
||||
'model_features': dict(),
|
||||
'models': collections.defaultdict(set),
|
||||
'plugins': dict(),
|
||||
|
@ -84,6 +84,16 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
|
||||
'extras.add_bookmark': ({'user': '$user'},),
|
||||
'extras.change_bookmark': ({'user': '$user'},),
|
||||
'extras.delete_bookmark': ({'user': '$user'},),
|
||||
# Permit users to manage their own notifications
|
||||
'extras.view_notification': ({'user': '$user'},),
|
||||
'extras.add_notification': ({'user': '$user'},),
|
||||
'extras.change_notification': ({'user': '$user'},),
|
||||
'extras.delete_notification': ({'user': '$user'},),
|
||||
# Permit users to manage their own subscriptions
|
||||
'extras.view_subscription': ({'user': '$user'},),
|
||||
'extras.add_subscription': ({'user': '$user'},),
|
||||
'extras.change_subscription': ({'user': '$user'},),
|
||||
'extras.delete_subscription': ({'user': '$user'},),
|
||||
# Permit users to manage their own API tokens
|
||||
'users.view_token': ({'user': '$user'},),
|
||||
'users.add_token': ({'user': '$user'},),
|
||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
9
netbox/project-static/styles/custom/_notifications.scss
Normal file
9
netbox/project-static/styles/custom/_notifications.scss
Normal file
@ -0,0 +1,9 @@
|
||||
@use 'sass:map';
|
||||
|
||||
// Mute read notifications
|
||||
tr[data-read=True] {
|
||||
td {
|
||||
background-color: var(--#{$prefix}bg-surface-secondary);
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
@ -24,3 +24,4 @@
|
||||
@import 'custom/interfaces';
|
||||
@import 'custom/markdown';
|
||||
@import 'custom/misc';
|
||||
@import 'custom/notifications';
|
||||
|
@ -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 %}
|
57
netbox/templates/extras/notificationgroup.html
Normal file
57
netbox/templates/extras/notificationgroup.html
Normal file
@ -0,0 +1,57 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Notification Group" %}</h5>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>
|
||||
{{ object.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>
|
||||
{{ object.description|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Groups" %}</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for group in object.groups.all %}
|
||||
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Users" %}</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for user in object.users.all %}
|
||||
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -77,6 +77,9 @@ Context:
|
||||
{% if perms.extras.add_bookmark and object.bookmarks %}
|
||||
{% bookmark_button object %}
|
||||
{% endif %}
|
||||
{% if perms.extras.add_subscription and object.subscriptions %}
|
||||
{% subscribe_button object %}
|
||||
{% endif %}
|
||||
{% if request.user|can_add:object %}
|
||||
{% clone_button object %}
|
||||
{% endif %}
|
||||
|
33
netbox/templates/htmx/notifications.html
Normal file
33
netbox/templates/htmx/notifications.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% 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-auto text-{{ notification.event.color }} fs-2 pe-0">
|
||||
<i class="{{ notification.event.icon }}"></i>
|
||||
</div>
|
||||
<div class="col text-truncate">
|
||||
<a href="{% url 'extras:notification_read' pk=notification.pk %}" class="text-body d-block">{{ notification.object }}</a>
|
||||
<div class="d-block text-secondary fs-5">{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="#" hx-get="{% url 'extras:notification_dismiss' pk=notification.pk %}" hx-target="closest .notifications" class="list-group-item-actions text-secondary" title="{% trans "Dismiss" %}">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="dropdown-item text-muted">
|
||||
{% trans "No unread notifications" %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if total_count %}
|
||||
<a href="{% url 'account:notifications' %}" class="list-group-item list-group-item-action d-flex justify-content-between p-2">
|
||||
{% trans "All notifications" %}
|
||||
{% badge total_count %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/notification_bell.html' %}
|
||||
|
9
netbox/templates/inc/notification_bell.html
Normal file
9
netbox/templates/inc/notification_bell.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% if notifications %}
|
||||
<span class="text-primary" id="notifications-alert" hx-swap-oob="true">
|
||||
<i class="mdi mdi-bell-badge"></i>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted" id="notifications-alert" hx-swap-oob="true">
|
||||
<i class="mdi mdi-bell"></i>
|
||||
</span>
|
||||
{% endif %}
|
@ -2,6 +2,17 @@
|
||||
{% 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">
|
||||
{% include 'inc/notification_bell.html' %}
|
||||
</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 +40,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>
|
||||
|
@ -5,6 +5,7 @@ from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.models import NotificationGroup
|
||||
from netbox.filtersets import BaseFilterSet
|
||||
from users.models import Group, ObjectPermission, Token
|
||||
from utilities.filters import ContentTypeFilter
|
||||
@ -32,6 +33,11 @@ class GroupFilterSet(BaseFilterSet):
|
||||
queryset=ObjectPermission.objects.all(),
|
||||
label=_('Permission (ID)'),
|
||||
)
|
||||
notification_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='notification_groups',
|
||||
queryset=NotificationGroup.objects.all(),
|
||||
label=_('Notification group (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
@ -67,6 +73,11 @@ class UserFilterSet(BaseFilterSet):
|
||||
queryset=ObjectPermission.objects.all(),
|
||||
label=_('Permission (ID)'),
|
||||
)
|
||||
notification_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='notification_groups',
|
||||
queryset=NotificationGroup.objects.all(),
|
||||
label=_('Notification group (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
|
@ -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>
|
||||
|
18
netbox/utilities/templates/buttons/subscribe.html
Normal file
18
netbox/utilities/templates/buttons/subscribe.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% load i18n %}
|
||||
{% if form_url %}
|
||||
<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>
|
||||
{% endif %}
|
@ -3,7 +3,8 @@ 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 netbox.models.features import NotificationsMixin
|
||||
from utilities.querydict import prepare_cloned_fields
|
||||
from utilities.views import get_viewname
|
||||
|
||||
@ -17,6 +18,7 @@ __all__ = (
|
||||
'edit_button',
|
||||
'export_button',
|
||||
'import_button',
|
||||
'subscribe_button',
|
||||
'sync_button',
|
||||
)
|
||||
|
||||
@ -94,6 +96,41 @@ def delete_button(instance):
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('buttons/subscribe.html', takes_context=True)
|
||||
def subscribe_button(context, instance):
|
||||
# Skip for objects which don't support notifications
|
||||
if not (issubclass(instance.__class__, NotificationsMixin)):
|
||||
return {}
|
||||
|
||||
# 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