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..c669513a0 --- /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, max_length=200), + ), + ] diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index 1d1bbc07c..819b1b2b3 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -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, 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/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): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 57cfd1801..4b8f7027d 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. @@ -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 c569343ee..8ecca73e1 100644 --- a/netbox/netbox/forms/mixins.py +++ b/netbox/netbox/forms/mixins.py @@ -7,12 +7,23 @@ from extras.models import * from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( + 'ChangeLoggingMixin', 'CustomFieldsMixin', 'SavedFiltersMixin', 'TagsMixin', ) +class ChangeLoggingMixin(forms.Form): + """ + Adds an optional field for recording a message on the resulting changelog record(s). + """ + changelog_message = forms.CharField( + required=False, + max_length=200 + ) + + 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/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 686326881..36223fd20 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 @@ -423,7 +424,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): } if prefetch_ids else {} for i, record in enumerate(records, start=1): - instance = None object_id = int(record.pop('id')) if record.get('id') else None # Determine whether this object is being created or updated @@ -439,6 +439,8 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): instance.snapshot() else: + instance = self.queryset.model() + # For newly created objects, apply any default custom field values custom_fields = CustomField.objects.filter( object_types=ContentType.objects.get_for_model(self.queryset.model), @@ -449,6 +451,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): if field_name not in record: record[field_name] = cf.default + # Record changelog message (if any) + instance._changelog_message = form.cleaned_data.pop('changelog_message', '') + # Instantiate the model form for the object model_form_kwargs = { 'data': record, @@ -622,6 +627,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 +900,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 +947,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 5bc79d962..657f95f1f 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 @@ -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 @@ -422,7 +425,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,23 +464,25 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(**kwargs) - form = ConfirmationForm(request.POST) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() + form = DeleteForm(request.POST) 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/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 %} +