mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 01:58:43 -06:00
Compare commits
7 Commits
ac26665f29
...
6acde0f432
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6acde0f432 | ||
|
|
f17438132a | ||
|
|
084f640566 | ||
|
|
a5d6173372 | ||
|
|
5ab696e55b | ||
|
|
bdb0e5720d | ||
|
|
1615a369f9 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)):
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user