From cb6852bf7add10289bc17a6ae9389303922af573 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 01:32:16 +0530 Subject: [PATCH 01/10] adds CXP (100GE) #12323 --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8ad8a0eb7..d32f5aaee 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -807,6 +807,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100GE_CFP = '100gbase-x-cfp' TYPE_100GE_CFP2 = '100gbase-x-cfp2' TYPE_100GE_CFP4 = '100gbase-x-cfp4' + TYPE_100GE_CXP = '100gbase-x-cxp' TYPE_100GE_CPAK = '100gbase-x-cpak' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' TYPE_200GE_CFP2 = '200gbase-x-cfp2' @@ -952,6 +953,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'), + (TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), From 1af3ba949689e1d5452a3290f023b024821b130a Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 18:01:50 +0530 Subject: [PATCH 02/10] Adds full_clean in examples (#12527) * adds full_clean in examples #11689 * removes extra info --- docs/administration/netbox-shell.md | 8 ++------ docs/customization/custom-scripts.md | 2 ++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index 1a10c5c3e..21cef01b2 100644 --- a/docs/administration/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f ``` >>> lab1 = Site.objects.get(pk=7) >>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1) +>>> myvlan.full_clean() >>> myvlan.save() ``` -Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.) - -``` ->>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save() -``` - To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again. ``` @@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c >>> vlan.name 'MyNewVLAN' >>> vlan.name = 'BetterName' +>>> vlan.full_clean() >>> vlan.save() >>> VLAN.objects.get(pk=1280).name 'BetterName' diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 074da34cd..e20b09ae6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -378,6 +378,7 @@ class NewBranchScript(Script): slug=slugify(data['site_name']), status=SiteStatusChoices.STATUS_PLANNED ) + site.full_clean() site.save() self.log_success(f"Created new site: {site}") @@ -391,6 +392,7 @@ class NewBranchScript(Script): status=DeviceStatusChoices.STATUS_PLANNED, device_role=switch_role ) + switch.full_clean() switch.save() self.log_success(f"Created new switch: {switch}") From 2b2c559a370f32aabb463c4e1faacaf07d82f342 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 01:07:31 +0530 Subject: [PATCH 03/10] updates ldap doc for centos #12447 --- docs/installation/6-ldap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index ffba6889b..1cd3e1f0a 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev On CentOS: ```no-highlight -sudo yum install -y openldap-devel +sudo yum install -y openldap-devel python3-devel ``` ### Install django-auth-ldap From e1b7a3aeb609e22587da4002d933ceadc3aad7c4 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 19:05:00 +0530 Subject: [PATCH 04/10] Replaced device type weight with device total weight (#12522) * replaced device type weight with device total weight #12286 * replaced device type weight with device total weight #12286 * Update netbox/templates/dcim/device.html Co-authored-by: Jeremy Stretch --------- Co-authored-by: Jeremy Stretch --- netbox/templates/dcim/device.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 13d5f2a94..aa1b80cf7 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -313,8 +313,8 @@ Weight - {% if object.device_type.weight %} - {{ object.device_type.weight|floatformat }} {{ object.device_type.get_weight_unit_display }} + {% if object.total_weight %} + {{ object.total_weight|floatformat }} Kilograms {% else %} {{ ''|placeholder }} {% endif %} From c55c14ea4c48ddb5d16b0411d56c919cec34e4f1 Mon Sep 17 00:00:00 2001 From: Dillon Henschen Date: Tue, 9 May 2023 09:59:42 -0400 Subject: [PATCH 05/10] Closes #11670: Add ability to optionally import DeviceType and ModuleType weight (#12512) * 11670: Add optional weight to DeviceType import This is 1 of 2 commits to address issue #11670 To maintain consistency, the import design of the DeviceType weight follows the same pattern used for importing weight and weight units in DCIM Racks. * Closes #11670: Add weight to ModuleType import This is commit 2 of 2 to address and close #11670. To maintain consistency, the import design of the ModuleType weight follows the same pattern used for importing weight and weight units in DCIM Racks. * Merge tests; misc cleanup --------- Co-authored-by: jeremystretch --- netbox/dcim/forms/bulk_import.py | 22 ++++++++++++++++++++-- netbox/dcim/models/devices.py | 4 ++++ netbox/dcim/tests/test_views.py | 24 +++++++++++++++++++++--- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8b7bd47ea..cdb59e9eb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm): required=False, help_text=_('The default platform for devices of this type (optional)') ) + weight = forms.DecimalField( + required=False, + help_text=_('Device weight'), + ) + weight_unit = CSVChoiceField( + choices=WeightUnitChoices, + required=False, + help_text=_('Unit for device weight') + ) class Meta: model = DeviceType fields = [ 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'description', 'comments', + 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', ] @@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm): queryset=Manufacturer.objects.all(), to_field_name='name' ) + weight = forms.DecimalField( + required=False, + help_text=_('Module weight'), + ) + weight_unit = CSVChoiceField( + choices=WeightUnitChoices, + required=False, + help_text=_('Unit for module weight') + ) class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments'] class DeviceRoleImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 02c68c10a..85a5d6870 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin): 'subdevice_role': self.subdevice_role, 'airflow': self.airflow, 'comments': self.comments, + 'weight': float(self.weight) if self.weight is not None else None, + 'weight_unit': self.weight_unit, } # Component templates @@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin): 'model': self.model, 'part_number': self.part_number, 'comments': self.comments, + 'weight': float(self.weight) if self.weight is not None else None, + 'weight_unit': self.weight_unit, } # Component templates diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index bae5a8e0b..44e6ef2a9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -681,11 +681,15 @@ class DeviceTypeTestCase( """ IMPORT_DATA = """ manufacturer: Generic -default_platform: Platform model: TEST-1000 slug: test-1000 +default_platform: Platform u_height: 2 +is_full_depth: false +airflow: front-to-rear subdevice_role: parent +weight: 10 +weight_unit: kg comments: Test comment console-ports: - name: Console Port 1 @@ -794,8 +798,16 @@ inventory-items: self.assertHttpStatus(response, 200) device_type = DeviceType.objects.get(model='TEST-1000') - self.assertEqual(device_type.comments, 'Test comment') + self.assertEqual(device_type.manufacturer.pk, manufacturer.pk) self.assertEqual(device_type.default_platform.pk, platform.pk) + self.assertEqual(device_type.slug, 'test-1000') + self.assertEqual(device_type.u_height, 2) + self.assertFalse(device_type.is_full_depth) + self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR) + self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT) + self.assertEqual(device_type.weight, 10) + self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM) + self.assertEqual(device_type.comments, 'Test comment') # Verify all of the components were created self.assertEqual(device_type.consoleporttemplates.count(), 3) @@ -1019,6 +1031,8 @@ class ModuleTypeTestCase( IMPORT_DATA = """ manufacturer: Generic model: TEST-1000 +weight: 10 +weight_unit: lb comments: Test comment console-ports: - name: Console Port 1 @@ -1082,7 +1096,8 @@ front-ports: """ # Create the manufacturer - Manufacturer(name='Generic', slug='generic').save() + manufacturer = Manufacturer(name='Generic', slug='generic') + manufacturer.save() # Add all required permissions to the test user self.add_permissions( @@ -1105,6 +1120,9 @@ front-ports: self.assertHttpStatus(response, 200) module_type = ModuleType.objects.get(model='TEST-1000') + self.assertEqual(module_type.manufacturer.pk, manufacturer.pk) + self.assertEqual(module_type.weight, 10) + self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND) self.assertEqual(module_type.comments, 'Test comment') # Verify all the components were created From 4e49f4a434b109aede79b618b7dd8d3d6865c17f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 19:50:02 +0530 Subject: [PATCH 06/10] Adds tooltip on custom field (#12505) * adds tooltip on custom field #12131 * adds description field check * fixed field name * updated code to match the panel * added escape filter on description --- .../templates/circuits/inc/circuit_termination.html | 13 ++++++++++--- netbox/templates/inc/panels/custom_fields.html | 11 +++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 22c204afc..b26a09205 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -132,9 +132,16 @@ {% for field, value in fields.items %} - - {{ field }} - + {{ field }} + {% if field.description %} + + {% endif %} + {% customfield_value field value %} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 45843eea5..7f5f4cc27 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -12,8 +12,15 @@ {% for field, value in fields.items %} -
- {{ field }} + {{ field }} + {% if field.description %} + + {% endif %} {% customfield_value field value %} From 57156f0e9442b60d1665eb1839ef806ddfd55726 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 19:51:23 +0530 Subject: [PATCH 07/10] Adds stroke to the reservation (#12506) * adds stroke to the reservation #11900 * fixed right side border * Tweak reserved stroke style & add constants for colors --------- Co-authored-by: jeremystretch --- netbox/dcim/svg/racks.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 62878cef9..9c317ea16 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -22,6 +22,11 @@ __all__ = ( 'RackElevationSVG', ) +GRADIENT_RESERVED = '#b0b0ff' +GRADIENT_OCCUPIED = '#d7d7d7' +GRADIENT_BLOCKED = '#ffc0c0' +STROKE_RESERVED = '#4d4dff' + def get_device_name(device): if device.virtual_chassis: @@ -132,9 +137,9 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') - RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED) + RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED) + RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED) return drawing @@ -246,13 +251,13 @@ class RackElevationSVG: coords = self._get_device_coords(segment[0], u_height) coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) size = ( - self.margin_width, + self.margin_width - 3, u_height * self.unit_height ) link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent') link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') link.add( - Rect(coords, size, class_='reservation') + Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2) ) self.drawing.add(link) From 6b19f15a7b605ff87aefd6905a930a452ceffb9b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 9 May 2023 22:19:13 +0530 Subject: [PATCH 08/10] Moves related ips to a tab (#12502) * moves related ips to a tab #12233 * Refactor IP address templates to use a base template --------- Co-authored-by: jeremystretch --- netbox/ipam/models/ip.py | 8 ++++++ netbox/ipam/views.py | 28 ++++++++++++------- netbox/templates/ipam/ipaddress.html | 8 ------ netbox/templates/ipam/ipaddress/base.html | 8 ++++++ .../ipam/ipaddress/ip_addresses.html | 19 +++++++++++++ 5 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 netbox/templates/ipam/ipaddress/base.html create mode 100644 netbox/templates/ipam/ipaddress/ip_addresses.html diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 28901ab8e..015f9220c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -783,6 +783,14 @@ class IPAddress(PrimaryModel): if available_ips: return next(iter(available_ips)) + def get_related_ips(self): + """ + Return all IPAddresses belonging to the same VRF. + """ + return IPAddress.objects.exclude(address=str(self.address)).filter( + vrf=self.vrf, address__net_contained_or_equal=str(self.address) + ) + def clean(self): super().clean() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 93d0dc8bb..6b19b502d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -755,19 +755,9 @@ class IPAddressView(generic.ObjectView): # Limit to a maximum of 10 duplicates displayed here duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False) - # Related IP table - related_ips = IPAddress.objects.restrict(request.user, 'view').exclude( - address=str(instance.address) - ).filter( - vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) - ) - related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - related_ips_table.configure(request) - return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, - 'related_ips_table': related_ips_table, } @@ -872,6 +862,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView): table = tables.IPAddressTable +@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses') +class IPAddressRelatedIPsView(generic.ObjectChildrenView): + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') + child_model = IPAddress + table = tables.IPAddressTable + filterset = filtersets.IPAddressFilterSet + template_name = 'ipam/ipaddress/ip_addresses.html' + tab = ViewTab( + label=_('Related IPs'), + badge=lambda x: x.get_related_ips().count(), + weight=500, + hide_if_empty=True, + ) + + def get_children(self, request, parent): + return parent.get_related_ips().restrict(request.user, 'view') + + # # VLAN groups # diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index c649f1dad..e58ac736f 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -3,13 +3,6 @@ {% load plugins %} {% load render_table from django_tables2 %} -{% block breadcrumbs %} - {{ block.super }} - {% if object.vrf %} - - {% endif %} -{% endblock %} - {% block content %}
@@ -116,7 +109,6 @@ {% if duplicate_ips_table.rows %} {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %} {% endif %} - {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
Services
{{ object.vrf }} + {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/ipaddress/ip_addresses.html b/netbox/templates/ipam/ipaddress/ip_addresses.html new file mode 100644 index 000000000..7034329aa --- /dev/null +++ b/netbox/templates/ipam/ipaddress/ip_addresses.html @@ -0,0 +1,19 @@ +{% extends 'ipam/ipaddress/base.html' %} +{% load helpers %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} +
+ {% csrf_token %} +
+
+ {% include 'htmx/table.html' %} +
+
+
+{% endblock content %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} From 2d0ac213c70b7483f406d63c09945ac69c8ed290 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 10 May 2023 09:39:25 -0400 Subject: [PATCH 09/10] Changelog for #11670, #11900, #12131, #12233, #12286, #12323 --- docs/release-notes/version-3.5.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 8bdfb1578..b9094a658 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -4,7 +4,13 @@ ### Enhancements +* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import +* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations +* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views * [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list +* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab +* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view +* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type * [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty --- From b7f028fba39026ca92f439c2ba64b0dfa587d429 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 10 May 2023 10:44:01 -0400 Subject: [PATCH 10/10] Fixes #12550: Fix rear port selection widget under front port creation form --- docs/release-notes/version-3.5.md | 4 ++++ netbox/dcim/forms/object_create.py | 1 + netbox/utilities/forms/mixins.py | 8 ++++---- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index b9094a658..36dc97bb9 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -13,6 +13,10 @@ * [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type * [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty +### Bug Fixes + +* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form + --- ## v3.5.1 (2023-05-05) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 236077421..d4c9e6ec3 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -242,6 +242,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): choices=[], label=_('Rear ports'), help_text=_('Select one rear port assignment for each front port being created.'), + widget=forms.SelectMultiple(attrs={'size': 6}) ) # Override fieldsets from FrontPortForm to omit rear_port_position diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index dc9c3eb80..7cdb9731e 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -32,11 +32,11 @@ class BootstrapMixin: elif isinstance(field.widget, forms.CheckboxInput): field.widget.attrs['class'] = f'{css} form-check-input' - elif isinstance(field.widget, forms.SelectMultiple): - if 'size' not in field.widget.attrs: - field.widget.attrs['class'] = f'{css} netbox-static-select' + elif isinstance(field.widget, forms.SelectMultiple) and 'size' in field.widget.attrs: + # Use native Bootstrap class for multi-line