Merge pull request #13907 from netbox-community/develop

Release v3.6.3
This commit is contained in:
Jeremy Stretch 2023-09-26 16:26:29 -04:00 committed by GitHub
commit ccc9e89e1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 916 additions and 163 deletions

View File

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

View File

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

View File

@ -1,5 +1,28 @@
# NetBox v3.6
## v3.6.3 (2023-09-26)
### Enhancements
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
### Bug Fixes
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface
---
## v3.6.2 (2023-09-20)
### Enhancements

View File

@ -549,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
}
if 'location' in data:
if location := data.get('location'):
params.update({
f"location__{self.fields['location'].to_field_name}": data.get('location'),
f"location__{self.fields['location'].to_field_name}": location,
})
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)

View File

@ -20,7 +20,7 @@ from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort
from .device_components import FrontPort, RearPort, PathEndpoint
__all__ = (
'Cable',
@ -518,9 +518,16 @@ class CablePath(models.Model):
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
# All mid-span terminations must all be attached to the same device
if not isinstance(terminations[0], PathEndpoint):
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
if len(set(t.link for t in terminations)) > 1:
if len(set(t.link for t in terminations)) > 1 and (
position_stack and len(terminations) != len(position_stack[-1])
):
is_split = True
break
@ -529,46 +536,68 @@ class CablePath(models.Model):
object_to_path_node(t) for t in terminations
])
# Step 2: Determine the attached link (Cable or WirelessLink), if any
link = terminations[0].link
if link is None and len(path) == 1:
# If this is the start of the path and no link exists, return None
return None
elif link is None:
# Step 2: Determine the attached links (Cable or WirelessLink), if any
links = [termination.link for termination in terminations if termination.link is not None]
if len(links) == 0:
if len(path) == 1:
# If this is the start of the path and no link exists, return None
return None
# Otherwise, halt the trace if no link exists
break
assert type(link) in (Cable, WirelessLink)
assert all(type(link) in (Cable, WirelessLink) for link in links)
assert all(isinstance(link, type(links[0])) for link in links)
# Step 3: Record the link and update path status if not "connected"
path.append([object_to_path_node(link)])
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
# Step 3: Record asymmetric paths as split
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
if len(not_connected_terminations) > 0:
is_complete = False
is_split = True
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
cables = []
for link in links:
if object_to_path_node(link) not in cables:
cables.append(object_to_path_node(link))
path.append(cables)
# Step 5: Update the path status if a link is not connected
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
is_active = False
# Step 4: Determine the far-end terminations
if isinstance(link, Cable):
# Step 6: Determine the far-end terminations
if isinstance(links[0], Cable):
termination_type = ContentType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type,
termination_id__in=[t.pk for t in terminations]
)
# Terminations must all belong to same end of Cable
local_cable_end = local_cable_terminations[0].cable_end
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
remote_cable_terminations = CableTermination.objects.filter(
cable=link,
cable_end='A' if local_cable_end == 'B' else 'B'
)
q_filter = Q()
for lct in local_cable_terminations:
cable_end = 'A' if lct.cable_end == 'B' else 'B'
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
remote_cable_terminations = CableTermination.objects.filter(q_filter)
remote_terminations = [ct.termination for ct in remote_cable_terminations]
else:
# WirelessLink
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
remote_terminations = [
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
]
# Step 5: Record the far-end termination object(s)
# Remote Terminations must all be of the same type, otherwise return a split path
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = False
is_split = True
break
# Step 7: Record the far-end termination object(s)
path.append([
object_to_path_node(t) for t in remote_terminations if t is not None
])
# Step 6: Determine the "next hop" terminations, if applicable
# Step 8: Determine the "next hop" terminations, if applicable
if not remote_terminations:
break
@ -577,20 +606,32 @@ class CablePath(models.Model):
rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations]
)
if len(rear_ports) > 1:
assert all(rp.positions == 1 for rp in rear_ports)
elif rear_ports[0].positions > 1:
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations])
terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort):
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
# Obtain the individual front ports based on the termination and all positions
elif len(remote_terminations) > 1 and position_stack:
positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations
assert len(remote_terminations) == len(positions)
# Get our front ports
q_filter = Q()
for rt in remote_terminations:
position = positions.pop()
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
assert q_filter is not Q()
front_ports = FrontPort.objects.filter(q_filter)
# Obtain the individual front ports based on the termination and position
elif position_stack:
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
@ -632,9 +673,16 @@ class CablePath(models.Model):
terminations = [circuit_termination]
# Anything else marks the end of the path
else:
is_complete = True
# Check for non-symmetric path
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = True
elif len(remote_terminations) == 0:
is_complete = False
else:
# Unsupported topology, mark as split and exit
is_complete = False
is_split = True
break
return cls(
@ -740,3 +788,15 @@ class CablePath(models.Model):
return [
ct.get_peer_termination() for ct in nodes
]
def get_asymmetric_nodes(self):
"""
Return all available next segments in a split cable path.
"""
from circuits.models import CircuitTermination
asymmetric_nodes = []
for nodes in self.path_objects:
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
asymmetric_nodes.extend([node for node in nodes if node.link is None])
return asymmetric_nodes

View File

@ -4,6 +4,7 @@ import yaml
from functools import cached_property
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError
@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
ret = super().save(*args, **kwargs)
# Delete any previously uploaded image files that are no longer in use
if self.front_image != self._original_front_image:
self._original_front_image.delete(save=False)
if self.rear_image != self._original_rear_image:
self._original_rear_image.delete(save=False)
if self._original_front_image and self.front_image != self._original_front_image:
default_storage.delete(self._original_front_image)
if self._original_rear_image and self.rear_image != self._original_rear_image:
default_storage.delete(self._original_rear_image)
return ret

View File

@ -32,11 +32,18 @@ class Node(Hyperlink):
color: Box fill color (RRGGBB format)
labels: An iterable of text strings. Each label will render on a new line within the box.
radius: Box corner radius, for rounded corners (default: 10)
object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
which terminations.
"""
def __init__(self, position, width, url, color, labels, radius=10, **extra):
object = None
def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
super(Node, self).__init__(href=url, target='_parent', **extra)
# Save object for reference by cable systems
self.object = object
x, y = position
# Add the box
@ -77,7 +84,7 @@ class Connector(Group):
labels: Iterable of text labels
"""
def __init__(self, start, url, color, labels=[], **extra):
def __init__(self, start, url, color, labels=[], description=[], **extra):
super().__init__(class_='connector', **extra)
self.start = start
@ -104,6 +111,8 @@ class Connector(Group):
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
if len(description) > 0:
link.set_desc("\n".join(description))
self.add(link)
@ -206,7 +215,8 @@ class CableTraceSVG:
url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term),
labels=self._get_labels(term),
radius=5
radius=5,
object=term
)
nodes_height = max(nodes_height, node.box['height'])
nodes.append(node)
@ -238,22 +248,65 @@ class CableTraceSVG:
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_cable(self, cable):
labels = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
def draw_cable(self, cable, terminations, cable_count=0):
"""
Draw a single cable. Terminations and cable count are passed for determining position and padding
:param cable: The cable to draw
:param terminations: List of terminations to build positioning data off of
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
tooltip.
"""
# If the cable count is higher than 2, collapse the description into a tooltip
if cable_count > 2:
# Use the cable __str__ function to denote the cable
labels = [f'{cable}']
# Include the label and the status description in the tooltip
description = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
# Include the cable type in the tooltip
description.append(cable.get_type_display())
if cable.length and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
labels = [
f'Cable {cable}',
cable.get_status_display()
]
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
# If there is only one termination, center on that termination
# Otherwise average the center across the terminations
if len(terminations) == 1:
center = terminations[0].bottom_center[0]
else:
# Get a list of termination centers
termination_centers = [term.bottom_center[0] for term in terminations]
# Average the centers
center = sum(termination_centers) / len(termination_centers)
# Create the connector
connector = Connector(
start=(self.center + OFFSET, self.cursor),
start=(center, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels
labels=labels,
description=description
)
# Set the cursor position
self.cursor += connector.height
return connector
@ -334,34 +387,52 @@ class CableTraceSVG:
# Connector (a Cable or WirelessLink)
if links:
link = links[0] # Remove Cable from list
link_cables = {}
fanin = False
fanout = False
# Cable
if type(link) is Cable:
# Determine if we have fanins or fanouts
if len(near_ends) > len(set(links)):
self.cursor += FANOUT_HEIGHT
fanin = True
if len(far_ends) > len(set(links)):
fanout = True
cursor = self.cursor
for link in links:
# Cable
if type(link) is Cable and not link_cables.get(link.pk):
# Reset cursor
self.cursor = cursor
# Generate a list of terminations connected to this cable
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
# Draw the cable
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
# Add cable to the list of cables
link_cables.update({link.pk: cable})
# Add cable to drawing
self.connectors.append(cable)
# Account for fan-ins height
if len(near_ends) > 1:
self.cursor += FANOUT_HEIGHT
# Draw fan-ins
if len(near_ends) > 1 and fanin:
for term in terminations:
if term.object.cable == link:
self.draw_fanin(term, cable)
cable = self.draw_cable(link)
self.connectors.append(cable)
# Draw fan-ins
if len(near_ends) > 1:
for term in terminations:
self.draw_fanin(term, cable)
# WirelessLink
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# WirelessLink
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# Far end termination(s)
if len(far_ends) > 1:
self.cursor += FANOUT_HEIGHT
terminations = self.draw_terminations(far_ends)
for term in terminations:
self.draw_fanout(term, cable)
if fanout:
self.cursor += FANOUT_HEIGHT
terminations = self.draw_terminations(far_ends)
for term in terminations:
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
else:
self.draw_terminations(far_ends)
elif far_ends:
self.draw_terminations(far_ends)
else:

View File

@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
Get interface enabled state as string to attach to <tr/> DOM element.
"""
if record.enabled:
return "enabled"
return 'enabled'
else:
return "disabled"
return 'disabled'
def get_interface_connected_attribute(record):
"""
Get interface disconnected state as string to attach to <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-enabled': get_interface_state_attribute,
'data-type': lambda record: record.type,
'data-connected': get_interface_connected_attribute
}

