Merge branch 'develop' into develop-2.8

This commit is contained in:
Jeremy Stretch 2020-03-27 12:53:55 -04:00
commit a72d5c899e
19 changed files with 511 additions and 139 deletions

12
docs/extra.css Normal file
View File

@ -0,0 +1,12 @@
/* Custom table styling */
table {
margin-bottom: 24px;
width: 100%;
}
th {
background-color: #f0f0f0;
padding: 6px;
}
td {
padding: 6px;
}

View File

@ -1,18 +1,28 @@
# NetBox v2.7 Release Notes # NetBox v2.7 Release Notes
## v2.7.11 (FUTURE) ## v2.7.11 (2020-03-27)
### Enhancements ### Enhancements
* [#738](https://github.com/netbox-community/netbox/issues/738) - Add ability to automatically check for new releases (must be enabled by setting `RELEASE_CHECK_URL`)
* [#4255](https://github.com/netbox-community/netbox/issues/4255) - Custom script object variables now utilize dynamic form widgets
* [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views * [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views
* [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations * [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations
* [#4380](https://github.com/netbox-community/netbox/issues/4380) - Enable webhooks for rack reservations
* [#4381](https://github.com/netbox-community/netbox/issues/4381) - Enable export templates for rack reservations
* [#4382](https://github.com/netbox-community/netbox/issues/4382) - Enable custom links for rack reservations
* [#4386](https://github.com/netbox-community/netbox/issues/4386) - Update admin links for Django RQ to reflect multiple queues
* [#4389](https://github.com/netbox-community/netbox/issues/4389) - Add a bulk edit view for device bays
* [#4404](https://github.com/netbox-community/netbox/issues/4404) - Add cable trace button for circuit terminations
### Bug Fixes ### Bug Fixes
* [#2769](https://github.com/netbox-community/netbox/issues/2769) - Improve `prefix_length` validation on available-prefixes API * [#2769](https://github.com/netbox-community/netbox/issues/2769) - Improve `prefix_length` validation on available-prefixes API
* [#3193](https://github.com/netbox-community/netbox/issues/3193) - Fix cable tracing across multiple rear ports
* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API * [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API
* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables * [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables
* [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view * [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view
* [#4415](https://github.com/netbox-community/netbox/issues/4415) - Fix duplicate name validation on device model
--- ---

View File

@ -7,11 +7,12 @@ python:
theme: theme:
name: readthedocs name: readthedocs
navigation_depth: 3 navigation_depth: 3
extra_css:
- extra.css
markdown_extensions: markdown_extensions:
- admonition: - admonition:
- markdown_include.include: - markdown_include.include:
headingOffset: 1 headingOffset: 1
nav: nav:
- Introduction: 'index.md' - Introduction: 'index.md'
- Installation: - Installation:

View File

@ -48,7 +48,7 @@ class CableTraceMixin(object):
# Initialize the path array # Initialize the path array
path = [] path = []
for near_end, cable, far_end in obj.trace(follow_circuits=True): for near_end, cable, far_end in obj.trace():
# Serialize each object # Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested') serializer_a = get_serializer_for_model(near_end, prefix='Nested')

View File

@ -4008,6 +4008,22 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk) ).exclude(pk=device_bay.device.pk)
class DeviceBayBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceBay.objects.all(),
widget=forms.MultipleHiddenInput()
)
description = forms.CharField(
max_length=100,
required=False
)
class Meta:
nullable_fields = (
'description',
)
class DeviceBayCSVForm(forms.ModelForm): class DeviceBayCSVForm(forms.ModelForm):
device = FlexibleModelChoiceField( device = FlexibleModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),

View File

@ -776,6 +776,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return 0 return 0
@extras_features('custom_links', 'export_templates', 'webhooks')
class RackReservation(ChangeLoggedModel): class RackReservation(ChangeLoggedModel):
""" """
One or more reserved units within a Rack. One or more reserved units within a Rack.
@ -1436,7 +1437,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention. # of the uniqueness constraint without manual intervention.
if self.name and self.tenant is None: if self.name and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True): if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True):
raise ValidationError({ raise ValidationError({
'name': 'A device with this name already exists.' 'name': 'A device with this name already exists.'
}) })
@ -2114,13 +2115,13 @@ class Cable(ChangeLoggedModel):
self.termination_a_type, self.termination_b_type self.termination_a_type, self.termination_b_type
)) ))
# A component with multiple positions must be connected to a component with an equal number of positions # A RearPort with multiple positions must be connected to a component with an equal number of positions
term_a_positions = getattr(self.termination_a, 'positions', 1) if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
term_b_positions = getattr(self.termination_b, 'positions', 1) if self.termination_a.positions != self.termination_b.positions:
if term_a_positions != term_b_positions:
raise ValidationError( raise ValidationError(
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format( "{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
self.termination_a, term_a_positions, self.termination_b, term_b_positions self.termination_a, self.termination_a.positions,
self.termination_b, self.termination_b.positions
) )
) )

View File

@ -1,3 +1,5 @@
import logging
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -8,7 +10,6 @@ from taggit.managers import TaggableManager
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.exceptions import LoopDetected
from dcim.fields import MACAddressField from dcim.fields import MACAddressField
from extras.models import ObjectChange, TaggedItem from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
@ -88,7 +89,7 @@ class CableTermination(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def trace(self, position=1, follow_circuits=False, cable_history=None): def trace(self):
""" """
Return a list representing a complete cable path, with each individual segment represented as a three-tuple: Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
[ [
@ -97,65 +98,85 @@ class CableTermination(models.Model):
(termination E, cable, termination F) (termination E, cable, termination F)
] ]
""" """
def get_peer_port(termination, position=1, follow_circuits=False): endpoint = self
path = []
position_stack = []
def get_peer_port(termination):
from circuits.models import CircuitTermination from circuits.models import CircuitTermination
# Map a front port to its corresponding rear port # Map a front port to its corresponding rear port
if isinstance(termination, FrontPort): if isinstance(termination, FrontPort):
return termination.rear_port, termination.rear_port_position position_stack.append(termination.rear_port_position)
# Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
return peer_port
# Map a rear port/position to its corresponding front port # Map a rear port/position to its corresponding front port
elif isinstance(termination, RearPort): elif isinstance(termination, RearPort):
# Can't map to a FrontPort without a position
if not position_stack:
# TODO: This behavior is broken. We need a mechanism by which to return all FrontPorts mapped
# to a given RearPort so that we can update end-to-end paths when a cable is created/deleted.
# For now, we're maintaining the current behavior of tracing only to the first FrontPort.
position_stack.append(1)
position = position_stack.pop()
# Validate the position
if position not in range(1, termination.positions + 1): if position not in range(1, termination.positions + 1):
raise Exception("Invalid position for {} ({} positions): {})".format( raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position termination, termination.positions, position
)) ))
try: try:
peer_port = FrontPort.objects.get( peer_port = FrontPort.objects.get(
rear_port=termination, rear_port=termination,
rear_port_position=position, rear_port_position=position,
) )
return peer_port, 1 return peer_port
except ObjectDoesNotExist: except ObjectDoesNotExist:
return None, None return None
# Follow a circuit to its other termination # Follow a circuit to its other termination
elif isinstance(termination, CircuitTermination) and follow_circuits: elif isinstance(termination, CircuitTermination):
peer_termination = termination.get_peer_termination() peer_termination = termination.get_peer_termination()
if peer_termination is None: if peer_termination is None:
return None, None return None
return peer_termination, position return peer_termination
# Termination is not a pass-through port # Termination is not a pass-through port
else: else:
return None, None return None
if not self.cable: logger = logging.getLogger('netbox.dcim.cable.trace')
return [(self, None, None)] logger.debug("Tracing cable from {} {}".format(self.parent, self))
# Record cable history to detect loops while endpoint is not None:
if cable_history is None:
cable_history = []
elif self.cable in cable_history:
raise LoopDetected()
cable_history.append(self.cable)
far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a # No cable connected; nothing to trace
path = [(self, self.cable, far_end)] if not endpoint.cable:
path.append((endpoint, None, None))
peer_port, position = get_peer_port(far_end, position, follow_circuits) logger.debug("No cable connected")
if peer_port is None:
return path return path
try: # Check for loops
next_segment = peer_port.trace(position, follow_circuits, cable_history) if endpoint.cable in [segment[1] for segment in path]:
except LoopDetected: logger.debug("Loop detected!")
return path return path
if next_segment is None: # Record the current segment in the path
return path + [(peer_port, None, None)] far_end = endpoint.get_cable_peer()
path.append((endpoint, endpoint.cable, far_end))
logger.debug("{}[{}] --- Cable {} ---> {}[{}]".format(
endpoint.parent, endpoint, endpoint.cable.pk, far_end.parent, far_end
))
return path + next_segment # Get the peer port of the far end termination
endpoint = get_peer_port(far_end)
if endpoint is None:
return path
def get_cable_peer(self): def get_cable_peer(self):
if self.cable is None: if self.cable is None:

View File

@ -1,3 +1,5 @@
import logging
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
@ -34,18 +36,22 @@ def update_connected_endpoints(instance, **kwargs):
""" """
When a Cable is saved, check for and update its two connected endpoints When a Cable is saved, check for and update its two connected endpoints
""" """
logger = logging.getLogger('netbox.dcim.cable')
# Cache the Cable on its two termination points # Cache the Cable on its two termination points
if instance.termination_a.cable != instance: if instance.termination_a.cable != instance:
logger.debug("Updating termination A for cable {}".format(instance))
instance.termination_a.cable = instance instance.termination_a.cable = instance
instance.termination_a.save() instance.termination_a.save()
if instance.termination_b.cable != instance: if instance.termination_b.cable != instance:
logger.debug("Updating termination B for cable {}".format(instance))
instance.termination_b.cable = instance instance.termination_b.cable = instance
instance.termination_b.save() instance.termination_b.save()
# Check if this Cable has formed a complete path. If so, update both endpoints. # Check if this Cable has formed a complete path. If so, update both endpoints.
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints() endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
endpoint_a.connected_endpoint = endpoint_b endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status endpoint_a.connection_status = path_status
endpoint_a.save() endpoint_a.save()
@ -59,18 +65,23 @@ def nullify_connected_endpoints(instance, **kwargs):
""" """
When a Cable is deleted, check for and update its two connected endpoints When a Cable is deleted, check for and update its two connected endpoints
""" """
logger = logging.getLogger('netbox.dcim.cable')
endpoint_a, endpoint_b, _ = instance.get_path_endpoints() endpoint_a, endpoint_b, _ = instance.get_path_endpoints()
# Disassociate the Cable from its termination points # Disassociate the Cable from its termination points
if instance.termination_a is not None: if instance.termination_a is not None:
logger.debug("Nullifying termination A for cable {}".format(instance))
instance.termination_a.cable = None instance.termination_a.cable = None
instance.termination_a.save() instance.termination_a.save()
if instance.termination_b is not None: if instance.termination_b is not None:
logger.debug("Nullifying termination B for cable {}".format(instance))
instance.termination_b.cable = None instance.termination_b.cable = None
instance.termination_b.save() instance.termination_b.save()
# If this Cable was part of a complete path, tear it down # If this Cable was part of a complete path, tear it down
if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'): if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
logger.debug("Tearing down path ({} <---> {})".format(endpoint_a, endpoint_b))
endpoint_a.connected_endpoint = None endpoint_a.connected_endpoint = None
endpoint_a.connection_status = None endpoint_a.connection_status = None
endpoint_a.save() endpoint_a.save()

View File

@ -864,7 +864,7 @@ class DeviceBayTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceBay model = DeviceBay
fields = ('name',) fields = ('name', 'description')
class DeviceBayDetailTable(DeviceComponentDetailTable): class DeviceBayDetailTable(DeviceComponentDetailTable):
@ -872,8 +872,8 @@ class DeviceBayDetailTable(DeviceComponentDetailTable):
installed_device = tables.LinkColumn() installed_device = tables.LinkColumn()
class Meta(DeviceBayTable.Meta): class Meta(DeviceBayTable.Meta):
fields = ('pk', 'name', 'device', 'installed_device') fields = ('pk', 'name', 'device', 'installed_device', 'description')
sequence = ('pk', 'name', 'device', 'installed_device') sequence = ('pk', 'name', 'device', 'installed_device', 'description')
exclude = ('cable',) exclude = ('cable',)

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
from circuits.models import *
from dcim.choices import * from dcim.choices import *
from dcim.models import * from dcim.models import *
from tenancy.models import Tenant from tenancy.models import Tenant
@ -459,95 +460,346 @@ class CableTestCase(TestCase):
class CablePathTestCase(TestCase): class CablePathTestCase(TestCase):
def setUp(self): @classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
) )
devicerole = DeviceRole.objects.create( devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000' name='Device Role 1', slug='device-role-1', color='ff0000'
)
self.device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
self.device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.panel1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=site
)
self.panel2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site
)
self.rear_port1 = RearPort.objects.create(
device=self.panel1, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C
)
self.front_port1 = FrontPort.objects.create(
device=self.panel1, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=self.rear_port1
)
self.rear_port2 = RearPort.objects.create(
device=self.panel2, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C
)
self.front_port2 = FrontPort.objects.create(
device=self.panel2, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=self.rear_port2
) )
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
CircuitTermination.objects.bulk_create((
CircuitTermination(circuit=circuit, site=site, term_side='A', port_speed=1000),
CircuitTermination(circuit=circuit, site=site, term_side='Z', port_speed=1000),
))
def test_path_completion(self): # Create four network devices with four interfaces each
devices = (
Device(device_type=devicetype, device_role=devicerole, name='Device 1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 4', site=site),
)
Device.objects.bulk_create(devices)
for device in devices:
Interface.objects.bulk_create((
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
))
# First segment # Create four patch panels, each with one rear port and four front ports
cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1) patch_panels = (
Device(device_type=devicetype, device_role=devicerole, name='Panel 1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site),
)
Device.objects.bulk_create(patch_panels)
for patch_panel in patch_panels:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.bulk_create((
FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 2', rear_port=rearport, rear_port_position=2, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 3', rear_port=rearport, rear_port_position=3, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C),
))
def test_direct_connection(self):
"""
[Device 1] ----- [Device 2]
Iface1 Iface1
"""
# Create cable
cable = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable
cable.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_patch(self):
"""
1 2 3
[Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Device 2]
Iface1 FP1 RP1 RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.save() cable1.save()
interface1 = Interface.objects.get(pk=self.interface1.pk) cable2 = Cable(
self.assertIsNone(interface1.connected_endpoint) termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
self.assertIsNone(interface1.connection_status) termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
# Second segment
cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2)
cable2.save() cable2.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.connected_endpoint)
self.assertIsNone(interface1.connection_status)
# Third segment
cable3 = Cable( cable3 = Cable(
termination_a=self.front_port2, termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=self.interface2, termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
status=CableStatusChoices.STATUS_PLANNED
) )
cable3.save() cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertFalse(interface1.connection_status)
# Switch third segment from planned to connected # Retrieve endpoints
cable3.status = CableStatusChoices.STATUS_CONNECTED endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
cable3.save() endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertTrue(interface1.connection_status)
def test_path_teardown(self): # Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Build the path # Delete cable 2
cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1)
cable1.save()
cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2)
cable2.save()
cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2)
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertTrue(interface1.connection_status)
# Remove a cable
cable2.delete() cable2.delete()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.connected_endpoint) # Refresh endpoints
self.assertIsNone(interface1.connection_status) endpoint_a.refresh_from_db()
interface2 = Interface.objects.get(pk=self.interface2.pk) endpoint_b.refresh_from_db()
self.assertIsNone(interface2.connected_endpoint)
self.assertIsNone(interface2.connection_status) # Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_multiple_patches(self):
"""
1 2 3 4 5
[Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
Iface1 FP1 RP1 RP1 FP1 FP1 RP1 RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable2.save()
cable3 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
)
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable4.save()
cable5 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable5.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_stacked_rear_ports(self):
"""
1 2 3 4 5
[Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
Iface1 FP1 RP1 FP1 RP1 RP1 FP1 RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
)
cable3.save()
cable4 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable4.save()
cable5 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable5.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_circuit(self):
"""
1 2
[Device 1] ----- [Circuit] ----- [Device 2]
Iface1 A Z Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable1.save()
cable2 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete circuit
circuit = Circuit.objects.first().delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_patched_circuit(self):
"""
1 2 3 4
[Device 1] ----- [Panel 1] ----- [Circuit] ----- [Panel 2] ----- [Device 2]
Iface1 FP1 RP1 A Z RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable2.save()
cable3 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable3.save()
cable4 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable4.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete circuit
circuit = Circuit.objects.first().delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)

View File

@ -1336,37 +1336,37 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay model = DeviceBay
# Disable inapplicable views
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
device1 = create_test_device('Device 1') device = create_test_device('Device 1')
device2 = create_test_device('Device 2')
# Update the DeviceType subdevice role to allow adding DeviceBays # Update the DeviceType subdevice role to allow adding DeviceBays
DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
DeviceBay.objects.bulk_create([ DeviceBay.objects.bulk_create([
DeviceBay(device=device1, name='Device Bay 1'), DeviceBay(device=device, name='Device Bay 1'),
DeviceBay(device=device1, name='Device Bay 2'), DeviceBay(device=device, name='Device Bay 2'),
DeviceBay(device=device1, name='Device Bay 3'), DeviceBay(device=device, name='Device Bay 3'),
]) ])
cls.form_data = { cls.form_data = {
'device': device2.pk, 'device': device.pk,
'name': 'Device Bay X', 'name': 'Device Bay X',
'description': 'A device bay', 'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie', 'tags': 'Alpha,Bravo,Charlie',
} }
cls.bulk_create_data = { cls.bulk_create_data = {
'device': device2.pk, 'device': device.pk,
'name_pattern': 'Device Bay [4-6]', 'name_pattern': 'Device Bay [4-6]',
'description': 'A device bay', 'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie', 'tags': 'Alpha,Bravo,Charlie',
} }
cls.bulk_edit_data = {
'description': 'New description',
}
cls.csv_data = ( cls.csv_data = (
"device,name", "device,name",
"Device 1,Device Bay 4", "Device 1,Device Bay 4",

View File

@ -284,7 +284,7 @@ urlpatterns = [
path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'), path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
# TODO: Bulk edit view for DeviceBays path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'),
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),

View File

@ -1899,6 +1899,14 @@ class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'dcim:devicebay_list' default_return_url = 'dcim:devicebay_list'
class DeviceBayBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicebay'
queryset = DeviceBay.objects.all()
filterset = filters.DeviceBayFilterSet
table = tables.DeviceBayTable
form = forms.DeviceBayBulkEditForm
class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_devicebay' permission_required = 'dcim.change_devicebay'
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
@ -2025,7 +2033,7 @@ class CableTraceView(PermissionRequiredMixin, View):
def get(self, request, model, pk): def get(self, request, model, pk):
obj = get_object_or_404(model, pk=pk) obj = get_object_or_404(model, pk=pk)
trace = obj.trace(follow_circuits=True) trace = obj.trace()
total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length]) total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length])
return render(request, 'dcim/cable_trace.html', { return render(request, 'dcim/cable_trace.html', {

View File

@ -19,6 +19,7 @@ from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .forms import ScriptForm from .forms import ScriptForm
from .signals import purge_changelog from .signals import purge_changelog
@ -168,7 +169,7 @@ class ObjectVar(ScriptVariable):
""" """
NetBox object representation. The provided QuerySet will determine the choices available. NetBox object representation. The provided QuerySet will determine the choices available.
""" """
form_field = forms.ModelChoiceField form_field = DynamicModelChoiceField
def __init__(self, queryset, *args, **kwargs): def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -185,7 +186,7 @@ class MultiObjectVar(ScriptVariable):
""" """
Like ObjectVar, but can represent one or more objects. Like ObjectVar, but can represent one or more objects.
""" """
form_field = forms.ModelMultipleChoiceField form_field = DynamicModelMultipleChoiceField
def __init__(self, queryset, *args, **kwargs): def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -11,7 +11,6 @@ class NetBoxAdminSite(AdminSite):
site_header = 'NetBox Administration' site_header = 'NetBox Administration'
site_title = 'NetBox' site_title = 'NetBox'
site_url = '/{}'.format(settings.BASE_PATH) site_url = '/{}'.format(settings.BASE_PATH)
index_template = 'django_rq/index.html'
admin_site = NetBoxAdminSite(name='admin') admin_site = NetBoxAdminSite(name='admin')

View File

@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include from django.conf.urls import include
from django.urls import path, re_path from django.urls import path, re_path, reverse
from django.views.generic.base import RedirectView
from django.views.static import serve from django.views.static import serve
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
@ -9,6 +10,18 @@ from netbox.views import APIRootView, HomeView, StaticMediaFailureView, SearchVi
from users.views import LoginView, LogoutView from users.views import LoginView, LogoutView
from .admin import admin_site from .admin import admin_site
# TODO: Remove in v2.9
class RQRedirectView(RedirectView):
"""
Temporary 301 redirect from the old URL to the new one.
"""
permanent = True
def get_redirect_url(self, *args, **kwargs):
return reverse('rq_home')
openapi_info = openapi.Info( openapi_info = openapi.Info(
title="NetBox API", title="NetBox API",
default_version='v2', default_version='v2',
@ -61,7 +74,9 @@ _patterns = [
# Admin # Admin
path('admin/', admin_site.urls), path('admin/', admin_site.urls),
path('admin/webhook-backend-status/', include('django_rq.urls')), path('admin/background-tasks/', include('django_rq.urls')),
# TODO: Remove in v2.9
path('admin/webhook-backend-status/', RQRedirectView.as_view()),
# Errors # Errors
path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),

View File

@ -0,0 +1,19 @@
{% extends "admin/index.html" %}
{% block content_title %}{% endblock %}
{% block sidebar %}
{{ block.super }}
<div class="module">
<table style="width: 100%">
<caption>Utilities</caption>
<tbody>
<tr>
<th>
<a href="{% url 'rq_home' %}">Background Tasks</a>
</th>
</tr>
</tbody>
</table>
</div>
{% endblock %}

View File

@ -48,6 +48,9 @@
</div> </div>
{% endif %} {% endif %}
<a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> <a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
{% if termination.connected_endpoint %} {% if termination.connected_endpoint %}
to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a> to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }} <i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}

View File

@ -36,6 +36,9 @@
</div> </div>
<h1>{% block title %}{{ rackreservation }}{% endblock %}</h1> <h1>{% block title %}{{ rackreservation }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=rackreservation %} {% include 'inc/created_updated.html' with obj=rackreservation %}
<div class="pull-right noprint">
{% custom_links rackreservation %}
</div>
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}> <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ rackreservation.get_absolute_url }}">Rack</a> <a href="{{ rackreservation.get_absolute_url }}">Rack</a>