From aaaf346e5ff8ef7fed5c627399d460575aac53f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Jul 2025 15:40:50 -0400 Subject: [PATCH 1/8] Add message field to ObjectChange model --- netbox/core/api/serializers_/change_logging.py | 3 ++- netbox/core/filtersets.py | 3 ++- .../core/migrations/0017_objectchange_message.py | 16 ++++++++++++++++ netbox/core/models/change_logging.py | 5 +++++ netbox/core/tables/change_logging.py | 8 +++++++- netbox/templates/core/objectchange.html | 8 +++++++- 6 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 netbox/core/migrations/0017_objectchange_message.py diff --git a/netbox/core/api/serializers_/change_logging.py b/netbox/core/api/serializers_/change_logging.py index e8af31ae8..575a849d5 100644 --- a/netbox/core/api/serializers_/change_logging.py +++ b/netbox/core/api/serializers_/change_logging.py @@ -44,7 +44,8 @@ class ObjectChangeSerializer(BaseModelSerializer): 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)) diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index c64bb03ff..9f90752d7 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -186,7 +186,8 @@ class ObjectChangeFilterSet(BaseFilterSet): return queryset return queryset.filter( Q(user_name__icontains=value) | - Q(object_repr__icontains=value) + Q(object_repr__icontains=value) | + Q(message__icontains=value) ) diff --git a/netbox/core/migrations/0017_objectchange_message.py b/netbox/core/migrations/0017_objectchange_message.py new file mode 100644 index 000000000..6abfbd388 --- /dev/null +++ b/netbox/core/migrations/0017_objectchange_message.py @@ -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), + ), + ] diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index 1d1bbc07c..d00e7a909 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -82,6 +82,11 @@ class ObjectChange(models.Model): max_length=200, editable=False ) + message = models.CharField( + verbose_name=_('message'), + editable=False, + blank=True + ) prechange_data = models.JSONField( verbose_name=_('pre-change data'), editable=False, diff --git a/netbox/core/tables/change_logging.py b/netbox/core/tables/change_logging.py index aced0e8a6..b35b711bb 100644 --- a/netbox/core/tables/change_logging.py +++ b/netbox/core/tables/change_logging.py @@ -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=() ) @@ -49,5 +52,8 @@ class ObjectChangeTable(NetBoxTable): 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', ) diff --git a/netbox/templates/core/objectchange.html b/netbox/templates/core/objectchange.html index ae32e44db..e4c7d4900 100644 --- a/netbox/templates/core/objectchange.html +++ b/netbox/templates/core/objectchange.html @@ -64,10 +64,16 @@ {% endif %} + + {% trans "Message" %} + + {{ object.message|placeholder }} + + {% trans "Request ID" %} - {{ object.request_id }} + {{ object.request_id }} From 5e5c46f77c4115c5b1a85ec275ae083bdbca284e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Jul 2025 16:20:35 -0400 Subject: [PATCH 2/8] Set max length on changelog message --- netbox/core/migrations/0017_objectchange_message.py | 2 +- netbox/core/models/change_logging.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/core/migrations/0017_objectchange_message.py b/netbox/core/migrations/0017_objectchange_message.py index 6abfbd388..c669513a0 100644 --- a/netbox/core/migrations/0017_objectchange_message.py +++ b/netbox/core/migrations/0017_objectchange_message.py @@ -11,6 +11,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='objectchange', name='message', - field=models.CharField(blank=True, editable=False), + field=models.CharField(blank=True, editable=False, max_length=200), ), ] diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index d00e7a909..819b1b2b3 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -84,6 +84,7 @@ class ObjectChange(models.Model): ) message = models.CharField( verbose_name=_('message'), + max_length=200, editable=False, blank=True ) From 8d5436876ed2ef7c5321864b689d7edd1fc87de5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Jul 2025 16:37:42 -0400 Subject: [PATCH 3/8] Enable changelog messages for single object operations --- netbox/netbox/forms/base.py | 4 ++-- netbox/netbox/forms/mixins.py | 15 +++++++++++++++ netbox/netbox/models/features.py | 5 ++++- netbox/netbox/views/generic/object_views.py | 7 ++++--- netbox/templates/htmx/form.html | 4 ++++ netbox/utilities/forms/forms.py | 11 +++++++++++ 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 57cfd1801..c09e8f7fd 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -11,7 +11,7 @@ from extras.models import CustomField, Tag from utilities.forms import BulkEditForm, CSVModelForm from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.mixins import CheckLastUpdatedMixin -from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin +from .mixins import ChangeLoggingMixin, CustomFieldsMixin, SavedFiltersMixin, TagsMixin __all__ = ( 'NetBoxModelForm', @@ -21,7 +21,7 @@ __all__ = ( ) -class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): +class NetBoxModelForm(ChangeLoggingMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): """ Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. diff --git a/netbox/netbox/forms/mixins.py b/netbox/netbox/forms/mixins.py index c569343ee..b06487141 100644 --- a/netbox/netbox/forms/mixins.py +++ b/netbox/netbox/forms/mixins.py @@ -7,12 +7,27 @@ from extras.models import * from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( + 'ChangeLoggingMixin', 'CustomFieldsMixin', 'SavedFiltersMixin', 'TagsMixin', ) +class ChangeLoggingMixin(forms.Form): + changelog_message = forms.CharField( + required=False, + max_length=200 + ) + + def clean(self): + + # Attach the changelog message (if any) to the instance + self.instance._changelog_message = self.cleaned_data.pop('changelog_message', None) + + return self.cleaned_data + + class CustomFieldsMixin: """ Extend a Form to include custom field support. diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 79145ce70..023259cc8 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -63,6 +63,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model): null=True ) + _changelog_message = None + class Meta: abstract = True @@ -103,7 +105,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model): objectchange = ObjectChange( changed_object=self, object_repr=str(self)[:200], - action=action + action=action, + message=self._changelog_message or '', ) if hasattr(self, '_prechange_snapshot'): objectchange.prechange_data = self._prechange_snapshot diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 5bc79d962..a8c50c1c4 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -19,7 +19,7 @@ from netbox.object_actions import ( ) from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation -from utilities.forms import ConfirmationForm, restrict_form_fields +from utilities.forms import DeleteForm, restrict_form_fields from utilities.htmx import htmx_partial from utilities.permissions import get_permission_for_model from utilities.querydict import normalize_querydict, prepare_cloned_fields @@ -422,7 +422,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): request: The current request """ obj = self.get_object(**kwargs) - form = ConfirmationForm(initial=request.GET) + form = DeleteForm(initial=request.GET) try: dependent_objects = self._get_dependent_objects(obj) @@ -461,7 +461,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(**kwargs) - form = ConfirmationForm(request.POST) + form = DeleteForm(request.POST) # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): @@ -469,6 +469,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): if form.is_valid(): logger.debug("Form validation was successful") + obj._changelog_message = form.cleaned_data.pop('changelog_message', '') try: obj.delete() diff --git a/netbox/templates/htmx/form.html b/netbox/templates/htmx/form.html index 530a18054..1108a6113 100644 --- a/netbox/templates/htmx/form.html +++ b/netbox/templates/htmx/form.html @@ -28,6 +28,10 @@ {% endif %} + {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} + {% else %} {# Render all fields in a single group #} diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 2192c5a99..122107728 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -10,6 +10,7 @@ __all__ = ( 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', + 'DeleteForm', 'FilterForm', 'TableConfigForm', ) @@ -30,6 +31,16 @@ class ConfirmationForm(forms.Form): ) +class DeleteForm(ConfirmationForm): + """ + Confirm the deletion of an object, optionally providing a changelog message. + """ + changelog_message = forms.CharField( + required=False, + max_length=200 + ) + + class BulkEditForm(BackgroundJobMixin, forms.Form): """ Provides bulk edit support for objects. From 1b11895c90d726124b603ba1fdf5ffeb1f47eed0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Jul 2025 16:38:51 -0400 Subject: [PATCH 4/8] Fix tests --- netbox/core/tests/test_filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index b7dfd516e..4b2cff84d 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -150,7 +150,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): 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): From 0703fe78526bab5ec144743d6f713d7adc6a6a6b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Jul 2025 10:31:42 -0400 Subject: [PATCH 5/8] Add changelog message support for bulk edit & bulk delete --- netbox/netbox/forms/base.py | 2 +- netbox/netbox/forms/mixins.py | 7 ------- netbox/netbox/views/generic/bulk_views.py | 12 +++++++++++- netbox/netbox/views/generic/object_views.py | 16 ++++++++++------ netbox/templates/generic/bulk_delete.html | 1 + netbox/templates/generic/bulk_edit.html | 1 + 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index c09e8f7fd..4b8f7027d 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): return customfield.to_form_field(for_csv_import=True) -class NetBoxModelBulkEditForm(CustomFieldsMixin, BulkEditForm): +class NetBoxModelBulkEditForm(ChangeLoggingMixin, CustomFieldsMixin, BulkEditForm): """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom fields and adding/removing tags. diff --git a/netbox/netbox/forms/mixins.py b/netbox/netbox/forms/mixins.py index b06487141..4c9e46c0e 100644 --- a/netbox/netbox/forms/mixins.py +++ b/netbox/netbox/forms/mixins.py @@ -20,13 +20,6 @@ class ChangeLoggingMixin(forms.Form): max_length=200 ) - def clean(self): - - # Attach the changelog message (if any) to the instance - self.instance._changelog_message = self.cleaned_data.pop('changelog_message', None) - - return self.cleaned_data - class CustomFieldsMixin: """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 686326881..246f41f3b 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -21,6 +21,7 @@ from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate +from netbox.forms.mixins import ChangeLoggingMixin from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation @@ -622,6 +623,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): if hasattr(obj, 'snapshot'): obj.snapshot() + # Attach the changelog message (if any) to the object + obj._changelog_message = form.cleaned_data.get('changelog_message') + # Update standard fields. If a field is listed in _nullify, delete its value. for name, model_field in model_fields.items(): # Handle nullification @@ -892,7 +896,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): """ Provide a standard bulk delete form if none has been specified for the view """ - class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm): + class BulkDeleteForm(BackgroundJobMixin, ChangeLoggingMixin, ConfirmationForm): pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) return BulkDeleteForm @@ -939,9 +943,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): try: with transaction.atomic(using=router.db_for_write(model)): for obj in queryset: + # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): obj.snapshot() + + # Attach the changelog message (if any) to the object + obj._changelog_message = form.cleaned_data.get('changelog_message') + + # Delete the object obj.delete() except (ProtectedError, RestrictedError) as e: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a8c50c1c4..657f95f1f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -288,6 +288,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): if form.is_valid(): logger.debug("Form validation was successful") + # Record changelog message (if any) + obj._changelog_message = form.cleaned_data.pop('changelog_message', '') + try: with transaction.atomic(using=router.db_for_write(model)): object_created = form.instance.pk is None @@ -463,22 +466,23 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): obj = self.get_object(**kwargs) form = DeleteForm(request.POST) - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - if form.is_valid(): logger.debug("Form validation was successful") + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + # Record changelog message (if any) obj._changelog_message = form.cleaned_data.pop('changelog_message', '') + # Delete the object try: obj.delete() - except (ProtectedError, RestrictedError) as e: logger.info(f"Caught {type(e)} while attempting to delete objects") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) - except AbortRequest as e: logger.debug(e.message) messages.error(request, mark_safe(e.message)) diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 594efff63..021803485 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -67,6 +67,7 @@ Context: {# Meta fields #}
+ {% render_field form.changelog_message %} {% render_field form.background_job %}
diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 6aace8786..f41b88ef5 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -104,6 +104,7 @@ Context: {# Meta fields #}
+ {% render_field form.changelog_message %} {% render_field form.background_job %}
From 2044802586d700ed02e148a4976743c9dfcbde02 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Jul 2025 11:19:39 -0400 Subject: [PATCH 6/8] Cosmetic improvements to form fields --- netbox/templates/generic/bulk_delete.html | 2 +- netbox/templates/generic/bulk_edit.html | 2 +- netbox/templates/generic/object_delete.html | 2 +- netbox/templates/htmx/delete_form.html | 19 ++++++++++++++++--- netbox/templates/htmx/form.html | 5 ++++- netbox/templates/inc/htmx_modal.html | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 021803485..37f9cef5c 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -66,7 +66,7 @@ Context: {% endfor %} {# Meta fields #} -
+
{% render_field form.changelog_message %} {% render_field form.background_job %}
diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index f41b88ef5..687739e50 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -103,7 +103,7 @@ Context: {% endif %} {# Meta fields #} -
+
{% render_field form.changelog_message %} {% render_field form.background_job %}
diff --git a/netbox/templates/generic/object_delete.html b/netbox/templates/generic/object_delete.html index bde8830d9..71d8d3984 100644 --- a/netbox/templates/generic/object_delete.html +++ b/netbox/templates/generic/object_delete.html @@ -20,7 +20,7 @@ Context: {% endblock %} {% block content %} -