View File

@ -15,6 +15,7 @@ class CablePathTestCase(TestCase):
1XX: Test direct connections between different endpoint types
2XX: Test different cable topologies
3XX: Test responses to changes in existing objects
4XX: Test to exclude specific cable topologies
"""
@classmethod
def setUpTestData(cls):
@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
def assertPathExists(self, nodes, **kwargs):
def _get_cablepath(self, nodes, **kwargs):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists.
Return a given cable path
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
:return: The matching CablePath (if any)
"""
@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
path.append([object_to_path_node(node) for node in step])
else:
path.append([object_to_path_node(step)])
return CablePath.objects.filter(path=path, **kwargs).first()
cablepath = CablePath.objects.filter(path=path, **kwargs).first()
def assertPathExists(self, nodes, **kwargs):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
first matching CablePath, if found.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNotNone(cablepath, msg='CablePath not found')
return cablepath
def assertPathDoesNotExist(self, nodes, **kwargs):
"""
Assert that a specific CablePath does *not* exist.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
def assertPathIsSet(self, origin, cablepath, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase):
self.assertPathIsSet(interface3, path3)
self.assertPathIsSet(interface4, path4)
def test_219_interface_to_interface_duplex_via_multiple_rearports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
[FP3] [RP3] --C4-- [RP4] [FP4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2]
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4]
)
cable4.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1, frontport3]
)
cable1.save()
self.assertPathExists(
(interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport4],
b_terminations=[interface2]
)
cable3.save()
self.assertPathExists(
(
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
(rearport1, rearport3), (frontport1, frontport3), cable1, interface1
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2]
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4]
)
cable4.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1]
)
cable1.save()
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
),
is_complete=False
)
# Create cable1
cable5 = Cable(
a_terminations=[interface3],
b_terminations=[frontport3]
)
cable5.save()
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4
),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport4],
b_terminations=[interface2]
)
cable3.save()
self.assertPathExists(
(
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
(rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3)
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 3)
def test_221_non_symmetric_paths(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
frontport5 = FrontPort.objects.create(
device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1
)
frontport6 = FrontPort.objects.create(
device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2],
label='C2'
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4],
label='C4'
)
cable4.save()
cable6 = Cable(
a_terminations=[frontport4],
b_terminations=[frontport5],
label='C6'
)
cable6.save()
cable7 = Cable(
a_terminations=[rearport5],
b_terminations=[rearport6],
label='C7'
)
cable7.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1],
label='C1'
)
cable1.save()
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
),
is_complete=False
)
# Create cable1
cable5 = Cable(
a_terminations=[interface3],
b_terminations=[frontport3],
label='C5'
)
cable5.save()
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
cable7, rearport6, frontport6
),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport6],
b_terminations=[interface2],
label='C3'
)
cable3.save()
self.assertPathExists(
(
interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7),
(rearport1, rearport5), (frontport1, frontport5), (cable1, cable6)
),
is_complete=False,
is_split=True
)
self.assertPathExists(
(
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
cable7, rearport6, frontport6, cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 3)
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
is_complete=True,
is_active=True
)
def test_401_exclude_midspan_devices(self):
"""
[IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
[FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] /
"""
device = Device.objects.create(
site=self.site,
device_type=self.device.device_type,
device_role=self.device.device_role,
name='Test mid-span Device'
)
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4 = FrontPort.objects.create(
device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
)
cable2 = Cable(
a_terminations=[rearport1],
b_terminations=[rearport2],
label='C2'
)
cable2.save()
cable4 = Cable(
a_terminations=[rearport3],
b_terminations=[rearport4],
label='C4'
)
cable4.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[frontport1, frontport3],
label='C1'
)
with self.assertRaises(AssertionError):
cable1.save()
self.assertPathDoesNotExist(
(
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
(rearport2, rearport4), (frontport2, frontport4)
),
is_complete=False
)
self.assertEqual(CablePath.objects.count(), 0)
# Create cable 3
cable3 = Cable(
a_terminations=[frontport2, frontport4],
b_terminations=[interface2],
label='C3'
)
with self.assertRaises(AssertionError):
cable3.save()
self.assertPathDoesNotExist(
(
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
(rearport1, rearport3), (frontport1, frontport2), cable1, interface1
),
is_complete=True,
is_active=True
)
self.assertPathDoesNotExist(
(
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 0)

View File

@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
required=False
)
data_file = NestedDataFileSerializer(
read_only=True
required=False
)
class Meta:

View File

@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
data = [
{'id': c[0], 'display': c[1]} for c in page
]
return self.get_paginated_response(data)
else:
data = []
return self.get_paginated_response(data)
#

View File

@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet):
(ACTION_UPDATE, _('Update'), 'blue'),
(ACTION_DELETE, _('Delete'), 'red'),
)
#
# Dashboard widgets
#
class DashboardWidgetColorChoices(ChoiceSet):
BLUE = 'blue'
INDIGO = 'indigo'
PURPLE = 'purple'
PINK = 'pink'
RED = 'red'
ORANGE = 'orange'
YELLOW = 'yellow'
GREEN = 'green'
TEAL = 'teal'
CYAN = 'cyan'
GRAY = 'gray'
BLACK = 'black'
WHITE = 'white'
CHOICES = (
(BLUE, _('Blue')),
(INDIGO, _('Indigo')),
(PURPLE, _('Purple')),
(PINK, _('Pink')),
(RED, _('Red')),
(ORANGE, _('Orange')),
(YELLOW, _('Yellow')),
(GREEN, _('Green')),
(TEAL, _('Teal')),
(CYAN, _('Cyan')),
(GRAY, _('Gray')),
(BLACK, _('Black')),
(WHITE, _('White')),
)

View File

@ -2,9 +2,9 @@ from django import forms
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from extras.choices import DashboardWidgetColorChoices
from netbox.registry import registry
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.choices import ButtonColorChoices
__all__ = (
'DashboardWidgetAddForm',
@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
required=False
)
color = forms.ChoiceField(
choices=add_blank_choice(ButtonColorChoices),
choices=add_blank_choice(DashboardWidgetColorChoices),
required=False,
)

View File

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

View File

@ -1,7 +1,8 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from dcim.models import Region, Site, SiteGroup
from dcim.models import Location, Rack, Region, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
from ipam.models import *
@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
)
from utilities.forms.widgets import BulkEditNullBooleanSelect
from virtualization.models import Cluster, ClusterGroup
__all__ = (
'AggregateBulkEditForm',
@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False
)
min_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
scope_type = ContentTypeChoiceField(
label=_('Scope type'),
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False
)
scope_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput()
)
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
required=False
)
sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$sitegroup',
}
)
location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site',
}
)
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site',
'location_id': '$location',
}
)
clustergroup = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
query_params={
'group_id': '$clustergroup',
}
)
model = VLANGroup
fieldsets = (
(None, ('site', 'min_vid', 'max_vid', 'description')),
(_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
)
nullable_fields = ('site', 'description')
nullable_fields = ('description',)
def clean(self):
super().clean()
# Assign scope based on scope_type
if self.cleaned_data.get('scope_type'):
scope_field = self.cleaned_data['scope_type'].model
if scope_obj := self.cleaned_data.get(scope_field):
self.cleaned_data['scope_id'] = scope_obj.pk
self.changed_data.append('scope_id')
else:
self.cleaned_data.pop('scope_type')
self.changed_data.remove('scope_type')
class VLANBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -354,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
})
elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]]
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
)

View File

@ -782,6 +782,13 @@ class IPAddress(PrimaryModel):
def __str__(self):
return str(self.address)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Denote the original assigned object (if any) for validation in clean()
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
@ -843,6 +850,26 @@ class IPAddress(PrimaryModel):
)
})
if self._original_assigned_object_id and self._original_assigned_object_type_id:
parent = getattr(self.assigned_object, 'parent_object', None)
ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
original_parent = getattr(original_assigned_object, 'parent_object', None)
# can't use is_primary_ip as self.assigned_object might be changed
is_primary = False
if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk:
is_primary = True
if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk:
is_primary = True
if is_primary and (parent != original_parent):
raise ValidationError({
'assigned_object': _(
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
)
})
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({

View File

@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
)
IPAddress.objects.bulk_create(ip_addresses)
def test_assign_object(self):
"""
Test the creation of available IP addresses within a parent IP range.
"""
site = Site.objects.create(name='Site 1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Switch')
device1 = Device.objects.create(
name='Device 1',
site=site,
device_type=device_type,
role=role,
status='active'
)
interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset')
device2 = Device.objects.create(
name='Device 2',
site=site,
device_type=device_type,
role=role,
status='active'
)
interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset')
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1),
IPAddress(address=IPNetwork('192.168.1.4/24')),
)
IPAddress.objects.bulk_create(ip_addresses)
ip1 = ip_addresses[0]
ip1.assigned_object = interface1
device1.primary_ip4 = ip_addresses[0]
device1.save()
ip2 = ip_addresses[1]
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
self.add_permissions('ipam.change_ipaddress')
# assign to same parent
data = {
'assigned_object_id': interface2.pk
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# assign to same different parent - should error
data = {
'assigned_object_id': interface3.pk
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroup

View File

@ -46,12 +46,13 @@ class ChoiceField(serializers.Field):
return super().validate_empty_values(data)
def to_representation(self, obj):
if obj == '':
return None
return {
'value': obj,
'label': self._choices[obj],
}
if obj != '':
# Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously
# configured choice has been removed from FIELD_CHOICES).
return {
'value': obj,
'label': self._choices.get(obj, ''),
}
def to_internal_value(self, data):
if data == '':

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6.2'
VERSION = '3.6.3'
# Hostname
HOSTNAME = platform.node()

View File

@ -3,6 +3,7 @@ import re
from copy import deepcopy
from django.contrib import messages
from django.contrib.contenttypes.fields import GenericRel
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
model_field = self.queryset.model._meta.get_field(name)
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
m2m_fields[name] = model_field
elif isinstance(model_field, GenericRel):
# Ignore generic relations (these may be used for other purposes in the form)
continue
else:
model_fields[name] = model_field
except FieldDoesNotExist:
# This form field is used to modify a field rather than set its value directly
model_fields[name] = None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = {
const showHideMap: ShowHideMap = {
vlangroup_add: 'vlangroup',
vlangroup_edit: 'vlangroup',
vlangroup_bulk_edit: 'vlangroup',
};
/**

View File

@ -141,9 +141,10 @@ class TableState {
private virtualButton: ButtonState;
/**
* Underlying DOM Table Caption Element.
* Instance of ButtonState for the 'show/hide virtual rows' button.
*/
private caption: Nullable<HTMLTableCaptionElement> = null;
// @ts-expect-error null handling is performed in the constructor
private disconnectedButton: ButtonState;
/**
* All table rows in table
@ -166,9 +167,10 @@ class TableState {
this.table,
'button.toggle-virtual',
);
const caption = this.table.querySelector('caption');
this.caption = caption;
const toggleDisconnectedButton = findFirstAdjacent<HTMLButtonElement>(
this.table,
'button.toggle-disconnected',
);
if (toggleEnabledButton === null) {
throw new TableStateError("Table is missing a 'toggle-enabled' button.", table);
@ -182,10 +184,15 @@ class TableState {
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
}
if (toggleDisconnectedButton === null) {
throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table);
}
// Attach event listeners to the buttons elements.
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this));
// Instantiate ButtonState for each button for state management.
this.enabledButton = new ButtonState(
@ -200,6 +207,10 @@ class TableState {
toggleVirtualButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
);
this.disconnectedButton = new ButtonState(
toggleDisconnectedButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-connected="disconnected"]'),
);
} catch (err) {
if (err instanceof TableStateError) {
// This class is useless for tables that don't have toggle buttons.
@ -211,52 +222,6 @@ class TableState {
}
}
/**
* Get the table caption's text.
*/
private get captionText(): string {
if (this.caption !== null) {
return this.caption.innerText;
}
return '';
}
/**
* Set the table caption's text.
*/
private set captionText(value: string) {
if (this.caption !== null) {
this.caption.innerText = value;
}
}
/**
* Update the table caption's text based on the state of each toggle button.
*/
private toggleCaption(): void {
const showEnabled = this.enabledButton.buttonState === 'show';
const showDisabled = this.disabledButton.buttonState === 'show';
const showVirtual = this.virtualButton.buttonState === 'show';
if (showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled Interfaces';
} else if (showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled & Disabled Interfaces';
} else if (!showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Disabled Interfaces';
} else if (!showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
} else if (!showEnabled && !showDisabled && showVirtual) {
this.captionText = 'Showing Virtual Interfaces';
} else if (showEnabled && !showDisabled && showVirtual) {
this.captionText = 'Showing Enabled & Virtual Interfaces';
} else if (showEnabled && showDisabled && showVirtual) {
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
} else {
this.captionText = '';
}
}
/**
* When toggle buttons are clicked, reapply visability all rows and
* pass the event to all button handlers
@ -272,7 +237,7 @@ class TableState {
instance.enabledButton.handleClick(event);
instance.disabledButton.handleClick(event);
instance.virtualButton.handleClick(event);
instance.toggleCaption();
instance.disconnectedButton.handleClick(event);
}
}

View File

@ -167,6 +167,12 @@ table td > .progress {
}
}
.alert {
code {
color: $gray-600;
}
}
span.profile-button .dropdown-menu {
right: 0;
left: auto;

View File

@ -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>");
// Code
$code-color: $gray-600;
$code-color: $gray-200;
$kbd-color: $white;
$kbd-bg: $gray-300;
$pre-color: null;

View File

@ -23,7 +23,15 @@
</div>
</div>
<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>
<p>{% trans "Select a node below to continue" %}:</p>
<ul class="text-start">

View File

@ -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-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-disconnected" data-state="show">{% trans "Hide Disconnected" %}</button>
</ul>
{% endblock extra_table_controls %}

View File

@ -8,11 +8,17 @@
{% if perms.extras.run_report %}
<div class="row">
<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">
{% csrf_token %}
{% render_form form %}
<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 %}
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
{% else %}

View File

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

View File

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