Skip to content

Closes #19713: Enable recording user messages in the change log #19908

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jul 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aaaf346
Add message field to ObjectChange model
jeremystretch Jul 17, 2025
5e5c46f
Set max length on changelog message
jeremystretch Jul 17, 2025
8d54368
Enable changelog messages for single object operations
jeremystretch Jul 17, 2025
1b11895
Fix tests
jeremystretch Jul 17, 2025
0703fe7
Add changelog message support for bulk edit & bulk delete
jeremystretch Jul 18, 2025
2044802
Cosmetic improvements to form fields
jeremystretch Jul 18, 2025
0514bb4
Fix bulk operation templates
jeremystretch Jul 18, 2025
f600429
Add message support for bulk import/update
jeremystretch Jul 22, 2025
ac26665
Add REST API support for changelog messages (WIP)
jeremystretch Jul 23, 2025
1615a36
Fix changelog_message assignment
jeremystretch Jul 24, 2025
bdb0e57
Enable changelog message support for bulk deletions
jeremystretch Jul 24, 2025
5ab696e
Add documentation
jeremystretch Jul 24, 2025
a5d6173
Fix changelog message support for VirtualChassis
jeremystretch Jul 24, 2025
084f640
Add ChangeLoggingMixin to necesssary model forms
jeremystretch Jul 24, 2025
f174381
Introduce get_random_string() utility function for tests
jeremystretch Jul 24, 2025
6acde0f
Incorporate changelog messages for object view tests
jeremystretch Jul 24, 2025
3fba47e
Incorporate changelog messages for object bulk view tests
jeremystretch Jul 25, 2025
5bed9e8
Add missing mixins for changelog message support
jeremystretch Jul 25, 2025
7470147
Tweak test to generate expected number of change records
jeremystretch Jul 25, 2025
68d3906
Finish adding tests for changelog message functionality
jeremystretch Jul 25, 2025
839e508
Misc cleanup
jeremystretch Jul 25, 2025
b4e14cb
Fixes #19956: Prevent duplicate deletion records from cascading delet…
jeremystretch Jul 25, 2025
1587600
Tweak bulk deletion test to work around cascading deletions issue
jeremystretch Jul 28, 2025
881bf3d
Correct API URL
jeremystretch Jul 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/features/change-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ When a request is made, a UUID is generated and attached to any change records r

Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format.

## User Messages

!!! info "This feature was introduced in NetBox v4.4."

When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change.

## Correlating Changes by Request

Every request made to NetBox is assigned a random unique ID that can be used to correlate change records. For example, if you change the status of three sites using the UI's bulk edit feature, you will see three new change records (one for each site) all referencing the same request ID. This shows that all three changes were made as part of the same request.
22 changes: 22 additions & 0 deletions docs/integrations/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,28 @@ http://netbox/api/dcim/sites/ \
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.

## Changelog Messages

!!! info "This feature was introduced in NetBox v4.4."

Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Beginning in NetBox v4.4, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.

For example, the following API request will create a new site and record a message in the resulting changelog entry:

```no-highlight
curl -s -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/ \
--data '{
"name": "Site A",
"slug": "site-a",
"changelog_message": "Adding a site for ticket #4137"
}'
```

This approach works when creating, modifying, or deleting objects, either individually or in bulk.

## Uploading Files

As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
Expand Down
3 changes: 2 additions & 1 deletion netbox/core/api/serializers_/change_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ class Meta:
model = ObjectChange
fields = [
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data',
'postchange_data',
]

@extend_schema_field(serializers.JSONField(allow_null=True))
Expand Down
3 changes: 2 additions & 1 deletion netbox/core/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ def search(self, queryset, name, value):
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
Q(object_repr__icontains=value) |
Q(message__icontains=value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to make this a default queried field, should we add an index to the field? (He says not having looked at other similar fields yet ...)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not as part of this change. message here is analogous to description on most other models. I'm not sure if it makes sense to add an index for those fields, but it does it IMO it should be undertaken as part of an application-wide audit. For now I think it makes sense to continue the current convention.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, got ahead of myself here. Just got done looking around and realized the same thing about it being a bigger, separate chunk of work.

)


Expand Down
16 changes: 16 additions & 0 deletions netbox/core/migrations/0017_objectchange_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0016_job_log_entries'),
]

