From 2424f684b9594355b0f32475cb5a407c3e781872 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Oct 2020 10:41:05 -0400 Subject: [PATCH 01/19] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2dfbf04d4..36d3fb980 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.8' +VERSION = '2.9.9-dev' # Hostname HOSTNAME = platform.node() From f0bac741cc0db757585a136a413f81432ea931aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 08:58:20 -0500 Subject: [PATCH 02/19] Move to lock-threads app --- .github/lock.yml | 23 ----------------------- .github/workflows/lock.yml | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 23 deletions(-) delete mode 100644 .github/lock.yml create mode 100644 .github/workflows/lock.yml diff --git a/.github/lock.yml b/.github/lock.yml deleted file mode 100644 index e00f3f4db..000000000 --- a/.github/lock.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Configuration for Lock (https://github.com/apps/lock) - -# Number of days of inactivity before a closed issue or pull request is locked -daysUntilLock: 90 - -# Skip issues and pull requests created before a given timestamp. Timestamp must -# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable -skipCreatedBefore: false - -# Issues and pull requests with these labels will be ignored. Set to `[]` to disable -exemptLabels: [] - -# Label to add before locking, such as `outdated`. Set to `false` to disable -lockLabel: false - -# Comment to post before locking. Set to `false` to disable -lockComment: false - -# Assign `resolved` as the reason for locking. Set to `false` to disable -setLockReason: true - -# Limit to only `issues` or `pulls` -# only: issues diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 000000000..b6073a71b --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,21 @@ +# lock-threads (https://github.com/marketplace/actions/lock-threads) +name: 'Lock threads' + +on: + schedule: + - cron: '0 3 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v2 + with: + github-token: ${{ github.token }} + issue-lock-inactive-days: '90' + issue-exclude-created-before: '' + issue-exclude-labels: '' + issue-lock-labels: '' + issue-lock-comment: '' + issue-lock-reason: 'resolved' + process-only: 'issues' From 1f0b1dd10e5eeb643ebc6a56aaefe8f2821a405b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 10:15:15 -0500 Subject: [PATCH 03/19] Add initial_params to DynamicModelChoiceMixin --- netbox/dcim/forms.py | 39 ++++++++++++-------------------- netbox/utilities/forms/fields.py | 15 ++++++++++-- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f5c27f7d3..7a4b9aaff 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1672,7 +1672,10 @@ class PlatformCSVForm(CSVModelForm): class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False + required=False, + initial_params={ + 'sites': '$site' + } ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -1686,6 +1689,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name', query_params={ 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' } ) rack = DynamicModelChoiceField( @@ -1711,7 +1717,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False + required=False, + initial_params={ + 'device_types': '$device_type' + } ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), @@ -1733,7 +1742,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, - null_option='None' + null_option='None', + initial_params={ + 'clusters': '$cluster' + } ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), @@ -1772,27 +1784,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - if 'initial' not in kwargs: - kwargs['initial'] = {} - # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field - if instance and hasattr(instance, 'device_type'): - kwargs['initial']['manufacturer'] = instance.device_type.manufacturer - if instance and instance.cluster is not None: - kwargs['initial']['cluster_group'] = instance.cluster.group - - if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']: - device_type_id = kwargs['initial']['device_type'] - manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first() - kwargs['initial']['manufacturer'] = manufacturer_id - - if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']: - cluster_id = kwargs['initial']['cluster'] - cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first() - kwargs['initial']['cluster_group'] = cluster_group_id - super().__init__(*args, **kwargs) if self.instance.pk: diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 6146e00d3..982b80323 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -248,6 +248,7 @@ class DynamicModelChoiceMixin: """ :param display_field: The name of the attribute of an API response object to display in the selection list :param query_params: A dictionary of additional key/value pairs to attach to the API request + :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value :param null_option: The string used to represent a null selection (if any) :param disabled_indicator: The name of the field which, if populated, will disable selection of the choice (optional) @@ -256,10 +257,11 @@ class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter widget = widgets.APISelect - def __init__(self, display_field='name', query_params=None, null_option=None, disabled_indicator=None, - brief_mode=True, *args, **kwargs): + def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None, + disabled_indicator=None, brief_mode=True, *args, **kwargs): self.display_field = display_field self.query_params = query_params or {} + self.initial_params = initial_params or {} self.null_option = null_option self.disabled_indicator = disabled_indicator self.brief_mode = brief_mode @@ -300,6 +302,15 @@ class DynamicModelChoiceMixin: def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) + # Set initial value based on prescribed child fields (if not already set) + if not self.initial and self.initial_params: + filter_kwargs = {} + for kwarg, child_field in self.initial_params.items(): + value = form.initial.get(child_field.lstrip('$')) + if value: + filter_kwargs[kwarg] = value + self.initial = self.queryset.filter(**filter_kwargs).first() + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() From 7598b891c1e356a0df77850b50877f9340e235d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 11:09:13 -0500 Subject: [PATCH 04/19] Add initial_params to all forms --- netbox/dcim/forms.py | 13 ++++--------- netbox/dcim/tests/test_forms.py | 17 ----------------- netbox/ipam/forms.py | 23 +++++++++++------------ netbox/tenancy/forms.py | 16 ++++------------ netbox/utilities/forms/fields.py | 3 ++- netbox/virtualization/forms.py | 13 ++++--------- 6 files changed, 25 insertions(+), 60 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7a4b9aaff..838a6508e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4280,7 +4280,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + initial_params={ + 'powerpanel_set': '$power_panel' + } ) power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), @@ -4315,14 +4318,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'phase': StaticSelect2(), } - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - # Initialize site field - if self.instance and hasattr(self.instance, 'power_panel'): - self.initial['site'] = self.instance.power_panel.site - class PowerFeedCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index aadc2cbfc..e8cb73fe4 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -100,23 +100,6 @@ class DeviceTestCase(TestCase): self.assertIn('face', form.errors) self.assertIn('position', form.errors) - def test_initial_data_population(self): - device_type = DeviceType.objects.first() - cluster = Cluster.objects.first() - test = DeviceForm(initial={ - 'device_type': device_type.pk, - 'device_role': DeviceRole.objects.first().pk, - 'status': DeviceStatusChoices.STATUS_ACTIVE, - 'site': Site.objects.first().pk, - 'cluster': cluster.pk, - }) - - # Check that the initial value for the manufacturer is set automatically when assigning the device type - self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk) - - # Check that the initial value for the cluster group is set automatically when assigning the cluster - self.assertEqual(test.initial['cluster_group'], cluster.group.pk) - class LabelTestCase(TestCase): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 75a4caf10..b49fb2e41 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -265,6 +265,9 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): null_option='None', query_params={ 'site_id': '$site' + }, + initial_params={ + 'vlans': '$vlan' } ) vlan = DynamicModelChoiceField( @@ -297,14 +300,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}).copy() - if instance and instance.vlan is not None: - initial['vlan_group'] = instance.vlan.group - kwargs['initial'] = initial - super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -501,7 +496,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - display_field='display_name' + display_field='display_name', + initial_params={ + 'interfaces': '$interface' + } ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -512,7 +510,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel ) virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), - required=False + required=False, + initial_params={ + 'interfaces': '$vminterface' + } ) vminterface = DynamicModelChoiceField( queryset=VMInterface.objects.all(), @@ -611,10 +612,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel initial = kwargs.get('initial', {}).copy() if instance: if type(instance.assigned_object) is Interface: - initial['device'] = instance.assigned_object.device initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VMInterface: - initial['virtual_machine'] = instance.assigned_object.virtual_machine initial['vminterface'] = instance.assigned_object if instance.nat_inside: nat_inside_parent = instance.nat_inside.assigned_object diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 142333bff..bceab7ce7 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -119,7 +119,10 @@ class TenancyForm(forms.Form): tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, - null_option='None' + null_option='None', + initial_params={ + 'tenants': '$tenant' + } ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), @@ -129,17 +132,6 @@ class TenancyForm(forms.Form): } ) - def __init__(self, *args, **kwargs): - - # Initialize helper selector - instance = kwargs.get('instance') - if instance and instance.tenant is not None: - initial = kwargs.get('initial', {}).copy() - initial['tenant_group'] = instance.tenant.group - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - class TenancyFilterForm(forms.Form): tenant_group = DynamicModelMultipleChoiceField( diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 982b80323..92e40e7bb 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -309,7 +309,8 @@ class DynamicModelChoiceMixin: value = form.initial.get(child_field.lstrip('$')) if value: filter_kwargs[kwarg] = value - self.initial = self.queryset.filter(**filter_kwargs).first() + if filter_kwargs: + self.initial = self.queryset.filter(**filter_kwargs).first() # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 712b39f9f..f99840f86 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -266,7 +266,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, - null_option='None' + null_option='None', + initial_params={ + 'clusters': '$cluster' + } ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), @@ -311,14 +314,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } def __init__(self, *args, **kwargs): - - # Initialize helper selector - instance = kwargs.get('instance') - if instance.pk and instance.cluster is not None: - initial = kwargs.get('initial', {}).copy() - initial['cluster_group'] = instance.cluster.group - kwargs['initial'] = initial - super().__init__(*args, **kwargs) if self.instance.pk: From 798220cf9a7e9de7a3b859e762e122aaa457749e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 13:05:24 -0500 Subject: [PATCH 05/19] Fix filter reference --- netbox/dcim/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 838a6508e..3b1f24b3a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4282,7 +4282,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): queryset=Site.objects.all(), required=False, initial_params={ - 'powerpanel_set': '$power_panel' + 'powerpanel': '$power_panel' } ) power_panel = DynamicModelChoiceField( From 7d52b4d9a541e95ffcc126a430f71bb150b3465f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 13:23:56 -0500 Subject: [PATCH 06/19] Changelog for #5271 --- docs/release-notes/version-2.9.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index dc6a10dae..977cb08e0 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,5 +1,14 @@ # NetBox v2.9 +## v2.9.9 (FUTURE) + +### Bug Fixes + +* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device + + +--- + ## v2.9.8 (2020-10-30) ### Enhancements From c53990c7393964893137aa6514955441c4c39e86 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 15:27:41 -0500 Subject: [PATCH 07/19] Closes #5307: Add a region selector for every site form field --- netbox/circuits/forms.py | 14 +- netbox/dcim/forms.py | 213 +++++++++++++++--- netbox/dcim/views.py | 5 +- netbox/ipam/forms.py | 71 +++++- .../circuits/circuittermination_edit.html | 1 + netbox/templates/dcim/cable_connect.html | 9 + netbox/templates/dcim/powerfeed_edit.html | 8 +- netbox/templates/dcim/rack_edit.html | 1 + .../templates/dcim/rackreservation_edit.html | 1 + netbox/templates/dcim/virtualchassis_add.html | 1 + netbox/templates/ipam/ipaddress_edit.html | 1 + netbox/templates/ipam/prefix_edit.html | 1 + netbox/templates/ipam/vlan_edit.html | 10 +- .../virtualization/cluster_edit.html | 1 + netbox/virtualization/forms.py | 24 +- 15 files changed, 312 insertions(+), 49 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 5e5a88080..4731c9adb 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -303,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3b1f24b3a..45be438f2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -352,8 +352,18 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) parent = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -367,7 +377,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackGroup fields = ( - 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site', 'parent', 'name', 'slug', 'description', ) @@ -447,14 +457,17 @@ class RackRoleCSVForm(CSVModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = DynamicModelChoiceField( - queryset=Site.objects.all() - ) - group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + region = DynamicModelChoiceField( + queryset=Region.objects.all(), required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), query_params={ - 'site_id': '$site' + 'region_id': '$region' } ) role = DynamicModelChoiceField( @@ -470,8 +483,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Rack fields = [ - 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', - 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags', + 'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -548,9 +562,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -691,9 +715,19 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -707,7 +741,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): display_field='display_name', query_params={ 'site_id': '$site', - 'group_id': 'rack', + 'group_id': '$rack', } ) units = NumericArrayField( @@ -809,15 +843,23 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): model = RackReservation - field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] + field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related('site'), @@ -3417,10 +3459,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', - required=False + required=False, + query_params={ + 'region_id': '$termination_b_region' + } ) termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -3446,8 +3496,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Cable fields = [ - 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', - 'label', 'color', 'length', 'length_unit', + 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', ] widgets = { 'status': StaticSelect2, @@ -3544,10 +3594,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): label='Provider', required=False ) + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', - required=False + required=False, + query_params={ + 'region_id': '$termination_b_region' + } ) termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), @@ -3571,8 +3629,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class Meta: model = Cable fields = [ - 'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', - 'status', 'label', 'color', 'length', 'length_unit', + 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', ] def clean_termination_b_id(self): @@ -3581,11 +3639,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): + termination_b_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False + ) termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, - display_field='cid' + query_params={ + 'region_id': '$termination_b_region' + } ) termination_b_rackgroup = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -3827,10 +3892,18 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), @@ -3879,10 +3952,18 @@ class CableFilterForm(BootstrapMixin, forms.Form): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -3895,10 +3976,18 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -3911,10 +4000,18 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): + region = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False + ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', - required=False + required=False, + query_params={ + 'region': '$region' + } ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -3938,9 +4035,19 @@ class DeviceSelectionForm(forms.Form): class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -3973,7 +4080,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags', ] def save(self, *args, **kwargs): @@ -4070,9 +4177,19 @@ class DeviceVCMembershipForm(forms.ModelForm): class VCMemberSelectForm(BootstrapMixin, forms.Form): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -4171,8 +4288,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + query_params={ + 'region_id': '$region' + } ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -4189,7 +4316,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPanel fields = [ - 'site', 'rack_group', 'name', 'tags', + 'region', 'site', 'rack_group', 'name', 'tags', ] @@ -4224,9 +4351,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=PowerPanel.objects.all(), widget=forms.MultipleHiddenInput ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -4278,11 +4415,21 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites__powerpanel': '$power_panel' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, initial_params={ 'powerpanel': '$power_panel' + }, + query_params={ + 'region_id': '$region' } ) power_panel = DynamicModelChoiceField( @@ -4308,7 +4455,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerFeed fields = [ - 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] widgets = { diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 886f3e702..42175d782 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2043,8 +2043,11 @@ class CableCreateView(ObjectEditView): initial_data = {k: request.GET[k] for k in request.GET} # Set initial site and rack based on side A termination (if not already set) + termination_a_site = getattr(obj.termination_a.parent, 'site', None) + if termination_a_site and 'termination_b_region' not in initial_data: + initial_data['termination_b_region'] = termination_a_site.region if 'termination_b_site' not in initial_data: - initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None) + initial_data['termination_b_site'] = termination_a_site if 'termination_b_rack' not in initial_data: initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b49fb2e41..ce7524dea 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -253,10 +253,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): label='VRF', display_field='display_name' ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - null_option='None' + null_option='None', + query_params={ + 'region_id': '$region' + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -369,9 +379,17 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='slug' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region': '$region' + } ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -529,10 +547,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel label='VRF', display_field='display_name' ) + nat_region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + label='Region', + initial_params={ + 'sites': '$nat_site' + } + ) nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site' + label='Site', + query_params={ + 'region_id': '$nat_region' + } ) nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), @@ -924,16 +953,26 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, forms.ModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) slug = SlugField() class Meta: model = VLANGroup fields = [ - 'site', 'name', 'slug', 'description', + 'region', 'site', 'name', 'slug', 'description', ] @@ -973,10 +1012,20 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - null_option='None' + null_option='None', + query_params={ + 'region_id': '$region' + } ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -1065,9 +1114,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='slug' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region': '$region' + } ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 8a6171e5f..6867872f9 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -40,6 +40,7 @@

