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}") 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 diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 8bdfb1578..36dc97bb9 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -4,9 +4,19 @@ ### 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 +### 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/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)'), 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/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/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/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) 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 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/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/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 %} 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 %} 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 %} 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