Переглянути джерело

Merge branch 'develop' into develop-2.8

Jeremy Stretch 5 роки тому
батько
коміт
a72d5c899e

+ 12 - 0
docs/extra.css

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

+ 11 - 1
docs/release-notes/version-2.7.md

@@ -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
 
 
 ---
 ---
 
 

+ 2 - 1
mkdocs.yml

@@ -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:

+ 1 - 1
netbox/dcim/api/views.py

@@ -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')

+ 16 - 0
netbox/dcim/forms.py

@@ -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(),

+ 10 - 9
netbox/dcim/models/__init__.py

@@ -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,15 +2115,15 @@ 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
-        term_a_positions = getattr(self.termination_a, 'positions', 1)
-        term_b_positions = getattr(self.termination_b, 'positions', 1)
-        if term_a_positions != term_b_positions:
-            raise ValidationError(
-                "{} 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
+        # A RearPort with multiple positions must be connected to a component with an equal number of positions
+        if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
+            if self.termination_a.positions != self.termination_b.positions:
+                raise ValidationError(
+                    "{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
+                        self.termination_a, self.termination_a.positions,
+                        self.termination_b, self.termination_b.positions
+                    )
                 )
                 )
-            )
 
 
         # A termination point cannot be connected to itself
         # A termination point cannot be connected to itself
         if self.termination_a == self.termination_b:
         if self.termination_a == self.termination_b:

+ 52 - 31
netbox/dcim/models/device_components.py

@@ -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 peer_termination, position
+                    return None
+                return peer_termination
 
 
             # Termination is not a pass-through port
             # Termination is not a pass-through port
             else:
             else:
-                return None, None
-
-        if not self.cable:
-            return [(self, None, None)]
+                return None
 
 
-        # Record cable history to detect loops
-        if cable_history is None:
-            cable_history = []
-        elif self.cable in cable_history:
-            raise LoopDetected()
-        cable_history.append(self.cable)
+        logger = logging.getLogger('netbox.dcim.cable.trace')
+        logger.debug("Tracing cable from {} {}".format(self.parent, self))
 
 
-        far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
-        path = [(self, self.cable, far_end)]
+        while endpoint is not None:
 
 
-        peer_port, position = get_peer_port(far_end, position, follow_circuits)
-        if peer_port is None:
-            return path
+            # No cable connected; nothing to trace
+            if not endpoint.cable:
+                path.append((endpoint, None, None))
+                logger.debug("No cable connected")
+                return path
 
 
-        try:
-            next_segment = peer_port.trace(position, follow_circuits, cable_history)
-        except LoopDetected:
-            return path
+            # Check for loops
+            if endpoint.cable in [segment[1] for segment in path]:
+                logger.debug("Loop detected!")
+                return path
 
 
-        if next_segment is None:
-            return path + [(peer_port, None, None)]
+            # Record the current segment in the path
+            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:

+ 11 - 0
netbox/dcim/signals.py

@@ -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()

+ 3 - 3
netbox/dcim/tables.py

@@ -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')
-        sequence = ('pk', 'name', 'device', 'installed_device')
+        fields = ('pk', 'name', 'device', 'installed_device', 'description')
+        sequence = ('pk', 'name', 'device', 'installed_device', 'description')
         exclude = ('cable',)
         exclude = ('cable',)
 
 
 
 

+ 318 - 66
netbox/dcim/tests/test_models.py

@@ -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')
-        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        site = Site.objects.create(name='Site 1', slug='site-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'
+        )
+        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),
+        ))
+
+        # 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),
+            ))
+
+        # Create four patch panels, each with one rear port and four front ports
+        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')
         )
         )
-        self.device1 = Device.objects.create(
-            device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
+        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')
         )
         )
-        self.device2 = Device.objects.create(
-            device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site
+        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')
         )
         )
-        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
+        cable2.save()
+        cable3 = 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')
         )
         )
