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

* Add message field to ObjectChange model

* Set max length on changelog message

* Enable changelog messages for single object operations

* Fix tests

* Add changelog message support for bulk edit & bulk delete

* Cosmetic improvements to form fields

* Fix bulk operation templates

* Add message support for bulk import/update

* Add REST API support for changelog messages (WIP)

* Fix changelog_message assignment

* Enable changelog message support for bulk deletions

* Add documentation

* Fix changelog message support for VirtualChassis

* Add ChangeLoggingMixin to necesssary model forms

* Introduce get_random_string() utility function for tests

* Incorporate changelog messages for object view tests

* Incorporate changelog messages for object bulk view tests

* Add missing mixins for changelog message support

* Tweak test to generate expected number of change records

* Finish adding tests for changelog message functionality

* Misc cleanup

* Fixes #19956: Prevent duplicate deletion records from cascading deletions

* Tweak bulk deletion test to work around cascading deletions issue

* Correct API URL
This commit is contained in:
Jeremy Stretch
2025-07-29 10:11:33 -04:00
committed by GitHub
parent 89a94486e1
commit 24a0e1907a
50 changed files with 575 additions and 184 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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(),