diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 8664768ee..a587b36e2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.2 + placeholder: v3.6.3 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 8e3af527a..71f1f2d97 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.2 + placeholder: v3.6.3 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index db19b6c11..db0e3d3ea 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,5 +1,28 @@ # NetBox v3.6 +## v3.6.3 (2023-09-26) + +### Enhancements + +* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view + +### Bug Fixes + +* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel +* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API +* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API +* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined +* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements +* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit +* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type +* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed +* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches +* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers +* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import +* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface + +--- + ## v3.6.2 (2023-09-20) ### Enhancements diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 74af0696b..70aceaa49 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -549,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm): params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), } - if 'location' in data: + if location := data.get('location'): params.update({ - f"location__{self.fields['location'].to_field_name}": data.get('location'), + f"location__{self.fields['location'].to_field_name}": location, }) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index ba9e11d46..751bca271 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -20,7 +20,7 @@ from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters from wireless.models import WirelessLink -from .device_components import FrontPort, RearPort +from .device_components import FrontPort, RearPort, PathEndpoint __all__ = ( 'Cable', @@ -518,9 +518,16 @@ class CablePath(models.Model): # Terminations must all be of the same type assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + # All mid-span terminations must all be attached to the same device + if not isinstance(terminations[0], PathEndpoint): + assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:]) + # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached) - if len(set(t.link for t in terminations)) > 1: + if len(set(t.link for t in terminations)) > 1 and ( + position_stack and len(terminations) != len(position_stack[-1]) + ): is_split = True break @@ -529,46 +536,68 @@ class CablePath(models.Model): object_to_path_node(t) for t in terminations ]) - # Step 2: Determine the attached link (Cable or WirelessLink), if any - link = terminations[0].link - if link is None and len(path) == 1: - # If this is the start of the path and no link exists, return None - return None - elif link is None: + # Step 2: Determine the attached links (Cable or WirelessLink), if any + links = [termination.link for termination in terminations if termination.link is not None] + if len(links) == 0: + if len(path) == 1: + # If this is the start of the path and no link exists, return None + return None # Otherwise, halt the trace if no link exists break - assert type(link) in (Cable, WirelessLink) + assert all(type(link) in (Cable, WirelessLink) for link in links) + assert all(isinstance(link, type(links[0])) for link in links) - # Step 3: Record the link and update path status if not "connected" - path.append([object_to_path_node(link)]) - if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED: + # Step 3: Record asymmetric paths as split + not_connected_terminations = [termination.link for termination in terminations if termination.link is None] + if len(not_connected_terminations) > 0: + is_complete = False + is_split = True + + # Step 4: Record the links, keeping cables in order to allow for SVG rendering + cables = [] + for link in links: + if object_to_path_node(link) not in cables: + cables.append(object_to_path_node(link)) + path.append(cables) + + # Step 5: Update the path status if a link is not connected + links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED] + if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]): is_active = False - # Step 4: Determine the far-end terminations - if isinstance(link, Cable): + # Step 6: Determine the far-end terminations + if isinstance(links[0], Cable): termination_type = ContentType.objects.get_for_model(terminations[0]) local_cable_terminations = CableTermination.objects.filter( termination_type=termination_type, termination_id__in=[t.pk for t in terminations] ) - # Terminations must all belong to same end of Cable - local_cable_end = local_cable_terminations[0].cable_end - assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:]) - remote_cable_terminations = CableTermination.objects.filter( - cable=link, - cable_end='A' if local_cable_end == 'B' else 'B' - ) + + q_filter = Q() + for lct in local_cable_terminations: + cable_end = 'A' if lct.cable_end == 'B' else 'B' + q_filter |= Q(cable=lct.cable, cable_end=cable_end) + + remote_cable_terminations = CableTermination.objects.filter(q_filter) remote_terminations = [ct.termination for ct in remote_cable_terminations] else: # WirelessLink - remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a] + remote_terminations = [ + link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links + ] - # Step 5: Record the far-end termination object(s) + # Remote Terminations must all be of the same type, otherwise return a split path + if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): + is_complete = False + is_split = True + break + + # Step 7: Record the far-end termination object(s) path.append([ object_to_path_node(t) for t in remote_terminations if t is not None ]) - # Step 6: Determine the "next hop" terminations, if applicable + # Step 8: Determine the "next hop" terminations, if applicable if not remote_terminations: break @@ -577,20 +606,32 @@ class CablePath(models.Model): rear_ports = RearPort.objects.filter( pk__in=[t.rear_port_id for t in remote_terminations] ) - if len(rear_ports) > 1: - assert all(rp.positions == 1 for rp in rear_ports) - elif rear_ports[0].positions > 1: + if len(rear_ports) > 1 or rear_ports[0].positions > 1: position_stack.append([fp.rear_port_position for fp in remote_terminations]) terminations = rear_ports elif isinstance(remote_terminations[0], RearPort): - - if len(remote_terminations) > 1 or remote_terminations[0].positions == 1: + if len(remote_terminations) == 1 and remote_terminations[0].positions == 1: front_ports = FrontPort.objects.filter( rear_port_id__in=[rp.pk for rp in remote_terminations], rear_port_position=1 ) + # Obtain the individual front ports based on the termination and all positions + elif len(remote_terminations) > 1 and position_stack: + positions = position_stack.pop() + + # Ensure we have a number of positions equal to the amount of remote terminations + assert len(remote_terminations) == len(positions) + + # Get our front ports + q_filter = Q() + for rt in remote_terminations: + position = positions.pop() + q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) + assert q_filter is not Q() + front_ports = FrontPort.objects.filter(q_filter) + # Obtain the individual front ports based on the termination and position elif position_stack: front_ports = FrontPort.objects.filter( rear_port_id=remote_terminations[0].pk, @@ -632,9 +673,16 @@ class CablePath(models.Model): terminations = [circuit_termination] - # Anything else marks the end of the path else: - is_complete = True + # Check for non-symmetric path + if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): + is_complete = True + elif len(remote_terminations) == 0: + is_complete = False + else: + # Unsupported topology, mark as split and exit + is_complete = False + is_split = True break return cls( @@ -740,3 +788,15 @@ class CablePath(models.Model): return [ ct.get_peer_termination() for ct in nodes ] + + def get_asymmetric_nodes(self): + """ + Return all available next segments in a split cable path. + """ + from circuits.models import CircuitTermination + asymmetric_nodes = [] + for nodes in self.path_objects: + if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]: + asymmetric_nodes.extend([node for node in nodes if node.link is None]) + + return asymmetric_nodes diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 9cca724ce..c9ebf898d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -4,6 +4,7 @@ import yaml from functools import cached_property from django.core.exceptions import ValidationError +from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F, ProtectedError @@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): ret = super().save(*args, **kwargs) # Delete any previously uploaded image files that are no longer in use - if self.front_image != self._original_front_image: - self._original_front_image.delete(save=False) - if self.rear_image != self._original_rear_image: - self._original_rear_image.delete(save=False) + if self._original_front_image and self.front_image != self._original_front_image: + default_storage.delete(self._original_front_image) + if self._original_rear_image and self.rear_image != self._original_rear_image: + default_storage.delete(self._original_rear_image) return ret diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 9413726fa..c01e656fd 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -32,11 +32,18 @@ class Node(Hyperlink): color: Box fill color (RRGGBB format) labels: An iterable of text strings. Each label will render on a new line within the box. radius: Box corner radius, for rounded corners (default: 10) + object: A copy of the object to allow reference when drawing cables to determine which cables are connected to + which terminations. """ - def __init__(self, position, width, url, color, labels, radius=10, **extra): + object = None + + def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra): super(Node, self).__init__(href=url, target='_parent', **extra) + # Save object for reference by cable systems + self.object = object + x, y = position # Add the box @@ -77,7 +84,7 @@ class Connector(Group): labels: Iterable of text labels """ - def __init__(self, start, url, color, labels=[], **extra): + def __init__(self, start, url, color, labels=[], description=[], **extra): super().__init__(class_='connector', **extra) self.start = start @@ -104,6 +111,8 @@ class Connector(Group): text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) text = Text(label, insert=text_coords, class_='bold' if not i else []) link.add(text) + if len(description) > 0: + link.set_desc("\n".join(description)) self.add(link) @@ -206,7 +215,8 @@ class CableTraceSVG: url=f'{self.base_url}{term.get_absolute_url()}', color=self._get_color(term), labels=self._get_labels(term), - radius=5 + radius=5, + object=term ) nodes_height = max(nodes_height, node.box['height']) nodes.append(node) @@ -238,22 +248,65 @@ class CableTraceSVG: Polyline(points=points, style=f'stroke: #{connector.color}'), )) - def draw_cable(self, cable): - labels = [ - f'Cable {cable}', - cable.get_status_display() - ] - if cable.type: - labels.append(cable.get_type_display()) - if cable.length and cable.length_unit: - labels.append(f'{cable.length} {cable.get_length_unit_display()}') + def draw_cable(self, cable, terminations, cable_count=0): + """ + Draw a single cable. Terminations and cable count are passed for determining position and padding + + :param cable: The cable to draw + :param terminations: List of terminations to build positioning data off of + :param cable_count: Count of all cables on this layer for determining whether to collapse description into a + tooltip. + """ + + # If the cable count is higher than 2, collapse the description into a tooltip + if cable_count > 2: + # Use the cable __str__ function to denote the cable + labels = [f'{cable}'] + + # Include the label and the status description in the tooltip + description = [ + f'Cable {cable}', + cable.get_status_display() + ] + + if cable.type: + # Include the cable type in the tooltip + description.append(cable.get_type_display()) + if cable.length and cable.length_unit: + # Include the cable length in the tooltip + description.append(f'{cable.length} {cable.get_length_unit_display()}') + else: + labels = [ + f'Cable {cable}', + cable.get_status_display() + ] + description = [] + if cable.type: + labels.append(cable.get_type_display()) + if cable.length and cable.length_unit: + # Include the cable length in the tooltip + labels.append(f'{cable.length} {cable.get_length_unit_display()}') + + # If there is only one termination, center on that termination + # Otherwise average the center across the terminations + if len(terminations) == 1: + center = terminations[0].bottom_center[0] + else: + # Get a list of termination centers + termination_centers = [term.bottom_center[0] for term in terminations] + # Average the centers + center = sum(termination_centers) / len(termination_centers) + + # Create the connector connector = Connector( - start=(self.center + OFFSET, self.cursor), + start=(center, self.cursor), color=cable.color or '000000', url=f'{self.base_url}{cable.get_absolute_url()}', - labels=labels + labels=labels, + description=description ) + # Set the cursor position self.cursor += connector.height return connector @@ -334,34 +387,52 @@ class CableTraceSVG: # Connector (a Cable or WirelessLink) if links: - link = links[0] # Remove Cable from list + link_cables = {} + fanin = False + fanout = False - # Cable - if type(link) is Cable: + # Determine if we have fanins or fanouts + if len(near_ends) > len(set(links)): + self.cursor += FANOUT_HEIGHT + fanin = True + if len(far_ends) > len(set(links)): + fanout = True + cursor = self.cursor + for link in links: + # Cable + if type(link) is Cable and not link_cables.get(link.pk): + # Reset cursor + self.cursor = cursor + # Generate a list of terminations connected to this cable + near_end_link_terminations = [term for term in terminations if term.object.cable == link] + # Draw the cable + cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) + # Add cable to the list of cables + link_cables.update({link.pk: cable}) + # Add cable to drawing + self.connectors.append(cable) - # Account for fan-ins height - if len(near_ends) > 1: - self.cursor += FANOUT_HEIGHT + # Draw fan-ins + if len(near_ends) > 1 and fanin: + for term in terminations: + if term.object.cable == link: + self.draw_fanin(term, cable) - cable = self.draw_cable(link) - self.connectors.append(cable) - - # Draw fan-ins - if len(near_ends) > 1: - for term in terminations: - self.draw_fanin(term, cable) - - # WirelessLink - elif type(link) is WirelessLink: - wirelesslink = self.draw_wirelesslink(link) - self.connectors.append(wirelesslink) + # WirelessLink + elif type(link) is WirelessLink: + wirelesslink = self.draw_wirelesslink(link) + self.connectors.append(wirelesslink) # Far end termination(s) if len(far_ends) > 1: - self.cursor += FANOUT_HEIGHT - terminations = self.draw_terminations(far_ends) - for term in terminations: - self.draw_fanout(term, cable) + if fanout: + self.cursor += FANOUT_HEIGHT + terminations = self.draw_terminations(far_ends) + for term in terminations: + if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): + self.draw_fanout(term, link_cables.get(term.object.cable.pk)) + else: + self.draw_terminations(far_ends) elif far_ends: self.draw_terminations(far_ends) else: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 34dbcbf30..624eb579b 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -64,9 +64,19 @@ def get_interface_state_attribute(record): Get interface enabled state as string to attach to DOM element. """ if record.enabled: - return "enabled" + return 'enabled' else: - return "disabled" + return 'disabled' + + +def get_interface_connected_attribute(record): + """ + Get interface disconnected state as string to attach to DOM element. + """ + if record.mark_connected or record.cable: + return 'connected' + else: + return 'disconnected' # @@ -674,6 +684,7 @@ class DeviceInterfaceTable(InterfaceTable): 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, 'data-type': lambda record: record.type, + 'data-connected': get_interface_connected_attribute } diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index d25333aed..a827939f7 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -15,6 +15,7 @@ class CablePathTestCase(TestCase): 1XX: Test direct connections between different endpoint types 2XX: Test different cable topologies 3XX: Test responses to changes in existing objects + 4XX: Test to exclude specific cable topologies """ @classmethod def setUpTestData(cls): @@ -33,12 +34,11 @@ class CablePathTestCase(TestCase): circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - def assertPathExists(self, nodes, **kwargs): + def _get_cablepath(self, nodes, **kwargs): """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. + Return a given cable path :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) :return: The matching CablePath (if any) """ @@ -48,12 +48,29 @@ class CablePathTestCase(TestCase): path.append([object_to_path_node(node) for node in step]) else: path.append([object_to_path_node(step)]) + return CablePath.objects.filter(path=path, **kwargs).first() - cablepath = CablePath.objects.filter(path=path, **kwargs).first() + def assertPathExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the + first matching CablePath, if found. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath + def assertPathDoesNotExist(self, nodes, **kwargs): + """ + Assert that a specific CablePath does *not* exist. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNone(cablepath, msg='Unexpected CablePath found') + def assertPathIsSet(self, origin, cablepath, msg=None): """ Assert that a specific CablePath instance is set as the path on the origin. @@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface4, path4) + def test_219_interface_to_interface_duplex_via_multiple_rearports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [FP3] [RP3] --C4-- [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3] + ) + cable1.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3] + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3) + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + + def test_221_non_symmetric_paths(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + frontport5 = FrontPort.objects.create( + device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 + ) + frontport6 = FrontPort.objects.create( + device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + cable6 = Cable( + a_terminations=[frontport4], + b_terminations=[frontport5], + label='C6' + ) + cable6.save() + cable7 = Cable( + a_terminations=[rearport5], + b_terminations=[rearport6], + label='C7' + ) + cable7.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1], + label='C1' + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3], + label='C5' + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport6], + b_terminations=[interface2], + label='C3' + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7), + (rearport1, rearport5), (frontport1, frontport5), (cable1, cable6) + ), + is_complete=False, + is_split=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase): is_complete=True, is_active=True ) + + def test_401_exclude_midspan_devices(self): + """ + [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2] + [FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] / + """ + device = Device.objects.create( + site=self.site, + device_type=self.device.device_type, + device_role=self.device.device_role, + name='Test mid-span Device' + ) + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3], + label='C1' + ) + with self.assertRaises(AssertionError): + cable1.save() + + self.assertPathDoesNotExist( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4) + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2], + label='C3' + ) + + with self.assertRaises(AssertionError): + cable3.save() + + self.assertPathDoesNotExist( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport2), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertPathDoesNotExist( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 0) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e007db43d..c1fad99ee 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer required=False ) data_file = NestedDataFileSerializer( - read_only=True + required=False ) class Meta: diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 06797891e..f518275e0 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet): data = [ {'id': c[0], 'display': c[1]} for c in page ] - return self.get_paginated_response(data) + else: + data = [] + + return self.get_paginated_response(data) # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 1061bf871..0572a33a1 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet): (ACTION_UPDATE, _('Update'), 'blue'), (ACTION_DELETE, _('Delete'), 'red'), ) + + +# +# Dashboard widgets +# + +class DashboardWidgetColorChoices(ChoiceSet): + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + BLACK = 'black' + WHITE = 'white' + + CHOICES = ( + (BLUE, _('Blue')), + (INDIGO, _('Indigo')), + (PURPLE, _('Purple')), + (PINK, _('Pink')), + (RED, _('Red')), + (ORANGE, _('Orange')), + (YELLOW, _('Yellow')), + (GREEN, _('Green')), + (TEAL, _('Teal')), + (CYAN, _('Cyan')), + (GRAY, _('Gray')), + (BLACK, _('Black')), + (WHITE, _('White')), + ) diff --git a/netbox/extras/dashboard/forms.py b/netbox/extras/dashboard/forms.py index 1e9f15408..ab708228c 100644 --- a/netbox/extras/dashboard/forms.py +++ b/netbox/extras/dashboard/forms.py @@ -2,9 +2,9 @@ from django import forms from django.urls import reverse_lazy from django.utils.translation import gettext as _ +from extras.choices import DashboardWidgetColorChoices from netbox.registry import registry from utilities.forms import BootstrapMixin, add_blank_choice -from utilities.choices import ButtonColorChoices __all__ = ( 'DashboardWidgetAddForm', @@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form): required=False ) color = forms.ChoiceField( - choices=add_blank_choice(ButtonColorChoices), + choices=add_blank_choice(DashboardWidgetColorChoices), required=False, ) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 6af81a9d9..9b12065ca 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -106,8 +106,6 @@ class Report(object): 'failure': 0, 'log': [], } - if not test_methods: - raise Exception("A report must contain at least one test method.") self.test_methods = test_methods @classproperty @@ -137,6 +135,13 @@ class Report(object): def source(self): return inspect.getsource(self.__class__) + @property + def is_valid(self): + """ + Indicates whether the report can be run. + """ + return bool(self.test_methods) + # # Logging methods # diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 548d01afa..f0a8286fc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,7 +1,8 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ( - CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, + CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, ) from utilities.forms.widgets import BulkEditNullBooleanSelect +from virtualization.models import Cluster, ClusterGroup __all__ = ( 'AggregateBulkEditForm', @@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False - ) min_vid = forms.IntegerField( min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, @@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + scope_type = ContentTypeChoiceField( + label=_('Scope type'), + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False + ) + scope_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput() + ) + region = DynamicModelChoiceField( + label=_('Region'), + queryset=Region.objects.all(), + required=False + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site = DynamicModelChoiceField( + label=_('Site'), + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$sitegroup', + } + ) + location = DynamicModelChoiceField( + label=_('Location'), + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + label=_('Rack'), + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + clustergroup = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + label=_('Cluster group') + ) + cluster = DynamicModelChoiceField( + label=_('Cluster'), + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$clustergroup', + } + ) model = VLANGroup fieldsets = ( (None, ('site', 'min_vid', 'max_vid', 'description')), + (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) - nullable_fields = ('site', 'description') + nullable_fields = ('description',) + + def clean(self): + super().clean() + + # Assign scope based on scope_type + if self.cleaned_data.get('scope_type'): + scope_field = self.cleaned_data['scope_type'].model + if scope_obj := self.cleaned_data.get(scope_field): + self.cleaned_data['scope_id'] = scope_obj.pk + self.changed_data.append('scope_id') + else: + self.cleaned_data.pop('scope_type') + self.changed_data.remove('scope_type') class VLANBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index e965bf7b1..bfd4f952d 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -354,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: raise ValidationError( _("Cannot reassign IP address while it is designated as the primary IP for the parent object") ) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 00c08b3bc..d176d3bff 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -782,6 +782,13 @@ class IPAddress(PrimaryModel): def __str__(self): return str(self.address) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Denote the original assigned object (if any) for validation in clean() + self._original_assigned_object_id = self.__dict__.get('assigned_object_id') + self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') + def get_absolute_url(self): return reverse('ipam:ipaddress', args=[self.pk]) @@ -843,6 +850,26 @@ class IPAddress(PrimaryModel): ) }) + if self._original_assigned_object_id and self._original_assigned_object_type_id: + parent = getattr(self.assigned_object, 'parent_object', None) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) + original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) + original_parent = getattr(original_assigned_object, 'parent_object', None) + + # can't use is_primary_ip as self.assigned_object might be changed + is_primary = False + if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk: + is_primary = True + if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk: + is_primary = True + + if is_primary and (parent != original_parent): + raise ValidationError({ + 'assigned_object': _( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + }) + # Validate IP status selection if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: raise ValidationError({ diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 24d219ca0..d696c8dae 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): ) IPAddress.objects.bulk_create(ip_addresses) + def test_assign_object(self): + """ + Test the creation of available IP addresses within a parent IP range. + """ + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + role = DeviceRole.objects.create(name='Switch') + device1 = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset') + interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset') + device2 = Device.objects.create( + name='Device 2', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset') + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1), + IPAddress(address=IPNetwork('192.168.1.4/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + + ip1 = ip_addresses[0] + ip1.assigned_object = interface1 + device1.primary_ip4 = ip_addresses[0] + device1.save() + + ip2 = ip_addresses[1] + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) + self.add_permissions('ipam.change_ipaddress') + + # assign to same parent + data = { + 'assigned_object_id': interface2.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # assign to same different parent - should error + data = { + 'assigned_object_id': interface3.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class FHRPGroupTest(APIViewTestCases.APIViewTestCase): model = FHRPGroup diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 347ed55bd..d6e43ea75 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -46,12 +46,13 @@ class ChoiceField(serializers.Field): return super().validate_empty_values(data) def to_representation(self, obj): - if obj == '': - return None - return { - 'value': obj, - 'label': self._choices[obj], - } + if obj != '': + # Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously + # configured choice has been removed from FIELD_CHOICES). + return { + 'value': obj, + 'label': self._choices.get(obj, ''), + } def to_internal_value(self, data): if data == '': diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3977201e9..e483488fc 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.2' +VERSION = '3.6.3' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index bef524bce..676e3f5af 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,6 +3,7 @@ import re from copy import deepcopy from django.contrib import messages +from django.contrib.contenttypes.fields import GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError @@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): model_field = self.queryset.model._meta.get_field(name) if isinstance(model_field, (ManyToManyField, ManyToManyRel)): m2m_fields[name] = model_field + elif isinstance(model_field, GenericRel): + # Ignore generic relations (these may be used for other purposes in the form) + continue 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 diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 2d7142bc6..84d1600e3 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index ffdd83285..9048a3286 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index b492e4d1d..7a3cd7859 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 84bfecae3..7d16f9916 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 7f2400ed2..1797f57ce 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/scopeSelector.ts b/netbox/project-static/src/forms/scopeSelector.ts index 14ef972f8..f7b77f041 100644 --- a/netbox/project-static/src/forms/scopeSelector.ts +++ b/netbox/project-static/src/forms/scopeSelector.ts @@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = { const showHideMap: ShowHideMap = { vlangroup_add: 'vlangroup', vlangroup_edit: 'vlangroup', + vlangroup_bulk_edit: 'vlangroup', }; /** diff --git a/netbox/project-static/src/tables/interfaceTable.ts b/netbox/project-static/src/tables/interfaceTable.ts index 56a0ae754..70243cf41 100644 --- a/netbox/project-static/src/tables/interfaceTable.ts +++ b/netbox/project-static/src/tables/interfaceTable.ts @@ -141,9 +141,10 @@ class TableState { private virtualButton: ButtonState; /** - * Underlying DOM Table Caption Element. + * Instance of ButtonState for the 'show/hide virtual rows' button. */ - private caption: Nullable = null; + // @ts-expect-error null handling is performed in the constructor + private disconnectedButton: ButtonState; /** * All table rows in table @@ -166,9 +167,10 @@ class TableState { this.table, 'button.toggle-virtual', ); - - const caption = this.table.querySelector('caption'); - this.caption = caption; + const toggleDisconnectedButton = findFirstAdjacent( + this.table, + 'button.toggle-disconnected', + ); if (toggleEnabledButton === null) { throw new TableStateError("Table is missing a 'toggle-enabled' button.", table); @@ -182,10 +184,15 @@ class TableState { throw new TableStateError("Table is missing a 'toggle-virtual' button.", table); } + if (toggleDisconnectedButton === null) { + throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table); + } + // Attach event listeners to the buttons elements. toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this)); + toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this)); // Instantiate ButtonState for each button for state management. this.enabledButton = new ButtonState( @@ -200,6 +207,10 @@ class TableState { toggleVirtualButton, table.querySelectorAll('tr[data-type="virtual"]'), ); + this.disconnectedButton = new ButtonState( + toggleDisconnectedButton, + table.querySelectorAll('tr[data-connected="disconnected"]'), + ); } catch (err) { if (err instanceof TableStateError) { // This class is useless for tables that don't have toggle buttons. @@ -211,52 +222,6 @@ class TableState { } } - /** - * Get the table caption's text. - */ - private get captionText(): string { - if (this.caption !== null) { - return this.caption.innerText; - } - return ''; - } - - /** - * Set the table caption's text. - */ - private set captionText(value: string) { - if (this.caption !== null) { - this.caption.innerText = value; - } - } - - /** - * Update the table caption's text based on the state of each toggle button. - */ - private toggleCaption(): void { - const showEnabled = this.enabledButton.buttonState === 'show'; - const showDisabled = this.disabledButton.buttonState === 'show'; - const showVirtual = this.virtualButton.buttonState === 'show'; - - if (showEnabled && !showDisabled && !showVirtual) { - this.captionText = 'Showing Enabled Interfaces'; - } else if (showEnabled && showDisabled && !showVirtual) { - this.captionText = 'Showing Enabled & Disabled Interfaces'; - } else if (!showEnabled && showDisabled && !showVirtual) { - this.captionText = 'Showing Disabled Interfaces'; - } else if (!showEnabled && !showDisabled && !showVirtual) { - this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces'; - } else if (!showEnabled && !showDisabled && showVirtual) { - this.captionText = 'Showing Virtual Interfaces'; - } else if (showEnabled && !showDisabled && showVirtual) { - this.captionText = 'Showing Enabled & Virtual Interfaces'; - } else if (showEnabled && showDisabled && showVirtual) { - this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces'; - } else { - this.captionText = ''; - } - } - /** * When toggle buttons are clicked, reapply visability all rows and * pass the event to all button handlers @@ -272,7 +237,7 @@ class TableState { instance.enabledButton.handleClick(event); instance.disabledButton.handleClick(event); instance.virtualButton.handleClick(event); - instance.toggleCaption(); + instance.disconnectedButton.handleClick(event); } } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 94fddc32c..a38633b5c 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -167,6 +167,12 @@ table td > .progress { } } +.alert { + code { + color: $gray-600; + } +} + span.profile-button .dropdown-menu { right: 0; left: auto; diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index 2d04b44e3..4bbe5cea5 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/netbox/project-static/styles/theme-dark.scss @@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%); $btn-close-bg: url("data:image/svg+xml,"); // Code -$code-color: $gray-600; +$code-color: $gray-200; $kbd-color: $white; $kbd-bg: $gray-300; $pre-color: null; diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 676f8a3e5..b004634bb 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -23,7 +23,15 @@
- {% if path.is_split %} + {% if path.is_split and path.get_asymmetric_nodes %} +

{% trans "Asymmetric Path" %}!

+

{% trans "The nodes below have no links and result in an asymmetric path" %}:

+
    + {% for next_node in path.get_asymmetric_nodes %} +
  • {{ next_node|linkify }}
  • + {% endfor %} +
+ {% elif path.is_split %}

{% trans "Path split" %}!

{% trans "Select a node below to continue" %}:

    diff --git a/netbox/templates/dcim/device/inc/interface_table_controls.html b/netbox/templates/dcim/device/inc/interface_table_controls.html index 36605cd25..7868d99db 100644 --- a/netbox/templates/dcim/device/inc/interface_table_controls.html +++ b/netbox/templates/dcim/device/inc/interface_table_controls.html @@ -9,5 +9,6 @@ +
{% endblock extra_table_controls %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 07d6fcfd5..806279d20 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -8,11 +8,17 @@ {% if perms.extras.run_report %}
+ {% if not report.is_valid %} +
+ + {% trans "This report is invalid and cannot be run." %} +
+ {% endif %}
{% csrf_token %} {% render_form form %}
-