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