diff --git a/docs/features/change-logging.md b/docs/features/change-logging.md index 919f59110..73e23709c 100644 --- a/docs/features/change-logging.md +++ b/docs/features/change-logging.md @@ -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. diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 7a0d3e176..47fb65494 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -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. 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/signals.py b/netbox/core/signals.py index 8ba8cc244..3d0317011 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -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 @@ -42,6 +44,10 @@ clear_events = Signal() # 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): """ @@ -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): @@ -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): """ 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_changelog.py b/netbox/core/tests/test_changelog.py index df8461076..4a00e4a25 100644 --- a/netbox/core/tests/test_changelog.py +++ b/netbox/core/tests/test_changelog.py @@ -346,6 +346,38 @@ class ChangeLogViewTest(ModelViewTestCase): 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): 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/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 04f6395a6..8d4403d2d 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -9,7 +9,7 @@ from dcim.models import ( 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 @@ -31,7 +31,11 @@ __all__ = ( ) -class ConsolePortTemplateSerializer(ValidatedModelSerializer): +class ComponentTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): + pass + + +class ConsolePortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -59,7 +63,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): +class ConsoleServerPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -87,7 +91,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class PowerPortTemplateSerializer(ValidatedModelSerializer): +class PowerPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -116,7 +120,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class PowerOutletTemplateSerializer(ValidatedModelSerializer): +class PowerOutletTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -156,7 +160,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class InterfaceTemplateSerializer(ValidatedModelSerializer): +class InterfaceTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -202,7 +206,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class RearPortTemplateSerializer(ValidatedModelSerializer): +class RearPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( required=False, nested=True, @@ -226,7 +230,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class FrontPortTemplateSerializer(ValidatedModelSerializer): +class FrontPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -251,7 +255,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class ModuleBayTemplateSerializer(ValidatedModelSerializer): +class ModuleBayTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -274,7 +278,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class DeviceBayTemplateSerializer(ValidatedModelSerializer): +class DeviceBayTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True ) @@ -288,7 +292,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class InventoryItemTemplateSerializer(ValidatedModelSerializer): +class InventoryItemTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9db7c250e..4cca47782 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,6 +11,7 @@ from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.choices import * from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms.mixins import ChangeLoggingMixin from tenancy.models import Tenant from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model @@ -1037,7 +1038,11 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): # Device component templates # -class ConsolePortTemplateBulkEditForm(BulkEditForm): +class ComponentTemplateBulkEditForm(ChangeLoggingMixin, BulkEditForm): + pass + + +class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConsolePortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1056,7 +1061,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'type', 'description') -class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): +class ConsoleServerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1079,7 +1084,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'type', 'description') -class PowerPortTemplateBulkEditForm(BulkEditForm): +class PowerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1114,7 +1119,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') -class PowerOutletTemplateBulkEditForm(BulkEditForm): +class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerOutletTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1165,7 +1170,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): self.fields['power_port'].widget.attrs['disabled'] = True -class InterfaceTemplateBulkEditForm(BulkEditForm): +class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1216,7 +1221,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role') -class FrontPortTemplateBulkEditForm(BulkEditForm): +class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=FrontPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1243,7 +1248,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class RearPortTemplateBulkEditForm(BulkEditForm): +class RearPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RearPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1270,7 +1275,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class ModuleBayTemplateBulkEditForm(BulkEditForm): +class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ModuleBayTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1288,7 +1293,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'position', 'description') -class DeviceBayTemplateBulkEditForm(BulkEditForm): +class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceBayTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1306,7 +1311,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'description') -class InventoryItemTemplateBulkEditForm(BulkEditForm): +class InventoryItemTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=InventoryItemTemplate.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d8cff372f..55fca4efa 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -11,6 +11,7 @@ from extras.models import ConfigTemplate from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm +from netbox.forms.mixins import ChangeLoggingMixin from tenancy.forms import TenancyForm from users.models import User from utilities.forms import add_blank_choice, get_field_value @@ -973,7 +974,7 @@ class VCMemberSelectForm(forms.Form): # Device component templates # -class ComponentTemplateForm(forms.ModelForm): +class ComponentTemplateForm(ChangeLoggingMixin, forms.ModelForm): device_type = DynamicModelChoiceField( label=_('Device type'), queryset=DeviceType.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index bcf91c547..5c9599eeb 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -426,6 +426,11 @@ class VirtualChassisCreateForm(NetBoxModelForm): help_text=_('Position of the first member device. Increases by one for each additional member.') ) + fieldsets = ( + FieldSet('name', 'domain', 'description', 'tags', name=_('Virtual Chassis')), + FieldSet('region', 'site_group', 'site', 'rack', 'members', 'initial_position', name=_('Member Devices')), + ) + class Meta: model = VirtualChassis fields = [ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 94afc2cb2..277a634ce 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3702,7 +3702,6 @@ class VirtualChassisView(generic.ObjectView): class VirtualChassisCreateView(generic.ObjectEditView): queryset = VirtualChassis.objects.all() form = forms.VirtualChassisCreateForm - template_name = 'dcim/virtualchassis_add.html' @register_model_view(VirtualChassis, 'edit') @@ -3750,6 +3749,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V formset = VCMemberFormSet(request.POST, queryset=members_queryset) if vc_form.is_valid() and formset.is_valid(): + virtual_chassis._changelog_message = vc_form.cleaned_data.pop('changelog_message', '') with transaction.atomic(using=router.db_for_write(Device)): diff --git a/netbox/extras/api/serializers_/configcontexts.py b/netbox/extras/api/serializers_/configcontexts.py index 42a11ffcd..4a3f25e2e 100644 --- a/netbox/extras/api/serializers_/configcontexts.py +++ b/netbox/extras/api/serializers_/configcontexts.py @@ -8,7 +8,7 @@ from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, Si from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from netbox.api.fields import SerializedPKRelatedField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer from tenancy.models import Tenant, TenantGroup from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterGroupSerializer, ClusterTypeSerializer @@ -19,7 +19,7 @@ __all__ = ( ) -class ConfigContextSerializer(ValidatedModelSerializer): +class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): regions = SerializedPKRelatedField( queryset=Region.objects.all(), serializer=RegionSerializer, diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index 69652907e..244308535 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -1,6 +1,6 @@ from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from extras.models import ConfigTemplate -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from netbox.api.serializers.features import TaggableModelSerializer __all__ = ( @@ -8,7 +8,7 @@ __all__ = ( ) -class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): +class ConfigTemplateSerializer(ChangeLogMessageSerializer, TaggableModelSerializer, ValidatedModelSerializer): data_source = DataSourceSerializer( nested=True, required=False diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py index a65fafc4e..f50f7a829 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/netbox/extras/api/serializers_/customfields.py @@ -7,7 +7,7 @@ from core.models import ObjectType from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from netbox.api.fields import ChoiceField, ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'CustomFieldChoiceSetSerializer', @@ -15,7 +15,7 @@ __all__ = ( ) -class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): +class CustomFieldChoiceSetSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): base_choices = ChoiceField( choices=CustomFieldChoiceSetBaseChoices, required=False @@ -36,7 +36,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count') -class CustomFieldSerializer(ValidatedModelSerializer): +class CustomFieldSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('custom_fields'), many=True diff --git a/netbox/extras/api/serializers_/customlinks.py b/netbox/extras/api/serializers_/customlinks.py index 8cc4f5f77..951c3aded 100644 --- a/netbox/extras/api/serializers_/customlinks.py +++ b/netbox/extras/api/serializers_/customlinks.py @@ -1,14 +1,14 @@ from core.models import ObjectType from extras.models import CustomLink from netbox.api.fields import ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'CustomLinkSerializer', ) -class CustomLinkSerializer(ValidatedModelSerializer): +class CustomLinkSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('custom_links'), many=True diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index 0d19d642c..0d3eed442 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -2,14 +2,14 @@ from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.models import ObjectType from extras.models import ExportTemplate from netbox.api.fields import ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'ExportTemplateSerializer', ) -class ExportTemplateSerializer(ValidatedModelSerializer): +class ExportTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('export_templates'), many=True diff --git a/netbox/extras/api/serializers_/notifications.py b/netbox/extras/api/serializers_/notifications.py index 62e1a8d63..9f0c7cff3 100644 --- a/netbox/extras/api/serializers_/notifications.py +++ b/netbox/extras/api/serializers_/notifications.py @@ -4,7 +4,7 @@ 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 netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from users.api.serializers_.users import GroupSerializer, UserSerializer from users.models import Group, User from utilities.api import get_serializer_for_model @@ -37,7 +37,7 @@ class NotificationSerializer(ValidatedModelSerializer): return serializer(instance.object, nested=True, context=context).data -class NotificationGroupSerializer(ValidatedModelSerializer): +class NotificationGroupSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): groups = SerializedPKRelatedField( queryset=Group.objects.all(), serializer=GroupSerializer, diff --git a/netbox/extras/api/serializers_/savedfilters.py b/netbox/extras/api/serializers_/savedfilters.py index fb0744e59..e7128389c 100644 --- a/netbox/extras/api/serializers_/savedfilters.py +++ b/netbox/extras/api/serializers_/savedfilters.py @@ -1,14 +1,14 @@ from core.models import ObjectType from extras.models import SavedFilter from netbox.api.fields import ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'SavedFilterSerializer', ) -class SavedFilterSerializer(ValidatedModelSerializer): +class SavedFilterSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.all(), many=True diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index 5dc39584f..7567a4543 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -5,7 +5,7 @@ from core.models import ObjectType from extras.models import Tag, TaggedItem from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer +from netbox.api.serializers import BaseModelSerializer, ChangeLogMessageSerializer, ValidatedModelSerializer from utilities.api import get_serializer_for_model __all__ = ( @@ -14,7 +14,7 @@ __all__ = ( ) -class TagSerializer(ValidatedModelSerializer): +class TagSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('tags'), many=True, diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index c854a6c81..1afc8a0f2 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -5,6 +5,7 @@ from extras.choices import * from extras.models import * from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms.mixins import ChangeLoggingMixin from utilities.forms import BulkEditForm, add_blank_choice from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField from utilities.forms.rendering import FieldSet @@ -27,7 +28,7 @@ __all__ = ( ) -class CustomFieldBulkEditForm(BulkEditForm): +class CustomFieldBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput @@ -95,7 +96,7 @@ class CustomFieldBulkEditForm(BulkEditForm): nullable_fields = ('group_name', 'description', 'choice_set') -class CustomFieldChoiceSetBulkEditForm(BulkEditForm): +class CustomFieldChoiceSetBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomFieldChoiceSet.objects.all(), widget=forms.MultipleHiddenInput @@ -115,7 +116,7 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm): nullable_fields = ('base_choices', 'description') -class CustomLinkBulkEditForm(BulkEditForm): +class CustomLinkBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomLink.objects.all(), widget=forms.MultipleHiddenInput @@ -141,7 +142,7 @@ class CustomLinkBulkEditForm(BulkEditForm): ) -class ExportTemplateBulkEditForm(BulkEditForm): +class ExportTemplateBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput @@ -174,7 +175,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') -class SavedFilterBulkEditForm(BulkEditForm): +class SavedFilterBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SavedFilter.objects.all(), widget=forms.MultipleHiddenInput @@ -294,7 +295,7 @@ class EventRuleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description', 'conditions') -class TagBulkEditForm(BulkEditForm): +class TagBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Tag.objects.all(), widget=forms.MultipleHiddenInput @@ -316,7 +317,7 @@ class TagBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class ConfigContextBulkEditForm(BulkEditForm): +class ConfigContextBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConfigContext.objects.all(), widget=forms.MultipleHiddenInput @@ -340,7 +341,7 @@ class ConfigContextBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class ConfigTemplateBulkEditForm(BulkEditForm): +class ConfigTemplateBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConfigTemplate.objects.all(), widget=forms.MultipleHiddenInput @@ -373,7 +374,7 @@ class ConfigTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') -class JournalEntryBulkEditForm(BulkEditForm): +class JournalEntryBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=JournalEntry.objects.all(), widget=forms.MultipleHiddenInput @@ -386,7 +387,7 @@ class JournalEntryBulkEditForm(BulkEditForm): comments = CommentField() -class NotificationGroupBulkEditForm(BulkEditForm): +class NotificationGroupBulkEditForm(ChangeLoggingMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=NotificationGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index fd333322b..cccef14fc 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -13,6 +13,7 @@ from extras.choices import * from extras.models import * from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelForm +from netbox.forms.mixins import ChangeLoggingMixin from tenancy.models import Tenant, TenantGroup from users.models import Group, User from utilities.forms import get_field_value @@ -45,7 +46,7 @@ __all__ = ( ) -class CustomFieldForm(forms.ModelForm): +class CustomFieldForm(ChangeLoggingMixin, forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_fields'), @@ -164,7 +165,7 @@ class CustomFieldForm(forms.ModelForm): del self.fields['choice_set'] -class CustomFieldChoiceSetForm(forms.ModelForm): +class CustomFieldChoiceSetForm(ChangeLoggingMixin, forms.ModelForm): # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model extra_choices = forms.CharField( widget=ChoicesWidget(), @@ -217,7 +218,7 @@ class CustomFieldChoiceSetForm(forms.ModelForm): return data -class CustomLinkForm(forms.ModelForm): +class CustomLinkForm(ChangeLoggingMixin, forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_links') @@ -249,7 +250,7 @@ class CustomLinkForm(forms.ModelForm): } -class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): +class ExportTemplateForm(ChangeLoggingMixin, SyncedDataMixin, forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('export_templates') @@ -291,7 +292,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): return self.cleaned_data -class SavedFilterForm(forms.ModelForm): +class SavedFilterForm(ChangeLoggingMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -388,7 +389,7 @@ class BookmarkForm(forms.ModelForm): fields = ('object_type', 'object_id') -class NotificationGroupForm(forms.ModelForm): +class NotificationGroupForm(ChangeLoggingMixin, forms.ModelForm): groups = DynamicModelMultipleChoiceField( label=_('Groups'), required=False, @@ -561,7 +562,7 @@ class EventRuleForm(NetBoxModelForm): return self.cleaned_data -class TagForm(forms.ModelForm): +class TagForm(ChangeLoggingMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -584,7 +585,7 @@ class TagForm(forms.ModelForm): ] -class ConfigContextForm(SyncedDataMixin, forms.ModelForm): +class ConfigContextForm(ChangeLoggingMixin, SyncedDataMixin, forms.ModelForm): regions = DynamicModelMultipleChoiceField( label=_('Regions'), queryset=Region.objects.all(), @@ -696,7 +697,7 @@ class ConfigContextForm(SyncedDataMixin, forms.ModelForm): return self.cleaned_data -class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): +class ConfigTemplateForm(ChangeLoggingMixin, SyncedDataMixin, forms.ModelForm): tags = DynamicModelMultipleChoiceField( label=_('Tags'), queryset=Tag.objects.all(), diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5e2733577..16a24b773 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1108,6 +1108,10 @@ class VLANTranslationRuleTest(APIViewTestCases.APIViewTestCase): name='Policy 2', description='foobar2', ), + VLANTranslationPolicy( + name='Policy 3', + description='foobar2', + ), ) VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) @@ -1152,7 +1156,8 @@ class VLANTranslationRuleTest(APIViewTestCases.APIViewTestCase): ] cls.bulk_update_data = { - 'policy': vlan_translation_policies[1].pk, + 'policy': vlan_translation_policies[2].pk, + 'description': 'New description', } diff --git a/netbox/netbox/api/serializers/__init__.py b/netbox/netbox/api/serializers/__init__.py index 0ec3ab5f3..d7ad19565 100644 --- a/netbox/netbox/api/serializers/__init__.py +++ b/netbox/netbox/api/serializers/__init__.py @@ -10,7 +10,12 @@ from .nested import * # Base model serializers # -class NetBoxModelSerializer(TaggableModelSerializer, CustomFieldModelSerializer, ValidatedModelSerializer): +class NetBoxModelSerializer( + ChangeLogMessageSerializer, + TaggableModelSerializer, + CustomFieldModelSerializer, + ValidatedModelSerializer +): """ Adds support for custom fields and tags. """ @@ -24,5 +29,5 @@ class NestedGroupModelSerializer(NetBoxModelSerializer): _depth = serializers.IntegerField(source='level', read_only=True) -class BulkOperationSerializer(serializers.Serializer): +class BulkOperationSerializer(ChangeLogMessageSerializer): id = serializers.IntegerField() diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index 3bd5c8a2d..1ee92e828 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -5,6 +5,7 @@ from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultVal from .nested import NestedTagSerializer __all__ = ( + 'ChangeLogMessageSerializer', 'CustomFieldModelSerializer', 'TaggableModelSerializer', ) @@ -54,3 +55,24 @@ class TaggableModelSerializer(serializers.Serializer): instance.tags.clear() return instance + + +class ChangeLogMessageSerializer(serializers.Serializer): + changelog_message = serializers.CharField( + write_only=True, + required=False, + ) + + def to_internal_value(self, data): + ret = super().to_internal_value(data) + + # Workaround to bypass requirement to include changelog_message in Meta.fields on every serializer + if type(data) is dict and 'changelog_message' in data: + ret['changelog_message'] = data['changelog_message'] + + return ret + + def save(self, **kwargs): + if self.instance is not None: + self.instance._changelog_message = self.validated_data.get('changelog_message') + return super().save(**kwargs) diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 2039f735b..6241be4cd 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -7,9 +7,11 @@ from django.db.models import ProtectedError, RestrictedError from django_pglocks import advisory_lock from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins +from rest_framework import status from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet +from netbox.api.serializers.features import ChangeLogMessageSerializer from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer from utilities.exceptions import AbortRequest from utilities.query import reapply_model_ordering @@ -199,9 +201,16 @@ class NetBoxModelViewSet( # Deletes def destroy(self, request, *args, **kwargs): - # Hotwire get_object() to ensure we save a pre-change snapshot - self.get_object = self.get_object_with_snapshot - return super().destroy(request, *args, **kwargs) + instance = self.get_object_with_snapshot() + + # Attach changelog message (if any) + serializer = ChangeLogMessageSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance._changelog_message = serializer.validated_data.get('changelog_message') + + self.perform_destroy(instance) + + return Response(status=status.HTTP_204_NO_CONTENT) def perform_destroy(self, instance): model = self.queryset.model diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 4fedebad5..e74488164 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -149,18 +149,25 @@ class BulkDestroyModelMixin: serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) qs = self.get_bulk_destroy_queryset().filter( - pk__in=[o['id'] for o in serializer.data] + pk__in=[o['id'] for o in serializer.validated_data] ) - self.perform_bulk_destroy(qs) + # Compile any changelog messages to be recorded on the objects being deleted + changelog_messages = { + o['id']: o.get('changelog_message') for o in serializer.validated_data + } + + self.perform_bulk_destroy(qs, changelog_messages) return Response(status=status.HTTP_204_NO_CONTENT) - def perform_bulk_destroy(self, objects): + def perform_bulk_destroy(self, objects, changelog_messages=None): + changelog_messages = changelog_messages or {} with transaction.atomic(using=router.db_for_write(self.queryset.model)): for obj in objects: if hasattr(obj, 'snapshot'): obj.snapshot() + obj._changelog_message = changelog_messages.get(obj.pk) self.perform_destroy(obj) 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..893131336 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -66,6 +66,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model): class Meta: abstract = True + def __init__(self, *args, **kwargs): + changelog_message = kwargs.pop('changelog_message', None) + super().__init__(*args, **kwargs) + self._changelog_message = changelog_message + def serialize_object(self, exclude=None): """ Return a JSON representation of the instance. Models can override this method to replace or extend the default @@ -103,7 +108,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..5f0bb609c 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.get('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 %} + + {% trans "Message" %} + + {{ object.message|placeholder }} + + {% trans "Request ID" %} - {{ object.request_id }} + {{ object.request_id }} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html deleted file mode 100644 index 832671a61..000000000 --- a/netbox/templates/dcim/virtualchassis_add.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - -
-
-

