Merge branch 'develop' into 10615-filter-cable-end

This commit is contained in:
Arthur 2023-04-07 09:09:46 -07:00
commit 199bb8dc3d
28 changed files with 109 additions and 186 deletions

View File

@ -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)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

View File

@ -4,13 +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
---

View File

@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.CircuitTypeFilterSet
table = tables.CircuitTypeTable

View File

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

View File

@ -774,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.
"""
if bulk_create:
components = [obj.instantiate(device=self) for obj in queryset]
if components and bulk_create:
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
@ -788,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):

View File

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

View File

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

View File

@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
filterset = filtersets.ConfigContextFilterSet
table = tables.ConfigContextTable

View File

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

View File

@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView):
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
filterset = filtersets.RoleFilterSet
table = tables.RoleTable

View File

@ -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])
# 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'])

View File

@ -111,7 +111,6 @@
</div>
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>

View File

@ -85,7 +85,6 @@
{% endif %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@ -27,7 +27,6 @@
</div>
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field vc_form.comments %}
</div>

View File

@ -85,7 +85,6 @@ Context:
{% if form.comments %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endif %}

View File

@ -33,9 +33,6 @@
{% endif %}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -139,9 +139,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -66,9 +66,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -53,9 +53,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -56,9 +56,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@ -1,39 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="row">
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side A</h5>
</div>
{% render_field form.device_a %}
{% render_field form.interface_a %}
</div>
</div>
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side B</h5>
</div>
{% render_field form.device_b %}
{% render_field form.interface_b %}
</div>
</div>
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Comments</h5>
</div>
{% render_field form.comments %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

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

View File

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

View File

@ -34,8 +34,8 @@ class CommentField(forms.CharField):
Markdown</a> 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):

View File

@ -3,11 +3,8 @@
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
{# 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' %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }}
</label>

View File

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

View File

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