diff --git a/README.md b/README.md index e3c9611c0..99ad9a597 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations. [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)            [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) -            - [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)            diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index c98e62b81..5f086bca4 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -4,11 +4,20 @@ ### Enhancements +* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN * [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type ### Bug Fixes +* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field +* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk +* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API * [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters +* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships +* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes +* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device +* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models +* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields --- diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 3168509ba..461113147 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = CircuitType.objects.annotate( circuit_count=count_related(Circuit, 'type') ) + filterset = filtersets.CircuitTypeFilterSet table = tables.CircuitTypeTable diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 493ccbbea..fb59659d3 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -24,6 +24,7 @@ __all__ = ( 'CableFilterSet', 'CabledObjectFilterSet', 'CableTerminationFilterSet', + 'CommonInterfaceFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', 'ConsolePortTemplateFilterSet', @@ -1321,11 +1322,45 @@ class PowerOutletFilterSet( fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] +class CommonInterfaceFilterSet(django_filters.FilterSet): + vlan_id = django_filters.CharFilter( + method='filter_vlan_id', + label=_('Assigned VLAN') + ) + vlan = django_filters.CharFilter( + method='filter_vlan', + label=_('Assigned VID') + ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label=_('VRF'), + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label=_('VRF (RD)'), + ) + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn', + queryset=L2VPN.objects.all(), + label=_('L2VPN (ID)'), + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('L2VPN'), + ) + + class InterfaceFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, CabledObjectFilterSet, - PathEndpointFilterSet + PathEndpointFilterSet, + CommonInterfaceFilterSet ): # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis # members @@ -1370,14 +1405,6 @@ class InterfaceFilterSet( poe_type = django_filters.MultipleChoiceFilter( choices=InterfacePoETypeChoices ) - vlan_id = django_filters.CharFilter( - method='filter_vlan_id', - label=_('Assigned VLAN') - ) - vlan = django_filters.CharFilter( - method='filter_vlan', - label=_('Assigned VID') - ) type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, null_value=None @@ -1388,17 +1415,6 @@ class InterfaceFilterSet( rf_channel = django_filters.MultipleChoiceFilter( choices=WirelessChannelChoices ) - vrf_id = django_filters.ModelMultipleChoiceFilter( - field_name='vrf', - queryset=VRF.objects.all(), - label=_('VRF'), - ) - vrf = django_filters.ModelMultipleChoiceFilter( - field_name='vrf__rd', - queryset=VRF.objects.all(), - to_field_name='rd', - label=_('VRF (RD)'), - ) vdc_id = django_filters.ModelMultipleChoiceFilter( field_name='vdcs', queryset=VirtualDeviceContext.objects.all(), @@ -1416,17 +1432,6 @@ class InterfaceFilterSet( to_field_name='name', label='Virtual Device Context', ) - l2vpn_id = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn', - queryset=L2VPN.objects.all(), - label=_('L2VPN (ID)'), - ) - l2vpn = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn__identifier', - queryset=L2VPN.objects.all(), - to_field_name='identifier', - label=_('L2VPN'), - ) class Meta: model = Interface diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 11fdfa6d2..4127aa3ea 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -103,9 +103,9 @@ class RearPortBulkCreateForm( class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name', 'label', 'position_pattern', 'description', 'tags') + field_order = ('name', 'label', 'position', 'description', 'tags') replication_fields = ('name', 'label', 'position') - position_pattern = ExpandableNameField( + position = ExpandableNameField( label=_('Position'), required=False, help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)') diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 062734355..cf5f30ee4 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -152,8 +152,6 @@ class Cable(PrimaryModel): # Validate length and length_unit if self.length is not None and not self.length_unit: raise ValidationError("Must specify a unit when setting a cable length") - elif self.length is None: - self.length_unit = '' if self.pk is None and (not self.a_terminations or not self.b_terminations): raise ValidationError("Must define A and B terminations when creating a new cable.") @@ -187,6 +185,10 @@ class Cable(PrimaryModel): else: self._abs_length = None + # Clear length_unit if no length is defined + if self.length is None: + self.length_unit = '' + super().save(*args, **kwargs) # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 26a6ade98..b879b77d3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -794,8 +794,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({ 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.", }) - elif self.rf_channel: - self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') # Validate channel width against interface type and selected channel (if any) if self.rf_channel_width: @@ -803,8 +801,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'): raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."}) - elif self.rf_channel: - self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') # VLAN validation @@ -815,6 +811,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd f"interface's parent device, or it must be global." }) + def save(self, *args, **kwargs): + + # Set absolute channel attributes from selected options + if self.rf_channel and not self.rf_channel_frequency: + self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') + if self.rf_channel and not self.rf_channel_width: + self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') + + super().save(*args, **kwargs) + @property def _occupied(self): return super()._occupied or bool(self.wireless_link_id) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 603129228..0526c49cb 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -659,8 +659,6 @@ class Device(PrimaryModel, ConfigContextModel): raise ValidationError({ 'rack': f"Rack {self.rack} does not belong to location {self.location}.", }) - elif self.rack: - self.location = self.rack.location if self.rack is None: if self.face: @@ -776,8 +774,10 @@ class Device(PrimaryModel, ConfigContextModel): bulk_create: If True, bulk_create() will be called to create all components in a single query (default). Otherwise, save() will be called on each instance individually. """ - components = [obj.instantiate(device=self) for obj in queryset] - if components and bulk_create: + if bulk_create: + components = [obj.instantiate(device=self) for obj in queryset] + if not components: + return model = components[0]._meta.model model.objects.bulk_create(components) # Manually send the post_save signal for each of the newly created components @@ -790,8 +790,9 @@ class Device(PrimaryModel, ConfigContextModel): using='default', update_fields=None ) - elif components: - for component in components: + else: + for obj in queryset: + component = obj.instantiate(device=self) component.save() def save(self, *args, **kwargs): @@ -801,6 +802,9 @@ class Device(PrimaryModel, ConfigContextModel): if is_new and not self.airflow: self.airflow = self.device_type.airflow + if self.rack and self.rack.location: + self.location = self.rack.location + super().save(*args, **kwargs) # If this is a new Device, instantiate all the related components per the DeviceType definition diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 03be2fdb3..e61e0f2a3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin): # Validate outer dimensions and unit if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: raise ValidationError("Must specify a unit when setting an outer width/depth") - elif self.outer_width is None and self.outer_depth is None: - self.outer_unit = '' # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin): else: self._abs_max_weight = None + # Clear unit if outer width & depth are not set + if self.outer_width is None and self.outer_depth is None: + self.outer_unit = '' + super().save(*args, **kwargs) @property diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b49e799c..5a6261eba 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') ) + filterset = filtersets.RackRoleFilterSet table = tables.RackRoleTable @@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer') ) + filterset = filtersets.ManufacturerFilterSet table = tables.ManufacturerTable @@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView): device_count=count_related(Device, 'device_role'), vm_count=count_related(VirtualMachine, 'role') ) + filterset = filtersets.DeviceRoleFilterSet table = tables.DeviceRoleTable @@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView): class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() + filterset = filtersets.PlatformFilterSet table = tables.PlatformTable @@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView): class InventoryItemBulkDeleteView(generic.BulkDeleteView): queryset = InventoryItem.objects.all() + filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView): queryset = InventoryItemRole.objects.annotate( inventoryitem_count=count_related(InventoryItem, 'role'), ) + filterset = filtersets.InventoryItemRoleFilterSet table = tables.InventoryItemRoleTable diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index fa16b8501..836562b95 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -215,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge """ for ct in content_types: model = ct.model_class() - instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}) + instances = model.objects.filter(custom_field_data__has_key=self.name) for instance in instances: del instance.custom_field_data[self.name] model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 81a607eec..4b5efda3c 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,13 +1,10 @@ import datetime -from unittest import skipIf from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware -from django_rq.queues import get_connection from rest_framework import status -from rq import Worker from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from extras.api.views import ReportViewSet, ScriptViewSet @@ -16,8 +13,6 @@ from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases -rq_worker_running = Worker.count(get_connection('default')) - class AppTest(APITestCase): @@ -539,16 +534,6 @@ class ReportTest(APITestCase): self.assertEqual(response.data['name'], self.TestReport.__name__) - @skipIf(not rq_worker_running, "RQ worker not running") - def test_run_report(self): - self.add_permissions('extras.run_script') - - url = reverse('extras-api:report-run', kwargs={'pk': None}) - response = self.client.post(url, {}, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - - self.assertEqual(response.data['result']['status']['value'], 'pending') - class ScriptTest(APITestCase): @@ -589,27 +574,6 @@ class ScriptTest(APITestCase): self.assertEqual(response.data['vars']['var2'], 'IntegerVar') self.assertEqual(response.data['vars']['var3'], 'BooleanVar') - @skipIf(not rq_worker_running, "RQ worker not running") - def test_run_script(self): - self.add_permissions('extras.run_script') - - script_data = { - 'var1': 'FooBar', - 'var2': 123, - 'var3': False, - } - - data = { - 'data': script_data, - 'commit': True, - } - - url = reverse('extras-api:script-detail', kwargs={'pk': None}) - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - - self.assertEqual(response.data['result']['status']['value'], 'pending') - class CreatedUpdatedFilterTest(APITestCase): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 91d3b5c58..d8a3c377d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView): class ConfigContextBulkDeleteView(generic.BulkDeleteView): queryset = ConfigContext.objects.all() + filterset = filtersets.ConfigContextFilterSet table = tables.ConfigContextTable diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index e8bf13375..9effc3add 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): if self.prefix: - # Clear host bits from prefix - self.prefix = self.prefix.cidr - # /0 masks are not acceptable if self.prefix.prefixlen == 0: raise ValidationError({ diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index ea6441650..5af1bc769 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.add_permissions('ipam.delete_vlan') url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk}) - with disable_warnings('django.request'): + with disable_warnings('netbox.api.views.ModelViewSet'): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_409_CONFLICT) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 130014f3f..5f10fb5a7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView): class RoleBulkDeleteView(generic.BulkDeleteView): queryset = Role.objects.all() + filterset = filtersets.RoleFilterSet table = tables.RoleTable diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 6060475d8..211b869e6 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -16,7 +16,6 @@ from django_tables2.export import TableExport from extras.models import ExportTemplate from extras.signals import clear_webhooks -from utilities.choices import ImportFormatChoices from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields @@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): ] nullified_fields = request.POST.getlist('_nullify') updated_objects = [] + model_fields = {} + m2m_fields = {} + + # Build list of model fields and m2m fields for later iteration + for name in standard_fields: + try: + model_field = self.queryset.model._meta.get_field(name) + if isinstance(model_field, (ManyToManyField, ManyToManyRel)): + m2m_fields[name] = model_field + else: + model_fields[name] = model_field + + except FieldDoesNotExist: + # This form field is used to modify a field rather than set its value directly + model_fields[name] = None for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): @@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): obj.snapshot() # Update standard fields. If a field is listed in _nullify, delete its value. - for name in standard_fields: - - try: - model_field = self.queryset.model._meta.get_field(name) - except FieldDoesNotExist: - # This form field is used to modify a field rather than set its value directly - model_field = None - + for name, model_field in model_fields.items(): # Handle nullification if name in form.nullable_fields and name in nullified_fields: - if isinstance(model_field, ManyToManyField): - getattr(obj, name).set([]) - else: - setattr(obj, name, None if model_field.null else '') - - # ManyToManyFields - elif isinstance(model_field, (ManyToManyField, ManyToManyRel)): - if form.cleaned_data[name]: - getattr(obj, name).set(form.cleaned_data[name]) + setattr(obj, name, None if model_field.null else '') # Normal fields elif name in form.changed_data: setattr(obj, name, form.cleaned_data[name]) @@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): obj.save() updated_objects.append(obj) + # Handle M2M fields after save + for name, m2m_field in m2m_fields.items(): + if name in form.nullable_fields and name in nullified_fields: + getattr(obj, name).clear() + else: + getattr(obj, name).set(form.cleaned_data[name]) + # Add/remove tags if form.cleaned_data.get('add_tags', None): obj.tags.add(*form.cleaned_data['add_tags']) diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 1c747b44b..0ad0637e3 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -98,7 +98,7 @@
Comments
- {% render_field form.comments %} + {% render_field form.comments %}
{% if form.custom_fields %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index b814e65ef..185482162 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -111,7 +111,6 @@
-
Comments
{% render_field form.comments %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index cd9ed637a..03624df1f 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -85,7 +85,6 @@ {% endif %}
-
Comments
{% render_field form.comments %}
{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 433837cf5..82b4c5bcc 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -27,7 +27,6 @@
-
Comments
{% render_field vc_form.comments %}
diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index c61fb723f..ff2ca26ad 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -85,7 +85,6 @@ Context: {% if form.comments %}
-
Comments
{% render_field form.comments %}
{% endif %} diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html index bf86e6c41..bc0a6797c 100644 --- a/netbox/templates/ipam/fhrpgroup_edit.html +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -33,9 +33,6 @@ {% endif %}
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index b9a988009..4aa1c610a 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -139,9 +139,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/service_create.html b/netbox/templates/ipam/service_create.html index 5c47dd2f8..2d8d183c5 100644 --- a/netbox/templates/ipam/service_create.html +++ b/netbox/templates/ipam/service_create.html @@ -66,9 +66,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index 709d816c1..f2a2f711d 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -53,9 +53,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index f4432efe3..0c4b68e7e 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -56,9 +56,6 @@
-
-
Comments
-
{% render_field form.comments %}
diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html deleted file mode 100644 index 462ae5148..000000000 --- a/netbox/templates/wireless/wirelesslink_edit.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
-
-
-
-
Side A
-
- {% render_field form.device_a %} - {% render_field form.interface_a %} -
-
-
-
-
-
Side B
-
- {% render_field form.device_b %} - {% render_field form.interface_b %} -
-
-
-
-
-
Comments
-
- {% render_field form.comments %} -
- {% if form.custom_fields %} -
-
-
Custom Fields
-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index f4c9b6d04..3ee39f0a7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -84,6 +84,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView): 'tenant_count', cumulative=True ) + filterset = filtersets.TenantGroupFilterSet table = tables.TenantGroupTable @@ -247,6 +248,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): 'contact_count', cumulative=True ) + filterset = filtersets.ContactGroupFilterSet table = tables.ContactGroupTable @@ -305,6 +307,7 @@ class ContactRoleBulkEditView(generic.BulkEditView): class ContactRoleBulkDeleteView(generic.BulkDeleteView): queryset = ContactRole.objects.all() + filterset = filtersets.ContactRoleFilterSet table = tables.ContactRoleTable diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index a0bf8a49e..7041eb812 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -12,7 +12,7 @@ class AppTest(APITestCase): def test_root(self): url = reverse('users-api:api-root') - response = self.client.get('{}?format=api'.format(url), **self.header) + response = self.client.get(f'{url}?format=api', **self.header) self.assertEqual(response.status_code, 200) @@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase): 'password': 'password6', }, ] + bulk_update_data = { + 'email': 'test@example.com', + } @classmethod def setUpTestData(cls): users = ( - User(username='User_1'), - User(username='User_2'), - User(username='User_3'), + User(username='User_1', password='password1'), + User(username='User_2', password='password2'), + User(username='User_3', password='password3'), ) User.objects.bulk_create(users) @@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase): ) Group.objects.bulk_create(users) + def test_bulk_update_objects(self): + """ + Disabled test. There's no attribute we can set in bulk for Groups. + """ + return + class TokenTest( # No GraphQL support for Token diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index ee9543452..bb8226e4d 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -34,8 +34,8 @@ class CommentField(forms.CharField): Markdown syntax is supported """ - def __init__(self, *, label='', help_text=help_text, required=False, **kwargs): - super().__init__(label=label, help_text=help_text, required=required, **kwargs) + def __init__(self, *, help_text=help_text, required=False, **kwargs): + super().__init__(help_text=help_text, required=required, **kwargs) class SlugField(forms.SlugField): diff --git a/netbox/utilities/templates/buttons/clone.html b/netbox/utilities/templates/buttons/clone.html index 24e685c3d..8fe62a1c8 100644 --- a/netbox/utilities/templates/buttons/clone.html +++ b/netbox/utilities/templates/buttons/clone.html @@ -1,3 +1,5 @@ - -  Clone - +{% if url %} + + Clone + +{% endif %} diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index 85c04df92..85bd86bbc 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -3,11 +3,8 @@
- {# Render the field label, except for: #} - {# 1. Checkboxes (label appears to the right of the field #} - {# 2. Textareas with no label set (will expand across entire row) #} - {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %} - {% else %} + {# Render the field label, except for checkboxes #} + {% if field|widget_type != 'checkboxinput' %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index bcdb099d8..dbd0240b9 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -20,6 +20,8 @@ def clone_button(instance): param_string = prepare_cloned_fields(instance).urlencode() if param_string: url = f'{url}?{param_string}' + else: + url = None return { 'url': url, diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 8f656811a..cf716ca32 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,9 +2,9 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ +from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet -from ipam.models import L2VPN, VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -250,7 +250,7 @@ class VirtualMachineFilterSet( return queryset.exclude(params) -class VMInterfaceFilterSet(NetBoxModelFilterSet): +class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster', queryset=Cluster.objects.all(), @@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet): mac_address = MultiValueMACAddressFilter( label=_('MAC address'), ) - vrf_id = django_filters.ModelMultipleChoiceFilter( - field_name='vrf', - queryset=VRF.objects.all(), - label=_('VRF'), - ) - vrf = django_filters.ModelMultipleChoiceFilter( - field_name='vrf__rd', - queryset=VRF.objects.all(), - to_field_name='rd', - label=_('VRF (RD)'), - ) - l2vpn_id = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn', - queryset=L2VPN.objects.all(), - label=_('L2VPN (ID)'), - ) - l2vpn = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn_terminations__l2vpn__identifier', - queryset=L2VPN.objects.all(), - to_field_name='identifier', - label=_('L2VPN'), - ) class Meta: model = VMInterface diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index cc39044f9..6e9cc5664 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): raise ValidationError({ 'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).' }) - elif self.cluster: - self.site = self.cluster.site # Validate assigned cluster device if self.device and not self.cluster: @@ -201,6 +199,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): field: f"The specified IP address ({ip}) is not assigned to this VM.", }) + def save(self, *args, **kwargs): + + # Assign site from cluster if not set + if self.cluster and not self.site: + self.site = self.cluster.site + + super().save(*args, **kwargs) + def get_status_color(self): return VirtualMachineStatusChoices.colors.get(self.status) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index f7fa4cb39..782b9f07f 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -72,7 +72,7 @@ class VirtualMachineTestCase(TestCase): # VM with cluster site but no direct site should have its site set automatically vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0]) - vm.full_clean() + vm.save() self.assertEqual(vm.site, sites[0]) def test_vm_name_case_sensitivity(self): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index af130fcce..1ff21f1e0 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -80,6 +80,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') ) + filterset = filtersets.ClusterTypeFilterSet table = tables.ClusterTypeTable @@ -147,6 +148,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') ) + filterset = filtersets.ClusterGroupFilterSet table = tables.ClusterGroupTable