فهرست منبع

Merge pull request #13907 from netbox-community/develop

Release v3.6.3
Jeremy Stretch 2 سال پیش
والد
کامیت
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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.6.2
+      placeholder: v3.6.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.6.2
+      placeholder: v3.6.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 23 - 0
docs/release-notes/version-3.6.md

@@ -1,5 +1,28 @@
 # NetBox v3.6
 # 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)
 ## v3.6.2 (2023-09-20)
 
 
 ### Enhancements
 ### Enhancements

+ 2 - 2
netbox/dcim/forms/bulk_import.py

@@ -549,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
             params = {
             params = {
                 f"site__{self.fields['site'].to_field_name}": data.get('site'),
                 f"site__{self.fields['site'].to_field_name}": data.get('site'),
             }
             }
-            if 'location' in data:
+            if location := data.get('location'):
                 params.update({
                 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)
             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.querysets import RestrictedQuerySet
 from utilities.utils import to_meters
 from utilities.utils import to_meters
 from wireless.models import WirelessLink
 from wireless.models import WirelessLink
-from .device_components import FrontPort, RearPort
+from .device_components import FrontPort, RearPort, PathEndpoint
 
 
 __all__ = (
 __all__ = (
     'Cable',
     'Cable',
@@ -518,9 +518,16 @@ class CablePath(models.Model):
             # Terminations must all be of the same type
             # Terminations must all be of the same type
             assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
             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
             # Check for a split path (e.g. rear port fanning out to multiple front ports with
             # different cables attached)
             # 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
                 is_split = True
                 break
                 break
 
 
@@ -529,46 +536,68 @@ class CablePath(models.Model):
                 object_to_path_node(t) for t in terminations
                 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
                 # Otherwise, halt the trace if no link exists
                 break
                 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
                 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])
                 termination_type = ContentType.objects.get_for_model(terminations[0])
                 local_cable_terminations = CableTermination.objects.filter(
                 local_cable_terminations = CableTermination.objects.filter(
                     termination_type=termination_type,
                     termination_type=termination_type,
                     termination_id__in=[t.pk for t in terminations]
                     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]
                 remote_terminations = [ct.termination for ct in remote_cable_terminations]
             else:
             else:
                 # WirelessLink
                 # 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([
             path.append([
                 object_to_path_node(t) for t in remote_terminations if t is not None
                 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:
             if not remote_terminations:
                 break
                 break
 
 
@@ -577,20 +606,32 @@ class CablePath(models.Model):
                 rear_ports = RearPort.objects.filter(
                 rear_ports = RearPort.objects.filter(
                     pk__in=[t.rear_port_id for t in remote_terminations]
                     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])
                     position_stack.append([fp.rear_port_position for fp in remote_terminations])
 
 
                 terminations = rear_ports
                 terminations = rear_ports
 
 
             elif isinstance(remote_terminations[0], RearPort):
             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(
                     front_ports = FrontPort.objects.filter(
                         rear_port_id__in=[rp.pk for rp in remote_terminations],
                         rear_port_id__in=[rp.pk for rp in remote_terminations],
                         rear_port_position=1
                         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:
                 elif position_stack:
                     front_ports = FrontPort.objects.filter(
                     front_ports = FrontPort.objects.filter(
                         rear_port_id=remote_terminations[0].pk,
                         rear_port_id=remote_terminations[0].pk,
@@ -632,9 +673,16 @@ class CablePath(models.Model):
 
 
                 terminations = [circuit_termination]
                 terminations = [circuit_termination]
 
 
-            # Anything else marks the end of the path
             else:
             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
                 break
 
 
         return cls(
         return cls(
@@ -740,3 +788,15 @@ class CablePath(models.Model):
             return [
             return [
                 ct.get_peer_termination() for ct in nodes
                 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 functools import cached_property
 
 
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
+from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import F, ProtectedError
 from django.db.models import F, ProtectedError
@@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         ret = super().save(*args, **kwargs)
         ret = super().save(*args, **kwargs)
 
 
         # Delete any previously uploaded image files that are no longer in use
         # 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
         return ret
 
 

+ 109 - 38
netbox/dcim/svg/cables.py

@@ -32,11 +32,18 @@ class Node(Hyperlink):
         color: Box fill color (RRGGBB format)
         color: Box fill color (RRGGBB format)
         labels: An iterable of text strings. Each label will render on a new line within the box.
         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)
         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)
         super(Node, self).__init__(href=url, target='_parent', **extra)
 
 
+        # Save object for reference by cable systems
+        self.object = object
+
         x, y = position
         x, y = position
 
 
         # Add the box
         # Add the box
@@ -77,7 +84,7 @@ class Connector(Group):
         labels: Iterable of text labels
         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)
         super().__init__(class_='connector', **extra)
 
 
         self.start = start
         self.start = start
@@ -104,6 +111,8 @@ class Connector(Group):
             text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
             text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
             text = Text(label, insert=text_coords, class_='bold' if not i else [])
             text = Text(label, insert=text_coords, class_='bold' if not i else [])
             link.add(text)
             link.add(text)
+        if len(description) > 0:
+            link.set_desc("\n".join(description))
 
 
         self.add(link)
         self.add(link)
 
 
@@ -206,7 +215,8 @@ class CableTraceSVG:
                 url=f'{self.base_url}{term.get_absolute_url()}',
                 url=f'{self.base_url}{term.get_absolute_url()}',
                 color=self._get_color(term),
                 color=self._get_color(term),
                 labels=self._get_labels(term),
                 labels=self._get_labels(term),
-                radius=5
+                radius=5,
+                object=term
             )
             )
             nodes_height = max(nodes_height, node.box['height'])
             nodes_height = max(nodes_height, node.box['height'])
             nodes.append(node)
             nodes.append(node)
@@ -238,22 +248,65 @@ class CableTraceSVG:
             Polyline(points=points, style=f'stroke: #{connector.color}'),
             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(
         connector = Connector(
-            start=(self.center + OFFSET, self.cursor),
+            start=(center, self.cursor),
             color=cable.color or '000000',
             color=cable.color or '000000',
             url=f'{self.base_url}{cable.get_absolute_url()}',
             url=f'{self.base_url}{cable.get_absolute_url()}',
-            labels=labels
+            labels=labels,
+            description=description
         )
         )
 
 
+        # Set the cursor position
         self.cursor += connector.height
         self.cursor += connector.height
 
 
         return connector
         return connector
@@ -334,34 +387,52 @@ class CableTraceSVG:
 
 
             # Connector (a Cable or WirelessLink)
             # Connector (a Cable or WirelessLink)
             if links:
             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)
                 # Far end termination(s)
                 if len(far_ends) > 1:
                 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:
                 elif far_ends:
                     self.draw_terminations(far_ends)
                     self.draw_terminations(far_ends)
                 else:
                 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.
     Get interface enabled state as string to attach to <tr/> DOM element.
     """
     """
     if record.enabled:
     if record.enabled:
-        return "enabled"
+        return 'enabled'
     else:
     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-name': lambda record: record.name,
             'data-enabled': get_interface_state_attribute,
             'data-enabled': get_interface_state_attribute,
             'data-type': lambda record: record.type,
             '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
         1XX: Test direct connections between different endpoint types
         2XX: Test different cable topologies
         2XX: Test different cable topologies
         3XX: Test responses to changes in existing objects
         3XX: Test responses to changes in existing objects
+        4XX: Test to exclude specific cable topologies
     """
     """
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
         circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
         circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
         cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
         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 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)
         :return: The matching CablePath (if any)
         """
         """
@@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
                 path.append([object_to_path_node(node) for node in step])
                 path.append([object_to_path_node(node) for node in step])
             else:
             else:
                 path.append([object_to_path_node(step)])
                 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')
         self.assertIsNotNone(cablepath, msg='CablePath not found')
 
 
         return cablepath
         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):
     def assertPathIsSet(self, origin, cablepath, msg=None):
         """
         """
         Assert that a specific CablePath instance is set as the path on the origin.
         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(interface3, path3)
         self.assertPathIsSet(interface4, path4)
         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):
     def test_301_create_path_via_existing_cable(self):
         """
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
             is_complete=True,
             is_complete=True,
             is_active=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
         required=False
     )
     )
     data_file = NestedDataFileSerializer(
     data_file = NestedDataFileSerializer(
-        read_only=True
+        required=False
     )
     )
 
 
     class Meta:
     class Meta:

+ 4 - 1
netbox/extras/api/views.py

@@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
             data = [
             data = [
                 {'id': c[0], 'display': c[1]} for c in page
                 {'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_UPDATE, _('Update'), 'blue'),
         (ACTION_DELETE, _('Delete'), 'red'),
         (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.urls import reverse_lazy
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from extras.choices import DashboardWidgetColorChoices
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.forms import BootstrapMixin, add_blank_choice
 from utilities.forms import BootstrapMixin, add_blank_choice
-from utilities.choices import ButtonColorChoices
 
 
 __all__ = (
 __all__ = (
     'DashboardWidgetAddForm',
     'DashboardWidgetAddForm',
@@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
         required=False
         required=False
     )
     )
     color = forms.ChoiceField(
     color = forms.ChoiceField(
-        choices=add_blank_choice(ButtonColorChoices),
+        choices=add_blank_choice(DashboardWidgetColorChoices),
         required=False,
         required=False,
     )
     )
 
 

+ 7 - 2
netbox/extras/reports.py

@@ -106,8 +106,6 @@ class Report(object):
                     'failure': 0,
                     'failure': 0,
                     'log': [],
                     'log': [],
                 }
                 }
-        if not test_methods:
-            raise Exception("A report must contain at least one test method.")
         self.test_methods = test_methods
         self.test_methods = test_methods
 
 
     @classproperty
     @classproperty
@@ -137,6 +135,13 @@ class Report(object):
     def source(self):
     def source(self):
         return inspect.getsource(self.__class__)
         return inspect.getsource(self.__class__)
 
 
+    @property
+    def is_valid(self):
+        """
+        Indicates whether the report can be run.
+        """
+        return bool(self.test_methods)
+
     #
     #
     # Logging methods
     # Logging methods
     #
     #

+ 77 - 8
netbox/ipam/forms/bulk_edit.py

@@ -1,7 +1,8 @@
 from django import forms
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 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.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.models import *
 from ipam.models import *
@@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import (
 from utilities.forms.fields import (
-    CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
+    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
 )
 )
 from utilities.forms.widgets import BulkEditNullBooleanSelect
 from utilities.forms.widgets import BulkEditNullBooleanSelect
+from virtualization.models import Cluster, ClusterGroup
 
 
 __all__ = (
 __all__ = (
     'AggregateBulkEditForm',
     'AggregateBulkEditForm',
@@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
 class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        required=False
-    )
     min_vid = forms.IntegerField(
     min_vid = forms.IntegerField(
         min_value=VLAN_VID_MIN,
         min_value=VLAN_VID_MIN,
         max_value=VLAN_VID_MAX,
         max_value=VLAN_VID_MAX,
@@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         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
     model = VLANGroup
     fieldsets = (
     fieldsets = (
         (None, ('site', 'min_vid', 'max_vid', 'description')),
         (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):
 class VLANBulkEditForm(NetBoxModelBulkEditForm):

+ 1 - 1
netbox/ipam/forms/model_forms.py

@@ -354,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
             })
             })
         elif selected_objects:
         elif selected_objects:
             assigned_object = self.cleaned_data[selected_objects[0]]
             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(
                 raise ValidationError(
                     _("Cannot reassign IP address while it is designated as the primary IP for the parent object")
                     _("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):
     def __str__(self):
         return str(self.address)
         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):
     def get_absolute_url(self):
         return reverse('ipam:ipaddress', args=[self.pk])
         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
         # Validate IP status selection
         if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
         if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
             raise ValidationError({
             raise ValidationError({

+ 56 - 0
netbox/ipam/tests/test_api.py

@@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
         )
         )
         IPAddress.objects.bulk_create(ip_addresses)
         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):
 class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
     model = FHRPGroup
     model = FHRPGroup

+ 7 - 6
netbox/netbox/api/fields.py

@@ -46,12 +46,13 @@ class ChoiceField(serializers.Field):
         return super().validate_empty_values(data)
         return super().validate_empty_values(data)
 
 
     def to_representation(self, obj):
     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):
     def to_internal_value(self, data):
         if 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
 # Environment setup
 #
 #
 
 
-VERSION = '3.6.2'
+VERSION = '3.6.3'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 4 - 1
netbox/netbox/views/generic/bulk_views.py

@@ -3,6 +3,7 @@ import re
 from copy import deepcopy
 from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
+from django.contrib.contenttypes.fields import GenericRel
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
@@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                 model_field = self.queryset.model._meta.get_field(name)
                 model_field = self.queryset.model._meta.get_field(name)
                 if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
                 if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
                     m2m_fields[name] = model_field
                     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:
                 else:
                     model_fields[name] = model_field
                     model_fields[name] = model_field
-
             except FieldDoesNotExist:
             except FieldDoesNotExist:
                 # This form field is used to modify a field rather than set its value directly
                 # This form field is used to modify a field rather than set its value directly
                 model_fields[name] = None
                 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 = {
 const showHideMap: ShowHideMap = {
   vlangroup_add: 'vlangroup',
   vlangroup_add: 'vlangroup',
   vlangroup_edit: '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;
   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
    * All table rows in table
@@ -166,9 +167,10 @@ class TableState {
         this.table,
         this.table,
         'button.toggle-virtual',
         'button.toggle-virtual',
       );
       );
-
-      const caption = this.table.querySelector('caption');
-      this.caption = caption;
+      const toggleDisconnectedButton = findFirstAdjacent<HTMLButtonElement>(
+        this.table,
+        'button.toggle-disconnected',
+      );
 
 
       if (toggleEnabledButton === null) {
       if (toggleEnabledButton === null) {
         throw new TableStateError("Table is missing a 'toggle-enabled' button.", table);
         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);
         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.
       // Attach event listeners to the buttons elements.
       toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
       toggleVirtualButton.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.
       // Instantiate ButtonState for each button for state management.
       this.enabledButton = new ButtonState(
       this.enabledButton = new ButtonState(
@@ -200,6 +207,10 @@ class TableState {
         toggleVirtualButton,
         toggleVirtualButton,
         table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
         table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
       );
       );
+      this.disconnectedButton = new ButtonState(
+        toggleDisconnectedButton,
+        table.querySelectorAll<HTMLTableRowElement>('tr[data-connected="disconnected"]'),
+      );
     } catch (err) {
     } catch (err) {
       if (err instanceof TableStateError) {
       if (err instanceof TableStateError) {
         // This class is useless for tables that don't have toggle buttons.
         // 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
    * When toggle buttons are clicked, reapply visability all rows and
    * pass the event to all button handlers
    * pass the event to all button handlers
@@ -272,7 +237,7 @@ class TableState {
     instance.enabledButton.handleClick(event);
     instance.enabledButton.handleClick(event);
     instance.disabledButton.handleClick(event);
     instance.disabledButton.handleClick(event);
     instance.virtualButton.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 {
 span.profile-button .dropdown-menu {
   right: 0;
   right: 0;
   left: auto;
   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>");
 $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
-$code-color: $gray-600;
+$code-color: $gray-200;
 $kbd-color: $white;
 $kbd-color: $white;
 $kbd-bg: $gray-300;
 $kbd-bg: $gray-300;
 $pre-color: null;
 $pre-color: null;

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

@@ -23,7 +23,15 @@
               </div>
               </div>
             </div>
             </div>
             <div class="trace-end">
             <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>
                     <h3 class="text-danger">{% trans "Path split" %}!</h3>
                     <p>{% trans "Select a node below to continue" %}:</p>
                     <p>{% trans "Select a node below to continue" %}:</p>
                     <ul class="text-start">
                     <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-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-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-virtual" data-state="show">{% trans "Hide Virtual" %}</button>
+    <button type="button" class="dropdown-item toggle-disconnected" data-state="show">{% trans "Hide Disconnected" %}</button>
   </ul>
   </ul>
 {% endblock extra_table_controls %}
 {% endblock extra_table_controls %}

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

@@ -8,11 +8,17 @@
     {% if perms.extras.run_report %}
     {% if perms.extras.run_report %}
       <div class="row">
       <div class="row">
         <div class="col">
         <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">
           <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit">
             {% csrf_token %}
             {% csrf_token %}
             {% render_form form %}
             {% render_form form %}
             <div class="float-end">
             <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 %}
                 {% if report.result %}
                   <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
                   <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
                 {% else %}
                 {% else %}

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

@@ -68,10 +68,18 @@
                           </td>
                           </td>
                         {% else %}
                         {% else %}
                           <td class="text-muted">{% trans "Never" %}</td>
                           <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 %}
                         {% endif %}
                         <td>
                         <td>
-                          {% if perms.extras.run_report %}
+                          {% if perms.extras.run_report and report.is_valid %}
                             <div class="float-end noprint">
                             <div class="float-end noprint">
                               <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
                               <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
                                 {% csrf_token %}
                                 {% csrf_token %}

+ 2 - 2
requirements.txt

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است