diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index d7832a823..d77fbd365 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -1,19 +1,22 @@ -# v2.6.12 (FUTURE) +# v2.6.12 (2020-01-13) ## Enhancements -* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger -* [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering the link +* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger (OpenAPI) +* [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering over the link * [#2113](https://github.com/netbox-community/netbox/issues/2113) - Allow NAPALM driver settings to be changed with request headers -* [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle for showing available prefixes/ip addresses -* [#3009](https://github.com/netbox-community/netbox/issues/3009) - Search by description when assigning IP address -* [#3090](https://github.com/netbox-community/netbox/issues/3090) - Add filter field for device interfaces -* [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations -* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate the circuits at the provider details view -* [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total length to cable trace -* [#3623](https://github.com/netbox-community/netbox/issues/3623) - Add word expansion during interface creation -* [#3668](https://github.com/netbox-community/netbox/issues/3668) - Search by DNS name when assigning IP address +* [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle the display of child prefixes/IP addresses +* [#3009](https://github.com/netbox-community/netbox/issues/3009) - Search by description when assigning IP address to interfaces +* [#3021](https://github.com/netbox-community/netbox/issues/3021) - Add `tenant` filter field for cables +* [#3090](https://github.com/netbox-community/netbox/issues/3090) - Enable filtering of interfaces by name on the device view +* [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations view +* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate assigned circuits at the provider details view +* [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total path length to cable trace +* [#3491](https://github.com/netbox-community/netbox/issues/3491) - Include content of response on webhook error +* [#3623](https://github.com/netbox-community/netbox/issues/3623) - Enable word expansion during interface creation +* [#3668](https://github.com/netbox-community/netbox/issues/3668) - Enable searching by DNS name when assigning IP address * [#3851](https://github.com/netbox-community/netbox/issues/3851) - Allow passing initial data to custom script forms +* [#3891](https://github.com/netbox-community/netbox/issues/3891) - Add `local_context_data` filter for virtual machines ## Bug Fixes @@ -21,12 +24,14 @@ * [#3849](https://github.com/netbox-community/netbox/issues/3849) - Fix ordering of models when dumping data to JSON * [#3853](https://github.com/netbox-community/netbox/issues/3853) - Fix device role link on config context view * [#3856](https://github.com/netbox-community/netbox/issues/3856) - Allow filtering VM interfaces by multiple MAC addresses -* [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix group custom links rendering +* [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix rendering of grouped custom links * [#3862](https://github.com/netbox-community/netbox/issues/3862) - Allow filtering device components by multiple device names -* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks -* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs of an address -* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fixed min/max to ASN input field at the site creation page +* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks for prefixes and IP addresses +* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs on the IP address view +* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fix minimum/maximum value rendering for site ASN field * [#3882](https://github.com/netbox-community/netbox/issues/3882) - Fix filtering of devices by rack group +* [#3898](https://github.com/netbox-community/netbox/issues/3898) - Fix references to deleted cables without a label +* [#3905](https://github.com/netbox-community/netbox/issues/3905) - Fix divide-by-zero on power feeds with low power values --- diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 4922cc89c..cf100af00 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1108,6 +1108,14 @@ class CableFilterSet(django_filters.FilterSet): method='filter_device', field_name='device__site__slug' ) + tenant_id = MultiValueNumberFilter( + method='filter_device', + field_name='device__tenant_id' + ) + tenant = MultiValueNumberFilter( + method='filter_device', + field_name='device__tenant__slug' + ) class Meta: model = Cable diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c289f5a25..5144cb1c6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -764,7 +764,7 @@ class RackElevationFilterForm(RackFilterForm): # Filter the rack field based on the site and group self.fields['site'].widget.add_filter_for('id', 'site') - self.fields['rack_group_id'].widget.add_filter_for('id', 'group_id') + self.fields['group_id'].widget.add_filter_for('id', 'group_id') # @@ -3242,6 +3242,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f termination_b_provider = forms.ModelChoiceField( queryset=Provider.objects.all(), label='Provider', + required=False, widget=APISelect( api_url='/api/circuits/providers/', filter_for={ @@ -3295,6 +3296,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', + required=False, widget=APISelect( api_url='/api/dcim/sites/', display_field='cid', @@ -3326,6 +3328,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode ('rack_group', 'termination_b_rackgroup'), ), label='Power Panel', + required=False, widget=APISelect( api_url='/api/dcim/power-panels/', filter_for={ @@ -3557,6 +3560,17 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) + tenant = FilterChoiceField( + queryset=Tenant.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field='slug', + filter_for={ + 'device_id': 'tenant', + } + ) + ) rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index ab1cf1701..18b0af525 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1949,15 +1949,14 @@ class Cable(ChangeLoggedModel): ('termination_b_type', 'termination_b_id'), ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # A copy of the PK to be used by __str__ in case the object is deleted + self._pk = self.pk + def __str__(self): - if self.label: - return self.label - - # Save a copy of the PK on the instance since it's nullified if .delete() is called - if not hasattr(self, 'id_string'): - self.id_string = '#{}'.format(self.pk) - - return self.id_string + return self.label or '#{}'.format(self._pk) def get_absolute_url(self): return reverse('dcim:cable', args=[self.pk]) @@ -2064,6 +2063,9 @@ class Cable(ChangeLoggedModel): super().save(*args, **kwargs) + # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) + self._pk = self.pk + def to_csv(self): return ( '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model), diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 18d0a581b..0c3206cd7 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -11,6 +11,7 @@ from dcim.models import ( VirtualChassis, ) from ipam.models import IPAddress +from tenancy.models import Tenant from virtualization.models import Cluster, ClusterType @@ -1100,7 +1101,7 @@ class DeviceTestCase(TestCase): Cluster.objects.bulk_create(clusters) devices = ( - Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), ) @@ -1328,6 +1329,12 @@ class DeviceTestCase(TestCase): # params = {'device_bays': 'false'} # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_local_context_data(self): + params = {'local_context_data': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'local_context_data': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ConsolePortTestCase(TestCase): queryset = ConsolePort.objects.all() @@ -2121,6 +2128,12 @@ class CableTestCase(TestCase): ) Site.objects.bulk_create(sites) + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[1]), @@ -2133,9 +2146,9 @@ class CableTestCase(TestCase): device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1), + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]), Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), @@ -2218,6 +2231,13 @@ class CableTestCase(TestCase): params = {'site': [site[0].slug, site[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + def test_tenant(self): + tenant = Tenant.objects.all()[:2] + params = {'tenant_id': [tenant[0].pk, tenant[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tenant': [tenant[0].slug, tenant[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class PowerPanelTestCase(TestCase): queryset = PowerPanel.objects.all() diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 18d16150d..7573d2cc4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -365,9 +365,12 @@ class CableTestCase(TestCase): def test_cable_deletion(self): """ - When a Cable is deleted, the `cable` field on its termination points must be nullified. + When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method + should still return the PK of the string even after being nullified. """ self.cable.delete() + self.assertIsNone(self.cable.pk) + self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) interface2 = Interface.objects.get(pk=self.interface2.pk) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index e8c13f2a0..e8fcc5f0f 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -61,5 +61,5 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque return 'Status {} returned, webhook successfully processed.'.format(response.status_code) else: raise requests.exceptions.RequestException( - "Status {} returned, webhook FAILED to process.".format(response.status_code) + "Status {} returned with content '{}', webhook FAILED to process.".format(response.status_code, response.content) ) diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 217de4a19..3ce1d36f2 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -110,7 +110,9 @@ {% if utilization %} {{ utilization.allocated }}VA / {{ powerfeed.available_power }}VA - {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %} + {% if powerfeed.available_power > 0 %} + {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %} + {% endif %} {% else %} N/A diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index b2f24c2fe..0d817ef47 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from dcim.models import DeviceRole, Interface, Platform, Region, Site -from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet +from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.filters import ( @@ -11,7 +11,6 @@ from utilities.filters import ( from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine - __all__ = ( 'ClusterFilterSet', 'ClusterGroupFilterSet', @@ -104,7 +103,12 @@ class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) -class VirtualMachineFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class VirtualMachineFilterSet( + LocalConfigContextFilterSet, + TenancyFilterSet, + CustomFieldFilterSet, + CreatedUpdatedFilterSet +): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index d5d5dd35b..b9dd6131a 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -203,7 +203,7 @@ class VirtualMachineTestCase(TestCase): DeviceRole.objects.bulk_create(roles) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) @@ -300,6 +300,12 @@ class VirtualMachineTestCase(TestCase): params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_local_context_data(self): + params = {'local_context_data': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'local_context_data': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class InterfaceTestCase(TestCase): queryset = Interface.objects.all()