mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-21 11:37:21 -06:00
commit
ccc9e89e1a
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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,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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||||
link = terminations[0].link
|
links = [termination.link for termination in terminations if termination.link is not None]
|
||||||
if link is None and len(path) == 1:
|
if len(links) == 0:
|
||||||
# If this is the start of the path and no link exists, return None
|
if len(path) == 1:
|
||||||
return None
|
# If this is the start of the path and no link exists, return None
|
||||||
elif link is 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 the link and update path status if not "connected"
|
# Step 3: Record asymmetric paths as split
|
||||||
path.append([object_to_path_node(link)])
|
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
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
|
is_active = False
|
||||||
|
|
||||||
# Step 4: Determine the far-end terminations
|
# Step 6: Determine the far-end terminations
|
||||||
if isinstance(link, Cable):
|
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
|
q_filter = Q()
|
||||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
for lct in local_cable_terminations:
|
||||||
remote_cable_terminations = CableTermination.objects.filter(
|
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||||
cable=link,
|
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
|
||||||
)
|
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
|
||||||
|
]
|
||||||
|
|
||||||
# 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([
|
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:
|
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
|
||||||
assert all(rp.positions == 1 for rp in rear_ports)
|
|
||||||
elif 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 and remote_terminations[0].positions == 1:
|
||||||
if len(remote_terminations) > 1 or 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
|
||||||
|
@ -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:
|
if self._original_front_image and self.front_image != self._original_front_image:
|
||||||
self._original_front_image.delete(save=False)
|
default_storage.delete(self._original_front_image)
|
||||||
if self.rear_image != self._original_rear_image:
|
if self._original_rear_image and self.rear_image != self._original_rear_image:
|
||||||
self._original_rear_image.delete(save=False)
|
default_storage.delete(self._original_rear_image)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
@ -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):
|
def draw_cable(self, cable, terminations, cable_count=0):
|
||||||
labels = [
|
"""
|
||||||
f'Cable {cable}',
|
Draw a single cable. Terminations and cable count are passed for determining position and padding
|
||||||
cable.get_status_display()
|
|
||||||
]
|
:param cable: The cable to draw
|
||||||
if cable.type:
|
:param terminations: List of terminations to build positioning data off of
|
||||||
labels.append(cable.get_type_display())
|
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
|
||||||
if cable.length and cable.length_unit:
|
tooltip.
|
||||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
"""
|
||||||
|
|
||||||
|
# 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
|
# Determine if we have fanins or fanouts
|
||||||
if type(link) is Cable:
|
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
|
# Draw fan-ins
|
||||||
if len(near_ends) > 1:
|
if len(near_ends) > 1 and fanin:
|
||||||
self.cursor += FANOUT_HEIGHT
|
for term in terminations:
|
||||||
|
if term.object.cable == link:
|
||||||
|
self.draw_fanin(term, cable)
|
||||||
|
|
||||||
cable = self.draw_cable(link)
|
# WirelessLink
|
||||||
self.connectors.append(cable)
|
elif type(link) is WirelessLink:
|
||||||
|
wirelesslink = self.draw_wirelesslink(link)
|
||||||
# Draw fan-ins
|
self.connectors.append(wirelesslink)
|
||||||
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)
|
|
||||||
|
|
||||||
# Far end termination(s)
|
# Far end termination(s)
|
||||||
if len(far_ends) > 1:
|
if len(far_ends) > 1:
|
||||||
self.cursor += FANOUT_HEIGHT
|
if fanout:
|
||||||
terminations = self.draw_terminations(far_ends)
|
self.cursor += FANOUT_HEIGHT
|
||||||
for term in terminations:
|
terminations = self.draw_terminations(far_ends)
|
||||||
self.draw_fanout(term, cable)
|
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:
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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):
|
||||||
|
@ -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")
|
||||||
)
|
)
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
@ -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 == '':
|
if obj != '':
|
||||||
return None
|
# Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously
|
||||||
return {
|
# configured choice has been removed from FIELD_CHOICES).
|
||||||
'value': obj,
|
return {
|
||||||
'label': self._choices[obj],
|
'value': obj,
|
||||||
}
|
'label': self._choices.get(obj, ''),
|
||||||
|
}
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
if data == '':
|
if data == '':
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 toggleDisconnectedButton = findFirstAdjacent<HTMLButtonElement>(
|
||||||
const caption = this.table.querySelector('caption');
|
this.table,
|
||||||
this.caption = caption;
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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">
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user