diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 000000000..1bdba5a13 --- /dev/null +++ b/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; +} diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index b34731403..047ee3549 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,18 +1,28 @@ # NetBox v2.7 Release Notes -## v2.7.11 (FUTURE) +## v2.7.11 (2020-03-27) ### 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 * [#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 * [#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 * [#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 +* [#4415](https://github.com/netbox-community/netbox/issues/4415) - Fix duplicate name validation on device model --- diff --git a/mkdocs.yml b/mkdocs.yml index d980cc80c..c92f53d99 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,11 +7,12 @@ python: theme: name: readthedocs navigation_depth: 3 +extra_css: + - extra.css markdown_extensions: - admonition: - markdown_include.include: headingOffset: 1 - nav: - Introduction: 'index.md' - Installation: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f61041b58..69c862460 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -48,7 +48,7 @@ class CableTraceMixin(object): # Initialize the path array 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 serializer_a = get_serializer_for_model(near_end, prefix='Nested') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f34a0c819..6a93718b8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4008,6 +4008,22 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).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): device = FlexibleModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 185b742f8..5355ffae3 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -776,6 +776,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return 0 +@extras_features('custom_links', 'export_templates', 'webhooks') class RackReservation(ChangeLoggedModel): """ 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 # of the uniqueness constraint without manual intervention. 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({ 'name': 'A device with this name already exists.' }) @@ -2114,15 +2115,15 @@ class Cable(ChangeLoggedModel): 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 if self.termination_a == self.termination_b: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a9cbc194c..3e615b283 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,3 +1,5 @@ +import logging + from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,7 +10,6 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features @@ -88,7 +89,7 @@ class CableTermination(models.Model): class Meta: 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: [ @@ -97,65 +98,85 @@ class CableTermination(models.Model): (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 # Map a front port to its corresponding rear port 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 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): raise Exception("Invalid position for {} ({} positions): {})".format( termination, termination.positions, position )) + try: peer_port = FrontPort.objects.get( rear_port=termination, rear_port_position=position, ) - return peer_port, 1 + return peer_port except ObjectDoesNotExist: - return None, None + return None # Follow a circuit to its other termination - elif isinstance(termination, CircuitTermination) and follow_circuits: + elif isinstance(termination, CircuitTermination): peer_termination = termination.get_peer_termination() if peer_termination is None: - return None, None - return peer_termination, position + return None + return peer_termination # Termination is not a pass-through port else: - return None, None + return None - if not self.cable: - return [(self, None, None)] + logger = logging.getLogger('netbox.dcim.cable.trace') + logger.debug("Tracing cable from {} {}".format(self.parent, self)) - # 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) + while endpoint is not None: - far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a - path = [(self, self.cable, far_end)] + # No cable connected; nothing to trace + if not endpoint.cable: + path.append((endpoint, None, None)) + logger.debug("No cable connected") + return path - peer_port, position = get_peer_port(far_end, position, follow_circuits) - if peer_port is None: - return path + # Check for loops + if endpoint.cable in [segment[1] for segment in path]: + logger.debug("Loop detected!") + return path - try: - next_segment = peer_port.trace(position, follow_circuits, cable_history) - except LoopDetected: - return path + # 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 + )) - if next_segment is None: - return path + [(peer_port, None, None)] - - 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): if self.cable is None: diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 71ee7ec3c..4ea09655f 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,3 +1,5 @@ +import logging + from django.db.models.signals import post_save, pre_delete 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 """ + logger = logging.getLogger('netbox.dcim.cable') # Cache the Cable on its two termination points if instance.termination_a.cable != instance: + logger.debug("Updating termination A for cable {}".format(instance)) instance.termination_a.cable = instance instance.termination_a.save() if instance.termination_b.cable != instance: + logger.debug("Updating termination B for cable {}".format(instance)) instance.termination_b.cable = instance instance.termination_b.save() # Check if this Cable has formed a complete path. If so, update both 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): + logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) endpoint_a.connected_endpoint = endpoint_b endpoint_a.connection_status = path_status 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 """ + logger = logging.getLogger('netbox.dcim.cable') + endpoint_a, endpoint_b, _ = instance.get_path_endpoints() # Disassociate the Cable from its termination points if instance.termination_a is not None: + logger.debug("Nullifying termination A for cable {}".format(instance)) instance.termination_a.cable = None instance.termination_a.save() if instance.termination_b is not None: + logger.debug("Nullifying termination B for cable {}".format(instance)) instance.termination_b.cable = None instance.termination_b.save() # If this Cable was part of a complete path, tear it down 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.connection_status = None endpoint_a.save() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index c36ba076a..7131a6be3 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -864,7 +864,7 @@ class DeviceBayTable(BaseTable): class Meta(BaseTable.Meta): model = DeviceBay - fields = ('name',) + fields = ('name', 'description') class DeviceBayDetailTable(DeviceComponentDetailTable): @@ -872,8 +872,8 @@ class DeviceBayDetailTable(DeviceComponentDetailTable): installed_device = tables.LinkColumn() 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',) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index c9d5c4c20..303980630 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,6 +1,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from circuits.models import * from dcim.choices import * from dcim.models import * from tenancy.models import Tenant @@ -459,95 +460,346 @@ class CableTestCase(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( - 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( - name='Test Device Role 1', slug='test-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 + 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), + )) - 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 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1) + # 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') + ) + 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() - interface1 = Interface.objects.get(pk=self.interface1.pk) - self.assertIsNone(interface1.connected_endpoint) - self.assertIsNone(interface1.connection_status) - - # Second segment - cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + 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() - interface1 = Interface.objects.get(pk=self.interface1.pk) - self.assertIsNone(interface1.connected_endpoint) - self.assertIsNone(interface1.connection_status) - - # Third segment cable3 = Cable( - termination_a=self.front_port2, - termination_b=self.interface2, - status=CableStatusChoices.STATUS_PLANNED + 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') ) 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 - 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') - 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 - 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 + # Delete cable 2 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) + + # 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') + ) + 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) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9abb47ab9..2feaf625b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1336,37 +1336,37 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay - # Disable inapplicable views - test_bulk_edit_objects = None - @classmethod 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 DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) 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 = { - 'device': device2.pk, + 'device': device.pk, 'name': 'Device Bay X', 'description': 'A device bay', 'tags': 'Alpha,Bravo,Charlie', } cls.bulk_create_data = { - 'device': device2.pk, + 'device': device.pk, 'name_pattern': 'Device Bay [4-6]', 'description': 'A device bay', 'tags': 'Alpha,Bravo,Charlie', } + cls.bulk_edit_data = { + 'description': 'New description', + } + cls.csv_data = ( "device,name", "Device 1,Device Bay 4", diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 05f0aa9c2..c62800386 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -284,7 +284,7 @@ urlpatterns = [ path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), 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/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 932b156bc..725be6990 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1899,6 +1899,14 @@ class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView): 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): permission_required = 'dcim.change_devicebay' queryset = DeviceBay.objects.all() @@ -2025,7 +2033,7 @@ class CableTraceView(PermissionRequiredMixin, View): def get(self, request, model, 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]) return render(request, 'dcim/cable_trace.html', { diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 99236bf9c..f0ee13e7c 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -19,6 +19,7 @@ from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from utilities.exceptions import AbortTransaction +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField from .forms import ScriptForm from .signals import purge_changelog @@ -168,7 +169,7 @@ class ObjectVar(ScriptVariable): """ NetBox object representation. The provided QuerySet will determine the choices available. """ - form_field = forms.ModelChoiceField + form_field = DynamicModelChoiceField def __init__(self, queryset, *args, **kwargs): super().__init__(*args, **kwargs) @@ -185,7 +186,7 @@ class MultiObjectVar(ScriptVariable): """ Like ObjectVar, but can represent one or more objects. """ - form_field = forms.ModelMultipleChoiceField + form_field = DynamicModelMultipleChoiceField def __init__(self, queryset, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 222c6ffc5..1f806c7d9 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -11,7 +11,6 @@ class NetBoxAdminSite(AdminSite): site_header = 'NetBox Administration' site_title = 'NetBox' site_url = '/{}'.format(settings.BASE_PATH) - index_template = 'django_rq/index.html' admin_site = NetBoxAdminSite(name='admin') diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 6444f8a27..73aca52c4 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,6 +1,7 @@ from django.conf import settings 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 drf_yasg import openapi 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 .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( title="NetBox API", default_version='v2', @@ -61,7 +74,9 @@ _patterns = [ # Admin 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 path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), diff --git a/netbox/templates/admin/index.html b/netbox/templates/admin/index.html new file mode 100644 index 000000000..4867cbd3c --- /dev/null +++ b/netbox/templates/admin/index.html @@ -0,0 +1,19 @@ +{% extends "admin/index.html" %} + +{% block content_title %}{% endblock %} + +{% block sidebar %} + {{ block.super }} +
+ + + + + + + +
Utilities
+ Background Tasks +
+
+{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index d161fdb68..970f48f21 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -48,6 +48,9 @@ {% endif %} {{ termination.cable }} + + + {% if termination.connected_endpoint %} to {{ termination.connected_endpoint.device }} {{ termination.connected_endpoint }} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ef9e49d23..be9766557 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -36,6 +36,9 @@

{% block title %}{{ rackreservation }}{% endblock %}

{% include 'inc/created_updated.html' with obj=rackreservation %} +
+ {% custom_links rackreservation %} +