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/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 1e4a35332..1fccd0270 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user ( ```postgresql CREATE DATABASE netbox; CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; -GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox; +ALTER DATABASE netbox OWNER TO netbox; ``` !!! danger "Use a strong password" diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 47f590d59..5f086bca4 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -2,6 +2,23 @@ ## v3.4.8 (FUTURE) +### 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 + --- ## v3.4.7 (2023-03-28) diff --git a/mkdocs.yml b/mkdocs.yml index bf4251e0e..ae7fcadf8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,9 @@ theme: custom_dir: docs/_theme/ icon: repo: fontawesome/brands/github + features: + - content.code.copy + - navigation.footer palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -20,7 +23,8 @@ theme: icon: material/lightbulb name: Switch to Light Mode plugins: - - search + - search: + lang: en - mkdocstrings: handlers: python: diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index fc9540d16..e5f4faee1 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -247,6 +247,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 6f7ac419e..4f3122456 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -25,6 +25,7 @@ __all__ = ( 'CableFilterSet', 'CabledObjectFilterSet', 'CableTerminationFilterSet', + 'CommonInterfaceFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', 'ConsolePortTemplateFilterSet', @@ -1348,11 +1349,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 @@ -1397,14 +1432,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 @@ -1415,17 +1442,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(), @@ -1443,17 +1459,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 a6b8be57a..b497a90d2 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -797,8 +797,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: @@ -806,8 +804,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 @@ -818,6 +814,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 de6d1bc83..e0eceea1f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -707,8 +707,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: @@ -824,8 +822,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 @@ -838,8 +838,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): @@ -853,6 +854,10 @@ class Device(PrimaryModel, ConfigContextModel): if is_new and not self.platform: self.platform = self.device_type.default_platform + # Inherit location from Rack if not set + 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 e6a9b02d4..e5412a3ab 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 114df410a..a6920f46e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -578,6 +578,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( rack_count=count_related(Rack, 'role') ) + filterset = filtersets.RackRoleFilterSet table = tables.RackRoleTable @@ -867,6 +868,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') ) + filterset = filtersets.ManufacturerFilterSet table = tables.ManufacturerTable @@ -1728,6 +1730,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView): device_count=count_related(Device, 'device_role'), vm_count=count_related(VirtualMachine, 'role') ) + filterset = filtersets.DeviceRoleFilterSet table = tables.DeviceRoleTable @@ -1785,6 +1788,7 @@ class PlatformBulkEditView(generic.BulkEditView): class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() + filterset = filtersets.PlatformFilterSet table = tables.PlatformTable @@ -2853,6 +2857,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' @@ -2909,6 +2914,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 73d5cf9ce..18430300f 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -221,7 +221,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): """ 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/models/models.py b/netbox/extras/models/models.py index 61dae5c99..ef604df91 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -306,7 +306,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change max_length=50, blank=True, verbose_name='MIME type', - help_text=_('Defaults to text/plain') + help_text=_('Defaults to text/plain; charset=utf-8') ) file_extension = models.CharField( max_length=15, @@ -368,7 +368,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change Render the template to an HTTP response, delivered as a named file attachment """ output = self.render(queryset) - mime_type = 'text/plain' if not self.mime_type else self.mime_type + mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type # Build the response response = HttpResponse(output, content_type=mime_type) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index aa293e318..ef6fa605b 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 core.choices import ManagedFileRootPathChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site @@ -17,8 +14,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): @@ -547,16 +542,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): @@ -603,27 +588,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 4e25539ce..685ee853b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -422,6 +422,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 9dff944bb..28901ab8e 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -120,9 +120,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 3908c8f3d..b6aeccc1a 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -952,7 +952,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 1d1ef1d06..a49c4aab3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -465,6 +465,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 549e1cd8c..8ca77ce55 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -503,6 +503,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']): @@ -511,25 +526,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]) @@ -547,6 +547,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/device_edit.html b/netbox/templates/dcim/device_edit.html index d0677ad20..17780b513 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -108,7 +108,6 @@
-
Comments
{% render_field form.comments %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 413feff31..4bbd72405 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -83,7 +83,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/htmx/form.html b/netbox/templates/htmx/form.html index e15df4706..09360d996 100644 --- a/netbox/templates/htmx/form.html +++ b/netbox/templates/htmx/form.html @@ -36,7 +36,6 @@ {% if form.comments %}
-
Comments
{% render_field form.comments %}
{% endif %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 95c566cc9..55c4c8a6b 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -134,9 +134,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 bbb332050..7b8c85cb8 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -53,9 +53,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 2a0fe19b5..ba7249c8d 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -83,6 +83,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView): 'tenant_count', cumulative=True ) + filterset = filtersets.TenantGroupFilterSet table = tables.TenantGroupTable @@ -233,6 +234,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): 'contact_count', cumulative=True ) + filterset = filtersets.ContactGroupFilterSet table = tables.ContactGroupTable @@ -286,6 +288,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 9948d7889..cb8c14d6d 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 9ef7ec930..ecc39e434 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -3,9 +3,7 @@
- {# 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) #} + {# Render the field label (if any), except for checkboxes #} {% if label and not field|widget_type == 'checkboxinput' %}