Skip to content

Commit f65744f

Browse files
Fixes: #11079 - Handle cables across multiple rear-port positions (#13337)
* Catch AssertionError's in signals. Handle accordingly * Alter cable logic to handle certain additional path types. * Fix failures and add test * More tests * Remove not needed tests, add additional tests * Finish tests, correct some behaviour * Add check for mid-span device not allowed condition * Remove excess import * Remove logging import * Remove logging import * Minor tweaks based on Arthur's feedback * Update netbox/dcim/tests/test_cablepaths.py Co-authored-by: Jeremy Stretch <[email protected]> * Update netbox/dcim/models/cables.py Co-authored-by: Jeremy Stretch <[email protected]> * Changes to account for required SVG rendering changes and based on feedback * More tweaks for cable path checking * Improve handling of links with multi-terminations * Improved SVG rendering of multiple rear ports (with positions) per path trace. Include asymmetric path detection * Include missing assert to ensure links are same type. * Clean up tests * Remove unused objects from tests * Changes requested to tests and update comments/doctstrings * Fix parent reference --------- Co-authored-by: Jeremy Stretch <[email protected]>
1 parent 1ad6d94 commit f65744f

File tree

4 files changed

+605
-74
lines changed

4 files changed

+605
-74
lines changed

netbox/dcim/models/cables.py

Lines changed: 91 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from utilities.querysets import RestrictedQuerySet
2121
from utilities.utils import to_meters
2222
from wireless.models import WirelessLink
23-
from .device_components import FrontPort, RearPort
23+
from .device_components import FrontPort, RearPort, PathEndpoint
2424

2525
__all__ = (
2626
'Cable',
@@ -518,9 +518,16 @@ def from_origin(cls, terminations):
518518
# Terminations must all be of the same type
519519
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
520520

521+
# All mid-span terminations must all be attached to the same device
522+
if not isinstance(terminations[0], PathEndpoint):
523+
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
524+
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
525+
521526
# Check for a split path (e.g. rear port fanning out to multiple front ports with
522527
# different cables attached)
523-
if len(set(t.link for t in terminations)) > 1:
528+
if len(set(t.link for t in terminations)) > 1 and (
529+
position_stack and len(terminations) != len(position_stack[-1])
530+
):
524531
is_split = True
525532
break
526533

@@ -529,46 +536,68 @@ def from_origin(cls, terminations):
529536
object_to_path_node(t) for t in terminations
530537
])
531538

532-
# Step 2: Determine the attached link (Cable or WirelessLink), if any
533-
link = terminations[0].link
534-
if link is None and len(path) == 1:
535-
# If this is the start of the path and no link exists, return None
536-
return None
537-
elif link is None:
539+
# Step 2: Determine the attached links (Cable or WirelessLink), if any
540+
links = [termination.link for termination in terminations if termination.link is not None]
541+
if len(links) == 0:
542+
if len(path) == 1:
543+
# If this is the start of the path and no link exists, return None
544+
return None
538545
# Otherwise, halt the trace if no link exists
539546
break
540-
assert type(link) in (Cable, WirelessLink)
547+
assert all(type(link) in (Cable, WirelessLink) for link in links)
548+
assert all(isinstance(link, type(links[0])) for link in links)
549+
550+
# Step 3: Record asymmetric paths as split
551+
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
552+
if len(not_connected_terminations) > 0:
553+
is_complete = False
554+
is_split = True
541555

542-
# Step 3: Record the link and update path status if not "connected"
543-
path.append([object_to_path_node(link)])
544-
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
556+
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
557+
cables = []
558+
for link in links:
559+
if object_to_path_node(link) not in cables:
560+
cables.append(object_to_path_node(link))
561+
path.append(cables)
562+
563+
# Step 5: Update the path status if a link is not connected
564+
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
565+
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
545566
is_active = False
546567

547-
# Step 4: Determine the far-end terminations
548-
if isinstance(link, Cable):
568+
# Step 6: Determine the far-end terminations
569+
if isinstance(links[0], Cable):
549570
termination_type = ContentType.objects.get_for_model(terminations[0])
550571
local_cable_terminations = CableTermination.objects.filter(
551572
termination_type=termination_type,
552573
termination_id__in=[t.pk for t in terminations]
553574
)
554-
# Terminations must all belong to same end of Cable
555-
local_cable_end = local_cable_terminations[0].cable_end
556-
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
557-
remote_cable_terminations = CableTermination.objects.filter(
558-
cable=link,
559-
cable_end='A' if local_cable_end == 'B' else 'B'
560-
)
575+
576+
q_filter = Q()
577+
for lct in local_cable_terminations:
578+
cable_end = 'A' if lct.cable_end == 'B' else 'B'
579+
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
580+
581+
remote_cable_terminations = CableTermination.objects.filter(q_filter)
561582
remote_terminations = [ct.termination for ct in remote_cable_terminations]
562583
else:
563584
# WirelessLink
564-
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
585+
remote_terminations = [
586+
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
587+
]
588+
589+
# Remote Terminations must all be of the same type, otherwise return a split path
590+
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
591+
is_complete = False
592+
is_split = True
593+
break
565594