operations = [
migrations.AddField(
model_name='objectchange',
name='message',
field=models.CharField(blank=True, editable=False, max_length=200),
),
]
6 changes: 6 additions & 0 deletions netbox/core/models/change_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ class ObjectChange(models.Model):
max_length=200,
editable=False
)
message = models.CharField(
verbose_name=_('message'),
max_length=200,
editable=False,
blank=True
)
prechange_data = models.JSONField(
verbose_name=_('pre-change data'),
editable=False,
Expand Down
24 changes: 24 additions & 0 deletions netbox/core/signals.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
from threading import local

from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.core.signals import request_finished
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates

Expand Down Expand Up @@ -42,6 +44,10 @@
# Change logging & event handling
#

# Used to track received signals per object
_signals_received = local()


@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
Expand Down Expand Up @@ -130,6 +136,16 @@ def handle_deleted_object(sender, instance, **kwargs):
if request is None:
return

# Check whether we've already processed a pre_delete signal for this object. (This can
# happen e.g. when both a parent object and its child are deleted simultaneously, due
# to cascading deletion.)
if not hasattr(_signals_received, 'pre_delete'):
_signals_received.pre_delete = set()
signature = (ContentType.objects.get_for_model(instance), instance.pk)
if signature in _signals_received.pre_delete:
return
_signals_received.pre_delete.add(signature)

# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
Expand Down Expand Up @@ -179,6 +195,14 @@ def handle_deleted_object(sender, instance, **kwargs):
model_deletes.labels(instance._meta.model_name).inc()


@receiver(request_finished)
def clear_signal_history(sender, **kwargs):
"""
Clear out the signals history once the request is finished.
"""
_signals_received.pre_delete = set()


@receiver(clear_events)
def clear_events_queue(sender, **kwargs):
"""
Expand Down
8 changes: 7 additions & 1 deletion netbox/core/tables/change_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class ObjectChangeTable(NetBoxTable):
template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name=_('Request ID')
)
message = tables.Column(
verbose_name=_('Message'),
)
actions = columns.ActionsColumn(
actions=()
)
Expand All @@ -49,5 +52,8 @@ class Meta(NetBoxTable.Meta):
model = ObjectChange
fields = (
'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
'actions',
'message', 'actions',
)
default_columns = (
'pk', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'message', 'actions',
)
32 changes: 32 additions & 0 deletions netbox/core/tests/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,38 @@ def test_ordering_genericrelation(self):
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))

def test_duplicate_deletions(self):
"""
Check that a cascading deletion event does not generate multiple "deleted" ObjectChange records for
the same object.
"""
role1 = DeviceRole(name='Role 1', slug='role-1')
role1.save()
role2 = DeviceRole(name='Role 2', slug='role-2', parent=role1)
role2.save()
pk_list = [role1.pk, role2.pk]

# Delete both objects simultaneously
form_data = {
'pk': pk_list,
'confirm': True,
'_confirm': True,
}
request = {
'path': reverse('dcim:devicerole_bulk_delete'),
'data': post_data(form_data),
}
self.add_permissions('dcim.delete_devicerole')
self.assertHttpStatus(self.client.post(**request), 302)

# This should result in exactly one change record per object
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(DeviceRole),
changed_object_id__in=pk_list,
action=ObjectChangeActionChoices.ACTION_DELETE
)
self.assertEqual(objectchanges.count(), 2)


class ChangeLogAPITest(APITestCase):

Expand Down
2 changes: 1 addition & 1 deletion netbox/core/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def test_hash(self):
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet
ignore_fields = ('prechange_data', 'postchange_data')
ignore_fields = ('message', 'prechange_data', 'postchange_data')

@classmethod
def setUpTestData(cls):
Expand Down
26 changes: 15 additions & 11 deletions netbox/dcim/api/serializers_/devicetype_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import ValidatedModelSerializer
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from utilities.api import get_serializer_for_model
from wireless.choices import *
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
Expand All @@ -31,7 +31,11 @@
)


class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class ComponentTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
pass


class ConsolePortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand Down Expand Up @@ -59,7 +63,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class ConsoleServerPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand Down Expand Up @@ -87,7 +91,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class PowerPortTemplateSerializer(ValidatedModelSerializer):
class PowerPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand Down Expand Up @@ -116,7 +120,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand Down Expand Up @@ -156,7 +160,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class InterfaceTemplateSerializer(ValidatedModelSerializer):
class InterfaceTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand Down Expand Up @@ -202,7 +206,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class RearPortTemplateSerializer(ValidatedModelSerializer):
class RearPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
required=False,
nested=True,
Expand All @@ -226,7 +230,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class FrontPortTemplateSerializer(ValidatedModelSerializer):
class FrontPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand All @@ -251,7 +255,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class ModuleBayTemplateSerializer(ValidatedModelSerializer):
class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
Expand All @@ -274,7 +278,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True
)
Expand All @@ -288,7 +292,7 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'name', 'description')


class InventoryItemTemplateSerializer(ValidatedModelSerializer):
class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True
)
Expand Down
Loading