Skip to content

Commit ccc9e89

Browse files
Merge pull request #13907 from netbox-community/develop
Release v3.6.3
2 parents 952be24 + 9e35cef commit ccc9e89

File tree

35 files changed

+931
-178
lines changed

35 files changed

+931
-178
lines changed

.github/ISSUE_TEMPLATE/bug_report.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ body:
1414
attributes:
1515
label: NetBox version
1616
description: What version of NetBox are you currently running?
17-
placeholder: v3.6.2
17+
placeholder: v3.6.3
1818
validations:
1919
required: true
2020
- type: dropdown

.github/ISSUE_TEMPLATE/feature_request.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ body:
1414
attributes:
1515
label: NetBox version
1616
description: What version of NetBox are you currently running?
17-
placeholder: v3.6.2
17+
placeholder: v3.6.3
1818
validations:
1919
required: true
2020
- type: dropdown

docs/release-notes/version-3.6.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# NetBox v3.6
22

3+
## v3.6.3 (2023-09-26)
4+
5+
### Enhancements
6+
7+
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
8+
9+
### Bug Fixes
10+
11+
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
12+
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
13+
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
14+
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
15+
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
16+
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
17+
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
18+
* [#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
19+
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
20+
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
21+
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
22+
* [#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
23+
24+
---
25+
326
## v3.6.2 (2023-09-20)
427

528
### Enhancements

netbox/dcim/forms/bulk_import.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,9 +549,9 @@ def __init__(self, data=None, *args, **kwargs):
549549
params = {
550550
f"site__{self.fields['site'].to_field_name}": data.get('site'),
551551
}
552-
if 'location' in data:
552+
if location := data.get('location'):
553553
params.update({
554-
f"location__{self.fields['location'].to_field_name}": data.get('location'),
554+
f"location__{self.fields['location'].to_field_name}": location,
555555
})
556556
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
557557

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/models/devices.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from functools import cached_property
55

66
from django.core.exceptions import ValidationError
7+
from django.core.files.storage import default_storage
78
from django.core.validators import MaxValueValidator, MinValueValidator
89
from django.db import models
910
from django.db.models import F, ProtectedError
@@ -332,10 +333,10 @@ def save(self, *args, **kwargs):
332333
ret = super().save(*args, **kwargs)
333334

334335
# Delete any previously uploaded image files that are no longer in use
335-
if self.front_image != self._original_front_image:
336-
self._original_front_image.delete(save=False)
337-
if self.rear_image != self._original_rear_image:
338-
self._original_rear_image.delete(save=False)
336+
if self._original_front_image and self.front_image != self._original_front_image:
337+
default_storage.delete(self._original_front_image)
338+
if self._original_rear_image and self.rear_image != self._original_rear_image:
339+
default_storage.delete(self._original_rear_image)
339340

340341
return ret
341342

0 commit comments

Comments
 (0)