566-
# Step 5: Record the far-end termination object(s)
595+
# Step 7: Record the far-end termination object(s)
567596
path.append([
568597
object_to_path_node(t) for t in remote_terminations if t is not None
569598
])
570599

571-
# Step 6: Determine the "next hop" terminations, if applicable
600+
# Step 8: Determine the "next hop" terminations, if applicable
572601
if not remote_terminations:
573602
break
574603

@@ -577,20 +606,32 @@ def from_origin(cls, terminations):
577606
rear_ports = RearPort.objects.filter(
578607
pk__in=[t.rear_port_id for t in remote_terminations]
579608
)
580-
if len(rear_ports) > 1:
581-
assert all(rp.positions == 1 for rp in rear_ports)
582-
elif rear_ports[0].positions > 1:
609+
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
583610
position_stack.append([fp.rear_port_position for fp in remote_terminations])
584611

585612
terminations = rear_ports
586613

587614
elif isinstance(remote_terminations[0], RearPort):
588-
589-
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
615+
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
590616
front_ports = FrontPort.objects.filter(
591617
rear_port_id__in=[rp.pk for rp in remote_terminations],
592618
rear_port_position=1
593619
)
620+
# Obtain the individual front ports based on the termination and all positions
621+
elif len(remote_terminations) > 1 and position_stack:
622+
positions = position_stack.pop()
623+
624+
# Ensure we have a number of positions equal to the amount of remote terminations
625+
assert len(remote_terminations) == len(positions)
626+
627+
# Get our front ports
628+
q_filter = Q()
629+
for rt in remote_terminations:
630+
position = positions.pop()
631+
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
632+
assert q_filter is not Q()
633+
front_ports = FrontPort.objects.filter(q_filter)
634+
# Obtain the individual front ports based on the termination and position
594635
elif position_stack:
595636
front_ports = FrontPort.objects.filter(
596637
rear_port_id=remote_terminations[0].pk,
@@ -632,9 +673,16 @@ def from_origin(cls, terminations):
632673

633674
terminations = [circuit_termination]
634675

635-
# Anything else marks the end of the path
636676
else:
637-
is_complete = True
677+
# Check for non-symmetric path
678+
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
679+
is_complete = True
680+
elif len(remote_terminations) == 0:
681+
is_complete = False
682+
else:
683+
# Unsupported topology, mark as split and exit
684+
is_complete = False
685+
is_split = True
638686
break
639687

640688
return cls(
@@ -740,3 +788,15 @@ def get_split_nodes(self):
740788
return [
741789
ct.get_peer_termination() for ct in nodes
742790
]
791+
792+
def get_asymmetric_nodes(self):
793+
"""
794+
Return all available next segments in a split cable path.
795+
"""
796+
from circuits.models import CircuitTermination
797+
asymmetric_nodes = []
798+
for nodes in self.path_objects:
799+
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
800+
asymmetric_nodes.extend([node for node in nodes if node.link is None])
801+
802+
return asymmetric_nodes

netbox/dcim/svg/cables.py

Lines changed: 109 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,18 @@ class Node(Hyperlink):
3232
color: Box fill color (RRGGBB format)
3333
labels: An iterable of text strings. Each label will render on a new line within the box.
3434
radius: Box corner radius, for rounded corners (default: 10)
35+
object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
36+
which terminations.
3537
"""
3638

37-
def __init__(self, position, width, url, color, labels, radius=10, **extra):
39+
object = None
40+
41+
def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
3842
super(Node, self).__init__(href=url, target='_parent', **extra)
3943

44+
# Save object for reference by cable systems
45+
self.object = object
46+
4047
x, y = position
4148

4249
# Add the box
@@ -77,7 +84,7 @@ class Connector(Group):
7784
labels: Iterable of text labels
7885
"""
7986

80-
def __init__(self, start, url, color, labels=[], **extra):
87+
def __init__(self, start, url, color, labels=[], description=[], **extra):
8188
super().__init__(class_='connector', **extra)
8289

8390
self.start = start
@@ -104,6 +111,8 @@ def __init__(self, start, url, color, labels=[], **extra):
104111
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
105112
text = Text(label, insert=text_coords, class_='bold' if not i else [])
106113
link.add(text)
114+
if len(description) > 0:
115+
link.set_desc("\n".join(description))
107116

108117
self.add(link)
109118

@@ -206,7 +215,8 @@ def draw_terminations(self, terminations):
206215
url=f'{self.base_url}{term.get_absolute_url()}',
207216
color=self._get_color(term),
208217
labels=self._get_labels(term),
209-
radius=5
218+
radius=5,
219+
object=term
210220
)
211221
nodes_height = max(nodes_height, node.box['height'])
212222
nodes.append(node)
@@ -238,22 +248,65 @@ def draw_fanout(self, node, connector):
238248
Polyline(points=points, style=f'stroke: #{connector.color}'),
239249
))
240250

