Compare commits

..

7 Commits

Author SHA1 Message Date
Jeremy Stretch
6acde0f432 Incorporate changelog messages for object view tests
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-24 17:38:11 -04:00
Jeremy Stretch
f17438132a Introduce get_random_string() utility function for tests 2025-07-24 16:25:27 -04:00
Jeremy Stretch
084f640566 Add ChangeLoggingMixin to necesssary model forms 2025-07-24 16:24:44 -04:00
Jeremy Stretch
a5d6173372 Fix changelog message support for VirtualChassis 2025-07-24 16:24:06 -04:00
Jeremy Stretch
5ab696e55b Add documentation 2025-07-24 14:41:37 -04:00
Jeremy Stretch
bdb0e5720d Enable changelog message support for bulk deletions 2025-07-24 14:02:44 -04:00
Jeremy Stretch
1615a369f9 Fix changelog_message assignment 2025-07-24 13:41:15 -04:00
12 changed files with 99 additions and 59 deletions

View File

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

View File

@@ -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/site/ \
--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.

View File

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

View File

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

View File

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

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'),

View File

@@ -58,13 +58,16 @@ class TaggableModelSerializer(serializers.Serializer):
class ChangeLogMessageSerializer(serializers.Serializer):
changelog_message = serializers.CharField(write_only=True)
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 'changelog_message' in data and 'changelog_message' not in ret:
if type(data) is dict and 'changelog_message' in data:
# TODO: Validation
ret['changelog_message'] = data['changelog_message']

View File

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

View File

@@ -1,40 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% load i18n %}
{% block form %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>
</div>
{% render_field form.name %}
{% render_field form.domain %}
{% render_field form.description %}
{% render_field form.tags %}
</div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Member Devices" %}</h2>
</div>
{% 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 %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@@ -102,6 +102,9 @@
{% endfor %}
</tbody>
</table>
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% render_field vc_form.changelog_message %}
</div>
</div>
<div class="text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>

View File

@@ -1,6 +1,8 @@
import json
import logging
import random
import re
import string
from contextlib import contextmanager
from django.contrib.auth.models import Permission
@@ -154,3 +156,15 @@ def add_custom_field_data(form_data, model):
f'cf_{k}': v if type(v) is str else json.dumps(v)
for k, v in DUMMY_CF_DATA.items()
})
#
# Misc utilities
#
def get_random_string(length, charset=None):
"""
Return a random string of the given length.
"""
characters = string.ascii_letters + string.digits # a-z, A-Z, 0-9
return ''.join(random.choice(characters) for __ in range(length))

View File

@@ -14,7 +14,7 @@ from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
from users.models import ObjectPermission
from .base import ModelTestCase
from .utils import add_custom_field_data, disable_warnings, post_data
from .utils import add_custom_field_data, disable_warnings, get_random_string, post_data
__all__ = (
'ModelViewTestCase',
@@ -169,6 +169,11 @@ class ViewTestCases:
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# If supported, add a changelog message
if issubclass(self.model, ChangeLoggingMixin):
if 'changelog_message' not in self.form_data:
self.form_data['changelog_message'] = get_random_string(10)
# Try POST with model-level permission
initial_count = self._get_queryset().count()
request = {
@@ -181,13 +186,14 @@ class ViewTestCases:
self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields)
# Verify ObjectChange creation
if issubclass(instance.__class__, ChangeLoggingMixin):
if issubclass(self.model, ChangeLoggingMixin):
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(objectchanges[0].message, self.form_data['changelog_message'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_create_object_with_constrained_permission(self):
@@ -272,6 +278,11 @@ class ViewTestCases:
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# If supported, add a changelog message
if issubclass(self.model, ChangeLoggingMixin):
if 'changelog_message' not in self.form_data:
self.form_data['changelog_message'] = get_random_string(10)
# Try POST with model-level permission
request = {
'path': self._get_url('edit', instance),
@@ -282,13 +293,14 @@ class ViewTestCases:
self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields)
# Verify ObjectChange creation
if issubclass(instance.__class__, ChangeLoggingMixin):
if issubclass(self.model, ChangeLoggingMixin):
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchanges[0].message, self.form_data['changelog_message'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_edit_object_with_constrained_permission(self):
@@ -348,6 +360,7 @@ class ViewTestCases:
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_delete_object_with_permission(self):
instance = self._get_queryset().first()
form_data = {'confirm': True}
# Assign model-level permission
obj_perm = ObjectPermission(
@@ -361,23 +374,28 @@ class ViewTestCases:
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
# If supported, add a changelog message
if issubclass(self.model, ChangeLoggingMixin):
form_data['changelog_message'] = get_random_string(10)
# Try POST with model-level permission
request = {
'path': self._get_url('delete', instance),
'data': post_data({'confirm': True}),
'data': post_data(form_data),
}
self.assertHttpStatus(self.client.post(**request), 302)
with self.assertRaises(ObjectDoesNotExist):
self._get_queryset().get(pk=instance.pk)
# Verify ObjectChange creation
if issubclass(instance.__class__, ChangeLoggingMixin):
if issubclass(self.model, ChangeLoggingMixin):
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk
)
self.assertEqual(len(objectchanges), 1)
self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(objectchanges[0].message, form_data['changelog_message'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_delete_object_with_constrained_permission(self):