Merge branch 'develop-2.8' into 3351-plugins

This commit is contained in:
Jeremy Stretch 2020-03-27 13:05:34 -04:00
commit fa83750e72
20 changed files with 520 additions and 164 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,6 +7,8 @@ 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:

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

@ -3,7 +3,8 @@ import importlib
from django.apps import apps from django.apps import apps
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
@ -13,6 +14,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',
@ -65,7 +78,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

@ -1,6 +1,24 @@
{% extends "django_rq/index.html" %} {% extends "admin/index.html" %}
{% block content_title %}{% endblock %}
{% block sidebar %} {% block sidebar %}
{{ block.super }} {{ block.super }}
{% include 'extras/admin/plugins_index.html' %} <div class="module">
<table style="width: 100%">
<caption>System</caption>
<tbody>
<tr>
<th>
<a href="{% url 'rq_home' %}">Background Tasks</a>
</th>
</tr>
<tr>
<th>
<a href="{% url 'plugins_list' %}">Installed plugins</a>
</th>
</tr>
</tbody>
</table>
</div>
{% endblock %} {% 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

@ -38,6 +38,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>

View File

@ -1,14 +0,0 @@
<div id="django-rq">
<div class="module">
<table>
<caption>Plugins</caption>
<tbody>
<tr>
<th>
<a href = "{% url 'plugins_list' %}">Installed plugins</a>
</th>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -2,7 +2,6 @@
{% block title %}Installed Plugins {{ block.super }}{% endblock %} {% block title %}Installed Plugins {{ block.super }}{% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<div class="breadcrumbs"> <div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a> &rsaquo; <a href="{% url 'admin:index' %}">Home</a> &rsaquo;
@ -13,24 +12,23 @@
{% block content_title %}<h1>Installed Plugins{{ queue.name }}</h1>{% endblock %} {% block content_title %}<h1>Installed Plugins{{ queue.name }}</h1>{% endblock %}
{% block content %} {% block content %}
<div id="content-main"> <div id="content-main">
<div class="module" id="changelist"> <div class="module" id="changelist">
<div class="results"> <div class="results">
<table id="result_list"> <table id="result_list">
<thead> <thead>
<tr> <tr>
<th><div class = 'text'><span>Name</span></div></th> <th><div class="text"><span>Name</span></div></th>
<th><div class = 'text'><span>Package Name</span></div></th> <th><div class="text"><span>Package Name</span></div></th>
<th><div class = 'text'><span>Author</span></div></th> <th><div class="text"><span>Author</span></div></th>
<th><div class = 'text'><span>Author Email</span></div></th> <th><div class="text"><span>Author Email</span></div></th>
<th><div class = 'text'><span>Description</span></div></th> <th><div class="text"><span>Description</span></div></th>
<th><div class = 'text'><span>Version</span></div></th> <th><div class="text"><span>Version</span></div></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for plugin in plugins %} {% for plugin in plugins %}
<tr class = "{% cycle 'row1' 'row2' %}"> <tr class="{% cycle 'row1' 'row2' %}">
<td> <td>
{{ plugin.verbose_name }} {{ plugin.verbose_name }}
</td> </td>
@ -56,5 +54,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}