{% trans "Virtual Chassis" %}

-
- {% render_field form.name %} - {% render_field form.domain %} - {% render_field form.description %} - {% render_field form.tags %} -
- -
-
-

{% trans "Member Devices" %}

-
- {% render_field form.region %} - {% render_field form.site_group %} - {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.members %} - {% render_field form.initial_position %} -
- - {% if form.custom_fields %} -
-
-

{% trans "Custom Fields" %}

-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 29f5cac53..768fb7666 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -102,6 +102,9 @@ {% endfor %} +
+ {% render_field vc_form.changelog_message %} +
{% trans "Cancel" %} diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 594efff63..13e004aed 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -66,7 +66,10 @@ Context: {% endfor %} {# Meta fields #} -
+
+ {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} {% render_field form.background_job %}
diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 6aace8786..58bf6dbc2 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -103,7 +103,10 @@ Context: {% endif %} {# Meta fields #} -
+
+ {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} {% render_field form.background_job %}
diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index f4a67cc1f..0e83afcb3 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -42,32 +42,31 @@ Context: {# Data Import Form #}
-
-
-
- {% csrf_token %} - +
+ + {% csrf_token %} + - {# Form fields #} - {% render_field form.data %} - {% render_field form.format %} - {% render_field form.csv_delimiter %} + {# Form fields #} + {% render_field form.data %} + {% render_field form.format %} + {% render_field form.csv_delimiter %} - {# Meta fields #} -
- {% render_field form.background_job %} + {# Meta fields #} +
+ {% render_field form.changelog_message %} + {% render_field form.background_job %} +
+ +
+
+ {% if return_url %} + {% trans "Cancel" %} + {% endif %} +
- -
-
- {% if return_url %} - {% trans "Cancel" %} - {% endif %} - -
-
- -
+
+
@@ -83,6 +82,11 @@ Context: {% render_field form.format %} {% render_field form.csv_delimiter %} + {# Meta fields #} +
+ {% render_field form.changelog_message %} +
+
{% if return_url %} @@ -110,6 +114,7 @@ Context: {# 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 %} -