-        self.panel2 = Device.objects.create(
-            device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site
+        cable3.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 2
+        cable2.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_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')
         )
         )
-        self.rear_port1 = RearPort.objects.create(
-            device=self.panel1, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C
+        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')
         )
         )
-        self.front_port1 = FrontPort.objects.create(
-            device=self.panel1, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=self.rear_port1
+        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')
         )
         )
-        self.rear_port2 = RearPort.objects.create(
-            device=self.panel2, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C
+        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')
         )
         )
-        self.front_port2 = FrontPort.objects.create(
-            device=self.panel2, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=self.rear_port2
+        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()
 
 
-    def test_path_completion(self):
+        # 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')
 
 
-        # First segment
-        cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1)
-        cable1.save()
-        interface1 = Interface.objects.get(pk=self.interface1.pk)
-        self.assertIsNone(interface1.connected_endpoint)
-        self.assertIsNone(interface1.connection_status)
+        # 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)
 
 
-        # Second segment
-        cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2)
-        cable2.save()
-        interface1 = Interface.objects.get(pk=self.interface1.pk)
-        self.assertIsNone(interface1.connected_endpoint)
-        self.assertIsNone(interface1.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)
 
 
-        # Third segment
+    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(
         cable3 = Cable(
-            termination_a=self.front_port2,
-            termination_b=self.interface2,
-            status=CableStatusChoices.STATUS_PLANNED
+            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()
         cable3.save()
-        interface1 = Interface.objects.get(pk=self.interface1.pk)
-        self.assertEqual(interface1.connected_endpoint, self.interface2)
-        self.assertFalse(interface1.connection_status)
+        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()
 
 
-        # Switch third segment from planned to connected
-        cable3.status = CableStatusChoices.STATUS_CONNECTED
-        cable3.save()
-        interface1 = Interface.objects.get(pk=self.interface1.pk)
-        self.assertEqual(interface1.connected_endpoint, self.interface2)
-        self.assertTrue(interface1.connection_status)
+        # 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()
 
 
-    def test_path_teardown(self):
+        # Refresh endpoints
+        endpoint_a.refresh_from_db()
+        endpoint_b.refresh_from_db()
 
 
-        # Build the path
-        cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1)
+        # 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()
         cable1.save()
-        cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2)
+        cable2 = Cable(
+            termination_a=CircuitTermination.objects.get(term_side='Z'),
+            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
+        )
         cable2.save()
         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()
-        interface1 = Interface.objects.get(pk=self.interface1.pk)
-        self.assertIsNone(interface1.connected_endpoint)
-        self.assertIsNone(interface1.connection_status)
-        interface2 = Interface.objects.get(pk=self.interface2.pk)
-        self.assertIsNone(interface2.connected_endpoint)
-        self.assertIsNone(interface2.connection_status)
+        # 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)

+ 10 - 10
netbox/dcim/tests/test_views.py

@@ -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')
-        device2 = create_test_device('Device 2')
+        device = create_test_device('Device 1')
 
 
         # 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=device1, name='Device Bay 2'),
-            DeviceBay(device=device1, name='Device Bay 3'),
+            DeviceBay(device=device, name='Device Bay 1'),
+            DeviceBay(device=device, name='Device Bay 2'),
+            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",

+ 1 - 1
netbox/dcim/urls.py

@@ -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'),

+ 9 - 1
netbox/dcim/views.py

@@ -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', {

+ 3 - 2
netbox/extras/scripts.py

@@ -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)

+ 0 - 1
netbox/netbox/admin.py

@@ -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')

+ 17 - 2
netbox/netbox/urls.py

@@ -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'),

+ 19 - 0
netbox/templates/admin/index.html

@@ -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 %}

+ 3 - 0
netbox/templates/circuits/inc/circuit_termination.html

@@ -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 }}

+ 3 - 0
netbox/templates/dcim/rackreservation.html

@@ -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>