241-
def draw_cable(self, cable):
242-
labels = [
243-
f'Cable {cable}',
244-
cable.get_status_display()
245-
]
246-
if cable.type:
247-
labels.append(cable.get_type_display())
248-
if cable.length and cable.length_unit:
249-
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
251+
def draw_cable(self, cable, terminations, cable_count=0):
252+
"""
253+
Draw a single cable. Terminations and cable count are passed for determining position and padding
254+
255+
:param cable: The cable to draw
256+
:param terminations: List of terminations to build positioning data off of
257+
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
258+
tooltip.
259+
"""
260+
261+
# If the cable count is higher than 2, collapse the description into a tooltip
262+
if cable_count > 2:
263+
# Use the cable __str__ function to denote the cable
264+
labels = [f'{cable}']
265+
266+
# Include the label and the status description in the tooltip
267+
description = [
268+
f'Cable {cable}',
269+
cable.get_status_display()
270+
]
271+
272+
if cable.type:
273+
# Include the cable type in the tooltip
274+
description.append(cable.get_type_display())
275+
if cable.length and cable.length_unit:
276+
# Include the cable length in the tooltip
277+
description.append(f'{cable.length} {cable.get_length_unit_display()}')
278+
else:
279+
labels = [
280+
f'Cable {cable}',
281+
cable.get_status_display()
282+
]
283+
description = []
284+
if cable.type:
285+
labels.append(cable.get_type_display())
286+
if cable.length and cable.length_unit:
287+
# Include the cable length in the tooltip
288+
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
289+
290+
# If there is only one termination, center on that termination
291+
# Otherwise average the center across the terminations
292+
if len(terminations) == 1:
293+
center = terminations[0].bottom_center[0]
294+
else:
295+
# Get a list of termination centers
296+
termination_centers = [term.bottom_center[0] for term in terminations]
297+
# Average the centers
298+
center = sum(termination_centers) / len(termination_centers)
299+
300+
# Create the connector
250301
connector = Connector(
251-
start=(self.center + OFFSET, self.cursor),
302+
start=(center, self.cursor),
252303
color=cable.color or '000000',
253304
url=f'{self.base_url}{cable.get_absolute_url()}',
254-
labels=labels
305+
labels=labels,
306+
description=description
255307
)
256308

309+
# Set the cursor position
257310
self.cursor += connector.height
258311

259312
return connector
@@ -334,34 +387,52 @@ def render(self):
334387

335388
# Connector (a Cable or WirelessLink)
336389
if links:
337-
link = links[0] # Remove Cable from list
390+
link_cables = {}
391+
fanin = False
392+
fanout = False
338393

339-
# Cable
340-
if type(link) is Cable:
341-
342-
# Account for fan-ins height
343-
if len(near_ends) > 1:
344-
self.cursor += FANOUT_HEIGHT
345-
346-
cable = self.draw_cable(link)
347-
self.connectors.append(cable)
348-
349-
# Draw fan-ins
350-
if len(near_ends) > 1:
351-
for term in terminations:
352-
self.draw_fanin(term, cable)
353-
354-
# WirelessLink
355-
elif type(link) is WirelessLink:
356-
wirelesslink = self.draw_wirelesslink(link)
357-
self.connectors.append(wirelesslink)
394+
# Determine if we have fanins or fanouts
395+
if len(near_ends) > len(set(links)):
396+
self.cursor += FANOUT_HEIGHT
397+
fanin = True
398+
if len(far_ends) > len(set(links)):
399+
fanout = True
400+
cursor = self.cursor
401+
for link in links:
402+
# Cable
403+
if type(link) is Cable and not link_cables.get(link.pk):
404+
# Reset cursor
405+
self.cursor = cursor
406+
# Generate a list of terminations connected to this cable
407+
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
408+
# Draw the cable
409+
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
410+
# Add cable to the list of cables
411+
link_cables.update({link.pk: cable})
412+
# Add cable to drawing
413+
self.connectors.append(cable)
414+
415+
# Draw fan-ins
416+
if len(near_ends) > 1 and fanin:
417+
for term in terminations:
418+
if term.object.cable == link:
419+
self.draw_fanin(term, cable)
420+
421+
# WirelessLink
422+
elif type(link) is WirelessLink:
423+
wirelesslink = self.draw_wirelesslink(link)
424+
self.connectors.append(wirelesslink)
358425

359426
# Far end termination(s)
360427
if len(far_ends) > 1:
361-
self.cursor += FANOUT_HEIGHT
362-
terminations = self.draw_terminations(far_ends)
363-
for term in terminations:
364-
self.draw_fanout(term, cable)
428+
if fanout:
429+
self.cursor += FANOUT_HEIGHT
430+
terminations = self.draw_terminations(far_ends)
431+
for term in terminations:
432+
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
433+
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
434+
else:
435+
self.draw_terminations(far_ends)
365436
elif far_ends:
366437
self.draw_terminations(far_ends)
367438
else:

0 commit comments

Comments
 (0)