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

View File

@ -4,13 +4,20 @@
### Enhancements ### 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 * [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
### Bug Fixes ### 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 * [#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 * [#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 * [#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( queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type') circuit_count=count_related(Circuit, 'type')
) )
filterset = filtersets.CircuitTypeFilterSet
table = tables.CircuitTypeTable table = tables.CircuitTypeTable

View File

@ -24,6 +24,7 @@ __all__ = (
'CableFilterSet', 'CableFilterSet',
'CabledObjectFilterSet', 'CabledObjectFilterSet',
'CableTerminationFilterSet', 'CableTerminationFilterSet',
'CommonInterfaceFilterSet',
'ConsoleConnectionFilterSet', 'ConsoleConnectionFilterSet',
'ConsolePortFilterSet', 'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet', 'ConsolePortTemplateFilterSet',
@ -1321,11 +1322,45 @@ class PowerOutletFilterSet(
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] 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( class InterfaceFilterSet(
ModularDeviceComponentFilterSet, ModularDeviceComponentFilterSet,
NetBoxModelFilterSet, NetBoxModelFilterSet,
CabledObjectFilterSet, CabledObjectFilterSet,
PathEndpointFilterSet PathEndpointFilterSet,
CommonInterfaceFilterSet
): ):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
# members # members
@ -1370,14 +1405,6 @@ class InterfaceFilterSet(
poe_type = django_filters.MultipleChoiceFilter( poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices 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( type = django_filters.MultipleChoiceFilter(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
null_value=None null_value=None
@ -1388,17 +1415,6 @@ class InterfaceFilterSet(
rf_channel = django_filters.MultipleChoiceFilter( rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices 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( vdc_id = django_filters.ModelMultipleChoiceFilter(
field_name='vdcs', field_name='vdcs',
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
@ -1416,17 +1432,6 @@ class InterfaceFilterSet(
to_field_name='name', to_field_name='name',
label='Virtual Device Context', 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: class Meta:
model = Interface 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 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. (default). Otherwise, save() will be called on each instance individually.
""" """
components = [obj.instantiate(device=self) for obj in queryset] if bulk_create:
if components and bulk_create: components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
model = components[0]._meta.model model = components[0]._meta.model
model.objects.bulk_create(components) model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components # Manually send the post_save signal for each of the newly created components
@ -788,8 +790,9 @@ class Device(PrimaryModel, ConfigContextModel):
using='default', using='default',
update_fields=None update_fields=None
) )
elif components: else:
for component in components: for obj in queryset:
component = obj.instantiate(device=self)
component.save() component.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role') rack_count=count_related(Rack, 'role')
) )
filterset = filtersets.RackRoleFilterSet
table = tables.RackRoleTable table = tables.RackRoleTable
@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer') devicetype_count=count_related(DeviceType, 'manufacturer')
) )
filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
device_count=count_related(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role') vm_count=count_related(VirtualMachine, 'role')
) )
filterset = filtersets.DeviceRoleFilterSet
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView):
class PlatformBulkDeleteView(generic.BulkDeleteView): class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
filterset = filtersets.PlatformFilterSet
table = tables.PlatformTable table = tables.PlatformTable
@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
class InventoryItemBulkDeleteView(generic.BulkDeleteView): class InventoryItemBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItem.objects.all() queryset = InventoryItem.objects.all()
filterset = filtersets.InventoryItemFilterSet
table = tables.InventoryItemTable table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html' template_name = 'dcim/inventoryitem_bulk_delete.html'
@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate( queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'), inventoryitem_count=count_related(InventoryItem, 'role'),
) )
filterset = filtersets.InventoryItemRoleFilterSet
table = tables.InventoryItemRoleTable table = tables.InventoryItemRoleTable

View File

@ -1,13 +1,10 @@
import datetime import datetime
from unittest import skipIf
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet 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 extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
rq_worker_running = Worker.count(get_connection('default'))
class AppTest(APITestCase): class AppTest(APITestCase):
@ -539,16 +534,6 @@ class ReportTest(APITestCase):
self.assertEqual(response.data['name'], self.TestReport.__name__) 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): class ScriptTest(APITestCase):
@ -589,27 +574,6 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['vars']['var2'], 'IntegerVar') self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar') 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): class CreatedUpdatedFilterTest(APITestCase):

View File

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

View File

@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.add_permissions('ipam.delete_vlan') self.add_permissions('ipam.delete_vlan')
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk}) 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) response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)

View File

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

View File

