Просмотр исходного кода

Merge pull request #13907 from netbox-community/develop

Release v3.6.3
Jeremy Stretch 2 лет назад
Родитель
Сommit
ccc9e89e1a
35 измененных файлов с 918 добавлено и 165 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 23 0
      docs/release-notes/version-3.6.md
  4. 2 2
      netbox/dcim/forms/bulk_import.py
  5. 91 31
      netbox/dcim/models/cables.py
  6. 5 4
      netbox/dcim/models/devices.py
  7. 109 38
      netbox/dcim/svg/cables.py
  8. 13 2
      netbox/dcim/tables/devices.py
  9. 396 4
      netbox/dcim/tests/test_cablepaths.py
  10. 1 1
      netbox/extras/api/serializers.py
  11. 4 1
      netbox/extras/api/views.py
  12. 36 0
      netbox/extras/choices.py
  13. 2 2
      netbox/extras/dashboard/forms.py
  14. 7 2
      netbox/extras/reports.py
  15. 77 8
      netbox/ipam/forms/bulk_edit.py
  16. 1 1
      netbox/ipam/forms/model_forms.py
  17. 27 0
      netbox/ipam/models/ip.py
  18. 56 0
      netbox/ipam/tests/test_api.py
  19. 7 6
      netbox/netbox/api/fields.py
  20. 1 1
      netbox/netbox/settings.py
  21. 4 1
      netbox/netbox/views/generic/bulk_views.py
  22. 0 0
      netbox/project-static/dist/netbox-dark.css
  23. 0 0
      netbox/project-static/dist/netbox-light.css
  24. 0 0
      netbox/project-static/dist/netbox-print.css
  25. 0 0
      netbox/project-static/dist/netbox.js
  26. 0 0
      netbox/project-static/dist/netbox.js.map
  27. 1 0
      netbox/project-static/src/forms/scopeSelector.ts
  28. 17 52
      netbox/project-static/src/tables/interfaceTable.ts
  29. 6 0
      netbox/project-static/styles/netbox.scss
  30. 1 1
      netbox/project-static/styles/theme-dark.scss
  31. 9 1
      netbox/templates/dcim/cable_trace.html
  32. 1 0
      netbox/templates/dcim/device/inc/interface_table_controls.html
  33. 7 1
      netbox/templates/extras/report.html
  34. 10 2
      netbox/templates/extras/report_list.html
  35. 2 2
      requirements.txt

+ 1 - 1
.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

+ 1 - 1
.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

+ 23 - 0
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

+ 2 - 2
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)
 

+ 91 - 31
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 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 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 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
+                ]
+
+            # 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 5: Record the far-end termination object(s)
+            # 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

+ 5 - 4
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
 

+ 109 - 38
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:
-
-                    # Account for fan-ins height
-                    if len(near_ends) > 1:
-                        self.cursor += FANOUT_HEIGHT
-
-                    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)
+                # 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)
+
+                        # Draw fan-ins
+                        if len(near_ends) > 1 and fanin:
+                            for term in terminations:
+                                if term.object.cable == link:
+                                    self.draw_fanin(term, cable)
+
+                    # 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:

+ 13 - 2
netbox/dcim/tables/devices.py

@@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
     Get interface enabled state as string to attach to <tr/> 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 <tr/> 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
         }
 
 

+ 396 - 4
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)

+ 1 - 1
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:

+ 4 - 1
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)
 
 
 #

+ 36 - 0
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')),
+    )

+ 2 - 2
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,
     )
 

+ 7 - 2
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
     #

+ 77 - 8
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):

+ 1 - 1
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")
                 )