{{ form.term_side.value }}

+ {% render_field form.region %} {% render_field form.site %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index 644dfd3dc..05fd2de1a 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -32,6 +32,12 @@
{% if termination_a.device %} {# Device component #} +
+ +
+

{{ termination_a.device.site.region }}

+
+
@@ -111,6 +117,9 @@ {% if 'termination_b_provider' in form.fields %} {% render_field form.termination_b_provider %} {% endif %} + {% if 'termination_b_region' in form.fields %} + {% render_field form.termination_b_region %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html index f4b3ada46..e33aab19b 100644 --- a/netbox/templates/dcim/powerfeed_edit.html +++ b/netbox/templates/dcim/powerfeed_edit.html @@ -3,10 +3,16 @@ {% block form %}
-
Power Feed
+
Power Panel
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.power_panel %} +
+
+
+
Power Feed
+
{% render_field form.rack %} {% render_field form.name %} {% render_field form.status %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index cd1192c19..c45aa190d 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -5,6 +5,7 @@
Rack
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.name %} {% render_field form.facility_id %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html index d6fa9cfcb..9dbc17653 100644 --- a/netbox/templates/dcim/rackreservation_edit.html +++ b/netbox/templates/dcim/rackreservation_edit.html @@ -5,6 +5,7 @@
Rack Reservation
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.rack_group %} {% render_field form.rack %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 07b17f378..ac94cc7df 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -13,6 +13,7 @@
Member Devices
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.rack %} {% render_field form.members %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index e7894dbad..92093dea4 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -62,6 +62,7 @@
+ {% render_field form.nat_region %} {% render_field form.nat_site %} {% render_field form.nat_rack %} {% render_field form.nat_device %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 401a53e38..126593074 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -16,6 +16,7 @@
Site/VLAN Assignment
+ {% render_field form.region %} {% render_field form.site %} {% render_field form.vlan_group %} {% render_field form.vlan %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 1c191343d..9ea3cdabb 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -8,12 +8,18 @@ {% render_field form.vid %} {% render_field form.name %} {% render_field form.status %} - {% render_field form.site %} - {% render_field form.group %} {% render_field form.role %} {% render_field form.description %}
+
+
Assignment
+
+ {% render_field form.region %} + {% render_field form.site %} + {% render_field form.group %} +
+
Tenancy
diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html index c4d39d12e..5ad7c53e1 100644 --- a/netbox/templates/virtualization/cluster_edit.html +++ b/netbox/templates/virtualization/cluster_edit.html @@ -8,6 +8,7 @@ {% render_field form.name %} {% render_field form.type %} {% render_field form.group %} + {% render_field form.region %} {% render_field form.site %}
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index f99840f86..ce4ca3e3c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -79,9 +79,19 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=ClusterGroup.objects.all(), required=False ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region_id': '$region' + } ) comments = CommentField() tags = DynamicModelMultipleChoiceField( @@ -92,7 +102,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', ) @@ -143,9 +153,17 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Tenant.objects.all(), required=False ) + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='slug' + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + query_params={ + 'region': '$region' + } ) comments = CommentField( widget=SmallTextarea, From f845eeb11718ebd778332d6f6ccebc926e9480d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Nov 2020 17:11:57 -0500 Subject: [PATCH 08/19] Closes #5304: Return server error messages as JSON when handling REST API requests --- docs/release-notes/version-2.9.md | 4 ++++ netbox/utilities/middleware.py | 6 +++++- netbox/utilities/views.py | 23 +++++++++++++++++++---- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 977cb08e0..3d6ad5b81 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -2,6 +2,10 @@ ## v2.9.9 (FUTURE) +### Enhancements + +* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests + ### Bug Fixes * [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index d86be752b..605f10e42 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -7,7 +7,7 @@ from django.http import Http404, HttpResponseRedirect from django.urls import reverse from .api import is_api_request -from .views import server_error +from .views import server_error, rest_api_server_error class LoginRequiredMiddleware(object): @@ -86,6 +86,10 @@ class ExceptionHandlingMiddleware(object): if isinstance(exception, Http404): return + # Handle exceptions that occur from REST API requests + if is_api_request(request): + return rest_api_server_error(request) + # Determine the type of exception. If it's a common issue, return a custom error page with instructions. custom_template = None if isinstance(exception, ProgrammingError): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1dae60107..df8982755 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -13,7 +13,7 @@ from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, Obje from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.http import HttpResponse, HttpResponseServerError +from django.http import HttpResponse, HttpResponseServerError, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist @@ -27,6 +27,7 @@ from django.views.decorators.csrf import requires_csrf_token from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.generic import View from django_tables2 import RequestConfig +from rest_framework import status from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset @@ -1423,8 +1424,22 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): type_, error, traceback = sys.exc_info() return HttpResponseServerError(template.render({ - 'python_version': platform.python_version(), - 'netbox_version': settings.VERSION, - 'exception': str(type_), 'error': error, + 'exception': str(type_), + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), })) + + +def rest_api_server_error(request, *args, **kwargs): + """ + Handle exceptions and return a useful error message for REST API requests. + """ + type_, error, traceback = sys.exc_info() + data = { + 'error': str(error), + 'exception': type_.__name__, + 'netbox_version': settings.VERSION, + 'python_version': platform.python_version(), + } + return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) From f2cba785497471fac6dcd667d81420c81b523f7e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Nov 2020 14:24:22 -0500 Subject: [PATCH 09/19] Closes #5310: Link to rack groups within rack list table --- docs/release-notes/version-2.9.md | 1 + netbox/dcim/tables.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 3d6ad5b81..042f75691 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -5,6 +5,7 @@ ### Enhancements * [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests +* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table ### Bug Fixes diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 3106ed2a1..2d8195f85 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -262,12 +262,15 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) + name = tables.Column( + order_by=('_name',), + linkify=True ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + group = tables.Column( + linkify=True + ) + site = tables.Column( + linkify=True ) tenant = tables.TemplateColumn( template_code=COL_TENANT From b39019cec95d80562ec9f229477271d3af74598a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 5 Nov 2020 14:41:06 -0500 Subject: [PATCH 10/19] Clarify cache invalidation instructions --- docs/administration/replicating-netbox.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 7fa07517c..23c1082bc 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -73,8 +73,9 @@ tar -xf netbox_media.tar.gz ## Cache Invalidation -If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache by performing this command: +If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment): ```no-highlight -python3 manage.py invalidate all +# source /opt/netbox/venv/bin/activate +(venv) # python3 manage.py invalidate all ``` From 0d27abc6fc22a8d40183a59eceef5dda57e99eae Mon Sep 17 00:00:00 2001 From: John Anderson Date: Fri, 6 Nov 2020 16:47:07 -0500 Subject: [PATCH 11/19] fixes #5314 - Fix config context rendering when multiple tags are assgined to an object --- docs/release-notes/version-2.9.md | 1 + netbox/extras/querysets.py | 4 ++-- netbox/extras/tests/test_models.py | 35 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 042f75691..ce7289da4 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -10,6 +10,7 @@ ### Bug Fixes * [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device +* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assgined to an object --- diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 9bfa5da83..92402c45f 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -60,7 +60,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(tenants=obj.tenant) | Q(tenants=None), Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), is_active=True, - ).order_by('weight', 'name') + ).order_by('weight', 'name').distinct() if aggregate_data: return queryset.aggregate( @@ -95,7 +95,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): _data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name']) ).values("_data") ) - ) + ).distinct() def _get_config_context_filters(self): # Construct the set of Q objects for the specific object types diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 280da75b6..80beb0296 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -75,6 +75,7 @@ class ConfigContextTest(TestCase): self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) self.tag = Tag.objects.create(name="Tag", slug="tag") + self.tag2 = Tag.objects.create(name="Tag2", slug="tag2") self.device = Device.objects.create( name='Device 1', @@ -328,3 +329,37 @@ class ConfigContextTest(TestCase): annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data() self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context()) + + def test_multiple_tags_return_distinct_objects(self): + """ + Tagged items use a generic relationship, which results in duplicate rows being returned when queried. + This is combatted by by appending distinct() to the config context querysets. This test creates a config + context assigned to two tags and ensures objects related by those same two tags result in only a single + config context record being returned. + + See https://github.com/netbox-community/netbox/issues/5314 + """ + tag_context = ConfigContext.objects.create( + name="tag", + weight=100, + data={ + "tag": 1 + } + ) + tag_context.tags.add(self.tag) + tag_context.tags.add(self.tag2) + + device = Device.objects.create( + name="Device 3", + site=self.site, + tenant=self.tenant, + platform=self.platform, + device_role=self.devicerole, + device_type=self.devicetype + ) + device.tags.add(self.tag) + device.tags.add(self.tag2) + + annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() + self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1) + self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) From 7cf4095983a5c17a495067005c87968a53560666 Mon Sep 17 00:00:00 2001 From: 991jo Date: Mon, 9 Nov 2020 07:56:26 +0100 Subject: [PATCH 12/19] Fixed a UI Bug where there is no whitespace after grouped custom links. See #5325 for details --- netbox/extras/templatetags/custom_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 9fbec489d..78630336e 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -16,7 +16,7 @@ GROUP_BUTTON = '
\n' \ '{} \n' \ '\n' \ '
' + '{}
\n' GROUP_LINK = '
  • {}
  • \n' From d2c4c7fb10b95104f362b35c1095914cf6f25599 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 14:19:22 -0500 Subject: [PATCH 13/19] Closes #5324: Add missing template extension tags for plugins for VM interface view --- docs/release-notes/version-2.9.md | 3 ++- netbox/templates/virtualization/vminterface.html | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index ce7289da4..fe36b010e 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -10,7 +10,8 @@ ### Bug Fixes * [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device -* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assgined to an object +* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object +* [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view --- diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 8d46b52fd..976812a44 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -1,5 +1,6 @@ {% extends 'base.html' %} {% load helpers %} +{% load plugins %} {% block header %}
    @@ -12,6 +13,7 @@
    + {% plugin_buttons vminterface %} {% if perms.virtualization.change_vminterface %} Edit @@ -82,9 +84,11 @@
    + {% plugin_left_page vminterface %}
    {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %} + {% plugin_right_page vminterface %}
    @@ -97,4 +101,9 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
    +
    +
    + {% plugin_full_width_page vminterface %} +
    +
    {% endblock %} From c3f2cc7648d5f858935b7f6e575dc4ac0e87eda5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 14:20:07 -0500 Subject: [PATCH 14/19] Correct buttons() method in dummy plugin template content --- netbox/extras/tests/dummy_plugin/template_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/tests/dummy_plugin/template_content.py b/netbox/extras/tests/dummy_plugin/template_content.py index fed17ca0b..6151454ea 100644 --- a/netbox/extras/tests/dummy_plugin/template_content.py +++ b/netbox/extras/tests/dummy_plugin/template_content.py @@ -13,7 +13,7 @@ class SiteContent(PluginTemplateExtension): def full_width_page(self): return "SITE CONTENT - FULL WIDTH PAGE" - def full_buttons(self): + def buttons(self): return "SITE CONTENT - BUTTONS" From bf836d4d463188ac1ccf384b01a9e7127c8f52ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 15:58:59 -0500 Subject: [PATCH 15/19] Fixes #5331: Fix filtering of sites by null region --- docs/release-notes/version-2.9.md | 1 + netbox/utilities/filters.py | 5 ++--- netbox/utilities/tests/test_filters.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index fe36b010e..eee056dbb 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -12,6 +12,7 @@ * [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device * [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object * [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view +* [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region --- diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..18eeab7d2 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -68,11 +68,10 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] """ - def get_filter_predicate(self, v): - # null value filtering + # Null value filtering if v is None: - return {self.field_name.replace('in', 'isnull'): True} + return {f"{self.field_name}__isnull": True} return super().get_filter_predicate(v) def filter(self, qs, value): diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index f70d7e1db..56eaabd4c 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -23,7 +23,8 @@ class TreeNodeMultipleChoiceFilterTest(TestCase): class SiteFilterSet(django_filters.FilterSet): region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region__in', + field_name='region', + lookup_expr='in', to_field_name='slug', ) From ab33b3aa84a6342320a239415dab998a29c1c803 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 16:04:09 -0500 Subject: [PATCH 16/19] Fixes #5328: Fix CreatedUpdatedFilterTest when running in non-UTC timezone --- docs/release-notes/version-2.9.md | 1 + netbox/extras/tests/test_api.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index eee056dbb..f4dc03cc5 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -12,6 +12,7 @@ * [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device * [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object * [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view +* [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone * [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index c768534a2..9e9c418f9 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -3,7 +3,7 @@ from unittest import skipIf from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from django.utils import timezone +from django.utils.timezone import make_aware from django_rq.queues import get_connection from rest_framework import status from rq import Worker @@ -369,8 +369,8 @@ class CreatedUpdatedFilterTest(APITestCase): # change the created and last_updated of one Rack.objects.filter(pk=self.rack2.pk).update( - last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc), - created=datetime.datetime(2001, 2, 3) + last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)), + created=make_aware(datetime.datetime(2001, 2, 3)) ) def test_get_rack_created(self): From dc7da4f0f6821b5081b44238d7be39f4e2006ecc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 16:22:45 -0500 Subject: [PATCH 17/19] Fixes #5316: Dry running scripts should not trigger webhooks --- docs/release-notes/version-2.9.md | 1 + netbox/extras/scripts.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index f4dc03cc5..63e5d7117 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -11,6 +11,7 @@ * [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device * [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object +* [#5316](https://github.com/netbox-community/netbox/issues/5316) - Dry running scripts should not trigger webhooks * [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view * [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone * [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 9d5380655..f63f02782 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -441,8 +441,11 @@ def run_script(data, request, commit=True, *args, **kwargs): f"with NetBox v2.10." ) - with change_logging(request): - + def _run_script(): + """ + Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with + the change_logging context manager (which is bypassed if commit == False). + """ try: with transaction.atomic(): script.output = script.run(**kwargs) @@ -469,6 +472,14 @@ def run_script(data, request, commit=True, *args, **kwargs): logger.info(f"Script completed in {job_result.duration}") + # Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process + # change logging, webhooks, etc. + if commit: + with change_logging(request): + _run_script() + else: + _run_script() + # Delete any previous terminal state results JobResult.objects.filter( obj_type=job_result.obj_type, From 9b2dc89c6c6367fc3596eede1504e7d49d4f87ae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 16:48:11 -0500 Subject: [PATCH 18/19] Closes #5327: Be more strict when capturing anticipated ImportError exceptions --- docs/release-notes/version-2.9.md | 1 + netbox/dcim/api/views.py | 16 ++++++++------- netbox/netbox/authentication.py | 23 +++++++++++++-------- netbox/netbox/settings.py | 34 ++++++++++++++++++------------- 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 63e5d7117..f96eac458 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -6,6 +6,7 @@ * [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests * [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table +* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions ### Bug Fixes diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 427aecd5f..2d7107ef4 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -396,9 +396,7 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin): if device.platform is None: raise ServiceUnavailable("No platform is configured for this device.") if not device.platform.napalm_driver: - raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format( - device.platform - )) + raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.") # Check for primary IP address from NetBox object if device.primary_ip: @@ -407,21 +405,25 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin): # Raise exception for no IP address and no Name if device.name does not exist if not device.name: raise ServiceUnavailable( - "This device does not have a primary IP address or device name to lookup configured.") + "This device does not have a primary IP address or device name to lookup configured." + ) try: # Attempt to complete a DNS name resolution if no primary_ip is set host = socket.gethostbyname(device.name) except socket.gaierror: # Name lookup failure raise ServiceUnavailable( - f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.") + f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or " + f"setup name resolution.") # Check that NAPALM is installed try: import napalm from napalm.base.exceptions import ModuleImportError - except ImportError: - raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'napalm': + raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + raise e # Validate the configured driver try: diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 21fb3e229..0eee2c13e 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -137,19 +137,24 @@ class LDAPBackend: def __new__(cls, *args, **kwargs): try: - import ldap from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings - except ImportError: - raise ImproperlyConfigured( - "LDAP authentication has been configured, but django-auth-ldap is not installed." - ) + import ldap + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'django_auth_ldap': + raise ImproperlyConfigured( + "LDAP authentication has been configured, but django-auth-ldap is not installed." + ) + raise e try: from netbox import ldap_config - except ImportError: - raise ImproperlyConfigured( - "ldap_config.py does not exist" - ) + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'ldap_config': + raise ImproperlyConfigured( + "LDAP configuration file not found: Check that ldap_config.py has been created alongside " + "configuration.py." + ) + raise e try: getattr(ldap_config, 'AUTH_LDAP_SERVER_URI') diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 36d3fb980..824739345 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -38,10 +38,12 @@ if platform.python_version_tuple() < ('3', '6'): # Import configuration parameters try: from netbox import configuration -except ImportError: - raise ImproperlyConfigured( - "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." - ) +except ModuleNotFoundError as e: + if getattr(e, 'name') == 'configuration': + raise ImproperlyConfigured( + "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." + ) + raise # Enforce required configuration parameters for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']: @@ -183,11 +185,13 @@ if STORAGE_BACKEND is not None: try: import storages.utils - except ImportError: - raise ImproperlyConfigured( - "STORAGE_BACKEND is set to {} but django-storages is not present. It can be installed by running 'pip " - "install django-storages'.".format(STORAGE_BACKEND) - ) + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'storages': + raise ImproperlyConfigured( + f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be " + f"installed by running 'pip install django-storages'." + ) + raise e # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG def _setting(name, default=None): @@ -596,11 +600,13 @@ for plugin_name in PLUGINS: # Import plugin module try: plugin = importlib.import_module(plugin_name) - except ImportError: - raise ImproperlyConfigured( - "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the " - "correct Python environment.".format(plugin_name) - ) + except ModuleNotFoundError as e: + if getattr(e, 'name') == plugin_name: + raise ImproperlyConfigured( + "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the " + "correct Python environment.".format(plugin_name) + ) + raise e # Determine plugin config and add to INSTALLED_APPS. try: From a6f01548032b9ff09076a453cbc7100ab7fcb9ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 9 Nov 2020 20:51:35 -0500 Subject: [PATCH 19/19] Release v2.9.9 --- docs/release-notes/version-2.9.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index f96eac458..6c7839818 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,6 +1,6 @@ # NetBox v2.9 -## v2.9.9 (FUTURE) +## v2.9.9 (2020-11-09) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 824739345..39d8f5871 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.9-dev' +VERSION = '2.9.9' # Hostname HOSTNAME = platform.node()