@ -16,7 +16,6 @@ from django_tables2.export import TableExport
from extras.models import ExportTemplate from extras.models import ExportTemplate
from extras.signals import clear_webhooks from extras.signals import clear_webhooks
from utilities.choices import ImportFormatChoices
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
] ]
nullified_fields = request.POST.getlist('_nullify') nullified_fields = request.POST.getlist('_nullify')
updated_objects = [] 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']): for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.snapshot() obj.snapshot()
# Update standard fields. If a field is listed in _nullify, delete its value. # Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields: for name, model_field in model_fields.items():
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
# Handle nullification # Handle nullification
if name in form.nullable_fields and name in nullified_fields: if name in form.nullable_fields and name in nullified_fields:
if isinstance(model_field, ManyToManyField): setattr(obj, name, None if model_field.null else '')
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 # Normal fields
elif name in form.changed_data: elif name in form.changed_data:
setattr(obj, name, form.cleaned_data[name]) setattr(obj, name, form.cleaned_data[name])
@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.save() obj.save()
updated_objects.append(obj) 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 # Add/remove tags
if form.cleaned_data.get('add_tags', None): if form.cleaned_data.get('add_tags', None):
obj.tags.add(*form.cleaned_data['add_tags']) obj.tags.add(*form.cleaned_data['add_tags'])

View File

@ -98,7 +98,7 @@
<div class="card"> <div class="card">
<h5 class="card-header text-center">Comments</h5> <h5 class="card-header text-center">Comments</h5>
<div class="card-body"> <div class="card-body">
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
</div> </div>
{% if form.custom_fields %} {% if form.custom_fields %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -56,9 +56,6 @@
</div> </div>
<div class="field-group my-5"> <div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %} {% render_field form.comments %}
</div> </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', 'tenant_count',
cumulative=True cumulative=True
) )
filterset = filtersets.TenantGroupFilterSet
table = tables.TenantGroupTable table = tables.TenantGroupTable
@ -247,6 +248,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
'contact_count', 'contact_count',
cumulative=True cumulative=True
) )
filterset = filtersets.ContactGroupFilterSet
table = tables.ContactGroupTable table = tables.ContactGroupTable
@ -305,6 +307,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
class ContactRoleBulkDeleteView(generic.BulkDeleteView): class ContactRoleBulkDeleteView(generic.BulkDeleteView):
queryset = ContactRole.objects.all() queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
table = tables.ContactRoleTable table = tables.ContactRoleTable

View File

@ -12,7 +12,7 @@ class AppTest(APITestCase):
def test_root(self): def test_root(self):
url = reverse('users-api:api-root') 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) self.assertEqual(response.status_code, 200)
@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
'password': 'password6', 'password': 'password6',
}, },
] ]
bulk_update_data = {
'email': 'test@example.com',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
users = ( users = (
User(username='User_1'), User(username='User_1', password='password1'),
User(username='User_2'), User(username='User_2', password='password2'),
User(username='User_3'), User(username='User_3', password='password3'),
) )
User.objects.bulk_create(users) User.objects.bulk_create(users)
@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
) )
Group.objects.bulk_create(users) 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( class TokenTest(
# No GraphQL support for Token # No GraphQL support for Token

View File

@ -34,8 +34,8 @@ class CommentField(forms.CharField):
Markdown</a> syntax is supported Markdown</a> syntax is supported
""" """
def __init__(self, *, label='', help_text=help_text, required=False, **kwargs): def __init__(self, *, help_text=help_text, required=False, **kwargs):
super().__init__(label=label, help_text=help_text, required=required, **kwargs) super().__init__(help_text=help_text, required=required, **kwargs)
class SlugField(forms.SlugField): class SlugField(forms.SlugField):

View File

@ -3,11 +3,8 @@
<div class="row mb-3{% if field.errors %} has-errors{% endif %}"> <div class="row mb-3{% if field.errors %} has-errors{% endif %}">
{# Render the field label, except for: #} {# Render the field label, except for checkboxes #}
{# 1. Checkboxes (label appears to the right of the field #} {% if field|widget_type != 'checkboxinput' %}
{# 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 %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}"> <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }} {{ label }}
</label> </label>

View File

@ -2,9 +2,9 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import L2VPN, VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@ -250,7 +250,7 @@ class VirtualMachineFilterSet(
return queryset.exclude(params) return queryset.exclude(params)
class VMInterfaceFilterSet(NetBoxModelFilterSet): class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
cluster_id = django_filters.ModelMultipleChoiceFilter( cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__cluster', field_name='virtual_machine__cluster',
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
mac_address = MultiValueMACAddressFilter( mac_address = MultiValueMACAddressFilter(
label=_('MAC address'), 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: class Meta:
model = VMInterface model = VMInterface

View File

@ -80,6 +80,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate( queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type') cluster_count=count_related(Cluster, 'type')
) )
filterset = filtersets.ClusterTypeFilterSet
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
@ -147,6 +148,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate( queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group') cluster_count=count_related(Cluster, 'group')
) )
filterset = filtersets.ClusterGroupFilterSet
table = tables.ClusterGroupTable table = tables.ClusterGroupTable