mirror of
https://github.com/netbox-community/netbox.git
synced 2025-09-06 14:23:36 -06:00
* 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:
parent
89a94486e1
commit
24a0e1907a
@ -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/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.
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
16
netbox/core/migrations/0017_objectchange_message.py
Normal file
16
netbox/core/migrations/0017_objectchange_message.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
@ -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,
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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',
|
||||
)
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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()
|
||||
|
@ -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)):
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
|
@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
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()
|
||||
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
# 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))
|
||||
|
@ -64,10 +64,16 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Message" %}</th>
|
||||
<td>
|
||||
{{ object.message|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Request ID" %}</th>
|
||||
<td>
|
||||
{{ object.request_id }}
|
||||
<a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}">{{ object.request_id }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
@ -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>
|
||||
|
@ -66,7 +66,10 @@ Context:
|
||||
{% endfor %}
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
|
||||
{% if form.changelog_message %}
|
||||
{% render_field form.changelog_message %}
|
||||
{% endif %}
|
||||
{% render_field form.background_job %}
|
||||
</div>
|
||||
|
||||
|
@ -103,7 +103,10 @@ Context:
|
||||
{% endif %}
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
|
||||
{% if form.changelog_message %}
|
||||
{% render_field form.changelog_message %}
|
||||
{% endif %}
|
||||
{% render_field form.background_job %}
|
||||
</div>
|
||||
|
||||
|
@ -42,7 +42,6 @@ Context:
|
||||
|
||||
{# Data Import Form #}
|
||||
<div class="tab-pane show active" id="import-form" role="tabpanel" aria-labelledby="import-form-tab">
|
||||
<div class="row">
|
||||
<div class="col col-md-12 col-lg-10 offset-lg-1">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
@ -55,6 +54,7 @@ Context:
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
{% render_field form.changelog_message %}
|
||||
{% render_field form.background_job %}
|
||||
</div>
|
||||
|
||||
@ -69,7 +69,6 @@ Context:
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# File Upload Form #}
|
||||
<div class="tab-pane show" id="upload-form" role="tabpanel" aria-labelledby="upload-form-tab">
|
||||
@ -83,6 +82,11 @@ Context:
|
||||
{% render_field form.format %}
|
||||
{% render_field form.csv_delimiter %}
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
{% render_field form.changelog_message %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col col-md-12 text-end">
|
||||
{% if return_url %}
|
||||
@ -110,6 +114,7 @@ Context:
|
||||
|
||||
{# Meta fields #}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
|
||||
{% render_field form.changelog_message %}
|
||||
{% render_field form.background_job %}
|
||||
</div>
|
||||
|
||||
|
@ -20,7 +20,7 @@ Context:
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="modal" tabindex="-1" style="display: block; position: static">
|
||||
<div class="modal modal-lg" tabindex="-1" style="display: block; position: static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-1 border-danger">
|
||||
{% include 'htmx/delete_form.html' %}
|
||||
|
@ -2,10 +2,16 @@
|
||||
{% load i18n %}
|
||||
|
||||
<form action="{{ form_url }}" method="post">
|
||||
{# Render hidden fields #}
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% trans "Confirm Deletion" %}</h5>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
@ -16,10 +22,10 @@
|
||||
<p>
|
||||
{% trans "The following objects will be deleted as a result of this action." %}
|
||||
</p>
|
||||
<div class="accordion" id="deleteAccordion">
|
||||
<div class="accordion mb-3" id="deleteAccordion">
|
||||
{% for model, instances in dependent_objects.items %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="deleteheading{{ forloop.counter }}">
|
||||
<h2 class="accordion-header h4" id="deleteheading{{ forloop.counter }}">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="false" aria-controls="collapse{{ forloop.counter }}">
|
||||
{% with object_count=instances|length %}
|
||||
{{ object_count }}
|
||||
@ -46,8 +52,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_form form %}
|
||||
|
||||
{# Meta fields #}
|
||||
{% if form.changelog_message %}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3">
|
||||
{% render_field form.changelog_message %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
||||
|
@ -28,6 +28,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Meta fields #}
|
||||
{% if form.changelog_message %}
|
||||
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
|
||||
{% render_field form.changelog_message %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{# Render all fields in a single group #}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal modal-lg fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog{% if size %} modal-{{ size }}{% endif %}">
|
||||
<div class="modal-content" id="htmx-modal-content">
|
||||
{# Dynamic content goes here #}
|
||||
|
@ -8,12 +8,13 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
|
||||
from netbox.forms.mixins import ChangeLoggingMixin
|
||||
from utilities.constants import CSV_DELIMITERS
|
||||
from utilities.forms.mixins import BackgroundJobMixin
|
||||
from utilities.forms.utils import parse_csv
|
||||
|
||||
|
||||
class BulkImportForm(BackgroundJobMixin, SyncedDataMixin, forms.Form):
|
||||
class BulkImportForm(ChangeLoggingMixin, BackgroundJobMixin, SyncedDataMixin, forms.Form):
|
||||
import_method = forms.ChoiceField(
|
||||
choices=ImportMethodChoices,
|
||||
required=False
|
||||
|
@ -10,6 +10,7 @@ __all__ = (
|
||||
'BulkRenameForm',
|
||||
'ConfirmationForm',
|
||||
'CSVModelForm',
|
||||
'DeleteForm',
|
||||
'FilterForm',
|
||||
'TableConfigForm',
|
||||
)
|
||||
@ -30,6 +31,16 @@ class ConfirmationForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class DeleteForm(ConfirmationForm):
|
||||
"""
|
||||
Confirm the deletion of an object, optionally providing a changelog message.
|
||||
"""
|
||||
changelog_message = forms.CharField(
|
||||
required=False,
|
||||
max_length=200
|
||||
)
|
||||
|
||||
|
||||
class BulkEditForm(BackgroundJobMixin, forms.Form):
|
||||
"""
|
||||
Provides bulk edit support for objects.
|
||||
|
@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
|
||||
@ -15,10 +16,11 @@ from strawberry.types.union import StrawberryUnion
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from ipam.graphql.types import IPAddressFamilyType
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from users.models import ObjectPermission, Token, User
|
||||
from utilities.api import get_graphql_type_for_model
|
||||
from .base import ModelTestCase
|
||||
from .utils import disable_logging, disable_warnings
|
||||
from .utils import disable_logging, disable_warnings, get_random_string
|
||||
|
||||
__all__ = (
|
||||
'APITestCase',
|
||||
@ -223,8 +225,14 @@ class APIViewTestCases:
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
data = copy.deepcopy(self.create_data[0])
|
||||
|
||||
# If supported, add a changelog message
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
data['changelog_message'] = get_random_string(10)
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header)
|
||||
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + 1)
|
||||
instance = self._get_queryset().get(pk=response.data['id'])
|
||||
@ -236,13 +244,13 @@ class APIViewTestCases:
|
||||
)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if hasattr(self.model, 'to_objectchange'):
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
objectchange = ObjectChange.objects.get(
|
||||
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(objectchange.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(objectchange.message, data['changelog_message'])
|
||||
|
||||
def test_bulk_create_objects(self):
|
||||
"""
|
||||
@ -257,6 +265,12 @@ class APIViewTestCases:
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
# If supported, add a changelog message
|
||||
changelog_message = get_random_string(10)
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
for obj_data in self.create_data:
|
||||
obj_data['changelog_message'] = changelog_message
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
@ -264,6 +278,9 @@ class APIViewTestCases:
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data))
|
||||
for i, obj in enumerate(response.data):
|
||||
for field in self.create_data[i]:
|
||||
if field == 'changelog_message':
|
||||
# Write-only field
|
||||
continue
|
||||
if field not in self.validation_excluded_fields:
|
||||
self.assertIn(field, obj, f"Bulk create field '{field}' missing from object {i} in response")
|
||||
for i, obj in enumerate(response.data):
|
||||
@ -274,6 +291,20 @@ class APIViewTestCases:
|
||||
api=True
|
||||
)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
id_list = [
|
||||
obj['id'] for obj in response.data
|
||||
]
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(self.model),
|
||||
changed_object_id__in=id_list
|
||||
)
|
||||
self.assertEqual(len(objectchanges), len(self.create_data))
|
||||
for oc in objectchanges:
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc.message, changelog_message)
|
||||
|
||||
class UpdateObjectViewTestCase(APITestCase):
|
||||
update_data = {}
|
||||
bulk_update_data = None
|
||||
@ -308,24 +339,30 @@ class APIViewTestCases:
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
response = self.client.patch(url, update_data, format='json', **self.header)
|
||||
data = copy.deepcopy(update_data)
|
||||
|
||||
# If supported, add a changelog message
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
data['changelog_message'] = get_random_string(10)
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
instance.refresh_from_db()
|
||||
self.assertInstanceEqual(
|
||||
instance,
|
||||
update_data,
|
||||
data,
|
||||
exclude=self.validation_excluded_fields,
|
||||
api=True
|
||||
)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if hasattr(self.model, 'to_objectchange'):
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
objectchange = ObjectChange.objects.get(
|
||||
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(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.message, data['changelog_message'])
|
||||
|
||||
def test_bulk_update_objects(self):
|
||||
"""
|
||||
@ -349,14 +386,34 @@ class APIViewTestCases:
|
||||
{'id': id, **self.bulk_update_data} for id in id_list
|
||||
]
|
||||
|
||||
# If supported, add a changelog message
|
||||
changelog_message = get_random_string(10)
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
for obj_data in data:
|
||||
obj_data['changelog_message'] = changelog_message
|
||||
|
||||
response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
for i, obj in enumerate(response.data):
|
||||
for field in self.bulk_update_data:
|
||||
if field == 'changelog_data':
|
||||
# Write-only field
|
||||
continue
|
||||
self.assertIn(field, obj, f"Bulk update field '{field}' missing from object {i} in response")
|
||||
for instance in self._get_queryset().filter(pk__in=id_list):
|
||||
self.assertInstanceEqual(instance, self.bulk_update_data, api=True)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(self.model),
|
||||
changed_object_id__in=id_list
|
||||
)
|
||||
self.assertEqual(len(objectchanges), len(data))
|
||||
for oc in objectchanges:
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.message, changelog_message)
|
||||
|
||||
class DeleteObjectViewTestCase(APITestCase):
|
||||
|
||||
def test_delete_object_without_permission(self):
|
||||
@ -386,18 +443,24 @@ class APIViewTestCases:
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
response = self.client.delete(url, **self.header)
|
||||
data = {}
|
||||
|
||||
# If supported, add a changelog message
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
data['changelog_message'] = get_random_string(10)
|
||||
|
||||
response = self.client.delete(url, data, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists())
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if hasattr(self.model, 'to_objectchange'):
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
objectchange = ObjectChange.objects.get(
|
||||
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(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(objectchange.message, data['changelog_message'])
|
||||
|
||||
def test_bulk_delete_objects(self):
|
||||
"""
|
||||
@ -418,11 +481,28 @@ class APIViewTestCases:
|
||||
self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion")
|
||||
data = [{"id": id} for id in id_list]
|
||||
|
||||
# If supported, add a changelog message
|
||||
changelog_message = get_random_string(10)
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
for obj_data in data:
|
||||
obj_data['changelog_message'] = changelog_message
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count - 3)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(self.model),
|
||||
changed_object_id__in=id_list
|
||||
)
|
||||
self.assertEqual(len(objectchanges), len(data))
|
||||
for oc in objectchanges:
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.message, changelog_message)
|
||||
|
||||
class GraphQLTestCase(APITestCase):
|
||||
|
||||
def _get_graphql_base_name(self):
|
||||
|
@ -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):
|
||||
@ -610,6 +628,10 @@ class ViewTestCases:
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
|
||||
# If supported, add a changelog message
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
data['changelog_message'] = get_random_string(10)
|
||||
|
||||
# Assign model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
@ -623,9 +645,23 @@ class ViewTestCases:
|
||||
self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200)
|
||||
|
||||
# Test POST with permission
|
||||
self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
|
||||
response = self.client.post(self._get_url('bulk_import'), data)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
request_id = response.headers.get('X-Request-ID')
|
||||
self.assertIsNotNone(request_id, "Unable to determine request ID from response")
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(self.model),
|
||||
request_id=request_id
|
||||
)
|
||||
self.assertEqual(len(objectchanges), len(self.csv_data) - 1)
|
||||
for oc in objectchanges:
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc.message, data['changelog_message'])
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_bulk_update_objects_with_permission(self):
|
||||
if not hasattr(self, 'csv_update_data'):
|
||||
@ -727,6 +763,10 @@ class ViewTestCases:
|
||||
'_apply': True, # Form button
|
||||
}
|
||||
|
||||
# If supported, add a changelog message
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
data['changelog_message'] = get_random_string(10)
|
||||
|
||||
# Append the form data to the request
|
||||
data.update(post_data(self.bulk_edit_data))
|
||||
|
||||
@ -740,10 +780,24 @@ class ViewTestCases:
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
# Try POST with model-level permission
|
||||
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
|
||||
response = self.client.post(self._get_url('bulk_edit'), data)
|
||||
self.assertHttpStatus(response, 302)
|
||||
for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)):
|
||||
self.assertInstanceEqual(instance, self.bulk_edit_data)
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
request_id = response.headers.get('X-Request-ID')
|
||||
self.assertIsNotNone(request_id, "Unable to determine request ID from response")
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(self.model),
|
||||
changed_object_id__in=pk_list
|
||||
)
|
||||
self.assertEqual(len(objectchanges), len(pk_list))
|
||||
for oc in objectchanges:
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.message, data['changelog_message'])
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_bulk_edit_objects_with_constrained_permission(self):
|
||||
pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3])
|
||||
@ -804,13 +858,17 @@ class ViewTestCases:
|
||||
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403)
|
||||
|
||||
def test_bulk_delete_objects_with_permission(self):
|
||||
pk_list = self._get_queryset().values_list('pk', flat=True)
|
||||
pk_list = list(self._get_queryset().values_list('pk', flat=True))[:3]
|
||||
data = {
|
||||
'pk': pk_list,
|
||||
'confirm': True,
|
||||
'_confirm': True, # Form button
|
||||
}
|
||||
|
||||
# If supported, add a changelog message
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
data['changelog_message'] = get_random_string(10)
|
||||
|
||||
# Assign unconstrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
@ -821,8 +879,20 @@ class ViewTestCases:
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||
|
||||
# Try POST with model-level permission
|
||||
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
|
||||
self.assertEqual(self._get_queryset().count(), 0)
|
||||
response = self.client.post(self._get_url('bulk_delete'), data)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertFalse(self._get_queryset().filter(pk__in=pk_list).exists())
|
||||
|
||||
# Verify ObjectChange creation
|
||||
if issubclass(self.model, ChangeLoggingMixin):
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(self.model),
|
||||
changed_object_id__in=pk_list
|
||||
)
|
||||
self.assertEqual(len(objectchanges), len(pk_list))
|
||||
for oc in objectchanges:
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.message, data['changelog_message'])
|
||||
|
||||
def test_bulk_delete_objects_with_constrained_permission(self):
|
||||
pk_list = self._get_queryset().values_list('pk', flat=True)
|
||||
|
Loading…
Reference in New Issue
Block a user