Closes #15621: User notifications (#16800)

* 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:
Jeremy Stretch
2024-07-15 14:24:11 -04:00
committed by GitHub
parent 1c2336be60
commit b0e7294bc1
59 changed files with 1913 additions and 90 deletions

View File

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