+ 27 - 0
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({

+ 56 - 0
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

+ 7 - 6
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 == '':

+ 1 - 1
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()

+ 4 - 1
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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 0
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',
 };
 
 /**

+ 17 - 52
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<HTMLTableCaptionElement> = 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<HTMLButtonElement>(
+        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<HTMLTableRowElement>('tr[data-type="virtual"]'),
       );
+      this.disconnectedButton = new ButtonState(
+        toggleDisconnectedButton,
+        table.querySelectorAll<HTMLTableRowElement>('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);
   }
 }
 

+ 6 - 0
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;

+ 1 - 1
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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>");
 
 // Code
-$code-color: $gray-600;
+$code-color: $gray-200;
 $kbd-color: $white;
 $kbd-bg: $gray-300;
 $pre-color: null;

+ 9 - 1
netbox/templates/dcim/cable_trace.html

@@ -23,7 +23,15 @@
               </div>
             </div>
             <div class="trace-end">
-                {% if path.is_split %}
+                {% if path.is_split and path.get_asymmetric_nodes %}
+                    <h3 class="text-danger">{% trans "Asymmetric Path" %}!</h3>
+                    <p>{% trans "The nodes below have no links and result in an asymmetric path" %}:</p>
+                    <ul class="text-start">
+                      {% for next_node in path.get_asymmetric_nodes %}
+                        <li class="text-muted">{{ next_node|linkify }}</li>
+                      {% endfor %}
+                    </ul>
+                {% elif path.is_split %}
                     <h3 class="text-danger">{% trans "Path split" %}!</h3>
                     <p>{% trans "Select a node below to continue" %}:</p>
                     <ul class="text-start">

+ 1 - 0
netbox/templates/dcim/device/inc/interface_table_controls.html

@@ -9,5 +9,6 @@
     <button type="button" class="dropdown-item toggle-enabled" data-state="show">{% trans "Hide Enabled" %}</button>
     <button type="button" class="dropdown-item toggle-disabled" data-state="show">{% trans "Hide Disabled" %}</button>
     <button type="button" class="dropdown-item toggle-virtual" data-state="show">{% trans "Hide Virtual" %}</button>
+    <button type="button" class="dropdown-item toggle-disconnected" data-state="show">{% trans "Hide Disconnected" %}</button>
   </ul>
 {% endblock extra_table_controls %}

+ 7 - 1
netbox/templates/extras/report.html

@@ -8,11 +8,17 @@
     {% if perms.extras.run_report %}
       <div class="row">
         <div class="col">
+          {% if not report.is_valid %}
+            <div class="alert alert-warning">
+              <i class="mdi mdi-alert"></i>
+              {% trans "This report is invalid and cannot be run." %}
+            </div>
+          {% endif %}
           <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit">
             {% csrf_token %}
             {% render_form form %}
             <div class="float-end">
-              <button type="submit" name="_run" class="btn btn-primary">
+              <button type="submit" name="_run" class="btn btn-primary"{% if not report.is_valid %} disabled{% endif %}>
                 {% if report.result %}
                   <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
                 {% else %}

+ 10 - 2
netbox/templates/extras/report_list.html

@@ -68,10 +68,18 @@
                           </td>
                         {% else %}
                           <td class="text-muted">{% trans "Never" %}</td>
-                          <td>{{ ''|placeholder }}</td>
+                          <td>
+                            {% if report.is_valid %}
+                              {{ ''|placeholder }}
+                            {% else %}
+                              <span class="badge bg-danger" title="{% trans "Report has no test methods" %}">
+                                {% trans "Invalid" %}
+                              </span>
+                            {% endif %}
+                          </td>
                         {% endif %}
                         <td>
-                          {% if perms.extras.run_report %}
+                          {% if perms.extras.run_report and report.is_valid %}
                             <div class="float-end noprint">
                               <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
                                 {% csrf_token %}

+ 2 - 2
requirements.txt

@@ -21,11 +21,11 @@ graphene-django==3.0.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==9.3.2
+mkdocs-material==9.4.2
 mkdocstrings[python-legacy]==0.23.0
 netaddr==0.9.0
 Pillow==10.0.1
-psycopg[binary,pool]==3.1.10
+psycopg[binary,pool]==3.1.11
 PyYAML==6.0.1
 sentry-sdk==1.31.0
 social-auth-app-django==5.3.0

Некоторые файлы не были показаны из-за большого количества измененных файлов