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' 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 ``` diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index dc6a10dae..6c7839818 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,5 +1,25 @@ # NetBox v2.9 +## v2.9.9 (2020-11-09) + +### 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 +* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions + +### 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 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 + + +--- + ## v2.9.8 (2020-10-30) ### Enhancements 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/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/dcim/forms.py b/netbox/dcim/forms.py index f5c27f7d3..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'), @@ -1672,7 +1714,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 +1731,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name', query_params={ 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' } ) rack = DynamicModelChoiceField( @@ -1711,7 +1759,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 +1784,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 +1826,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: @@ -3426,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(), @@ -3455,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, @@ -3553,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(), @@ -3580,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): @@ -3590,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(), @@ -3836,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(), @@ -3888,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(), @@ -3904,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(), @@ -3920,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(), @@ -3947,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(), @@ -3982,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): @@ -4079,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(), @@ -4180,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(), @@ -4198,7 +4316,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPanel fields = [ - 'site', 'rack_group', 'name', 'tags', + 'region', 'site', 'rack_group', 'name', 'tags', ] @@ -4233,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(), @@ -4287,9 +4415,22 @@ 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 + required=False, + initial_params={ + 'powerpanel': '$power_panel' + }, + query_params={ + 'region_id': '$region' + } ) power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), @@ -4314,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 = { @@ -4324,14 +4465,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/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 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/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/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/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, 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 = '
{{ 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 @@{{ termination_a.device.site.region }}
+