diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a587b36e2..f4afe3f98 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.3 + placeholder: v3.6.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 71f1f2d97..9bf991e6e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.3 + placeholder: v3.6.4 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9692194..9d580baa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,15 +31,15 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -47,7 +47,7 @@ jobs: run: npm install -g yarn - name: Setup Node.js with Yarn Caching - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 6019cef5d..a3e66a429 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,7 +14,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v3 + - uses: dessant/lock-threads@v4 with: issue-inactive-days: 90 pr-inactive-days: 30 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3b37aae56..22de146a2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v6 + - uses: actions/stale@v8 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an diff --git a/base_requirements.txt b/base_requirements.txt index 4b75b1313..9863984ca 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -23,8 +23,9 @@ django-filter django-graphiql-debug-toolbar # Modified Preorder Tree Traversal (recursive nesting of objects) +# Pinned to 0.14.0; 0.15.0 requires Python 3.9+ # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst -django-mptt +django-mptt==0.14.0 # Context managers for PostgreSQL advisory locks # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt @@ -120,6 +121,10 @@ psycopg[binary,pool] # https://github.com/yaml/pyyaml/blob/master/CHANGES PyYAML +# Requests +# https://github.com/psf/requests/blob/main/HISTORY.md +requests + # Sentry SDK # https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md sentry-sdk diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index fd410a9d4..f143be139 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -80,6 +80,14 @@ changes in the database indefinitely. --- +## DATA_UPLOAD_MAX_MEMORY_SIZE + +Default: `2621440` (2.5 MB) + +The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception. + +--- + ## ENFORCE_GLOBAL_UNIQUE !!! tip "Dynamic Configuration Parameter" @@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address --- -## `FILE_UPLOAD_MAX_MEMORY_SIZE` +## FILE_UPLOAD_MAX_MEMORY_SIZE -Default: `2621440` (2.5 MB). +Default: `2621440` (2.5 MB) The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing. diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 3811474d2..0b1ed11df 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a ## Running Custom Scripts !!! note - To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below. + To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.  diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 7e3681304..a821c5da7 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r ## Running Reports !!! note - To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below. + To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.  diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index db0e3d3ea..a03dc548f 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,5 +1,37 @@ # NetBox v3.6 +## v3.6.4 (2023-10-17) + +### Enhancements + +* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image +* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter +* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI +* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list +* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens +* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables + +### Bug Fixes + +* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form +* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects +* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects +* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering +* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API +* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API +* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views +* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API +* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view +* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API +* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table +* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters +* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable +* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another +* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes +* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk + +--- + ## v3.6.3 (2023-09-26) ### Enhancements diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f045f1bb4..80a991736 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.renderers import TextRenderer -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model @@ -98,7 +98,7 @@ class PassThroughPortMixin(object): # Regions # -class RegionViewSet(NetBoxModelViewSet): +class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet): # Site groups # -class SiteGroupViewSet(NetBoxModelViewSet): +class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = SiteGroup.objects.add_related_count( SiteGroup.objects.all(), Site, @@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet): # Locations # -class LocationViewSet(NetBoxModelViewSet): +class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = Location.objects.add_related_count( Location.objects.add_related_count( Location.objects.all(), @@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet): filterset_class = filtersets.DeviceBayTemplateFilterSet -class InventoryItemTemplateViewSet(NetBoxModelViewSet): +class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') serializer_class = serializers.InventoryItemTemplateSerializer filterset_class = filtersets.InventoryItemTemplateFilterSet @@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet): brief_prefetch_fields = ['device'] -class InventoryItemViewSet(NetBoxModelViewSet): +class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0261998db..d600667d7 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1745,6 +1745,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): method='filter_by_cable_end_b', field_name='terminations__termination_id' ) + unterminated = django_filters.BooleanFilter( + method='_unterminated', + label=_('Unterminated'), + ) type = django_filters.MultipleChoiceFilter( choices=CableTypeChoices ) @@ -1812,6 +1816,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): # Filter by termination id and cable_end type return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B) + def _unterminated(self, queryset, name, value): + if value: + terminated_ids = ( + queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A) + .filter(terminations__cable_end=CableEndChoices.SIDE_B) + .values("id") + ) + return queryset.exclude(id__in=terminated_ids) + else: + return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter( + terminations__cable_end=CableEndChoices.SIDE_B + ) + class CableTerminationFilterSet(BaseFilterSet): termination_type = ContentTypeFilter() diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 70aceaa49..e41e875e4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1192,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm): termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) else: termination_object = model.objects.get(device=device, name=name) - if termination_object.cable is not None: + if termination_object.cable is not None and termination_object.cable != self.instance: raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") except ObjectDoesNotExist: raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 43e5f4481..d0d321187 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Device type') ) - role_id = DynamicModelMultipleChoiceField( + device_role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, label=_('Device role') @@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')), - (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')), + (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( @@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=add_blank_choice(CableLengthUnitChoices), required=False ) + unterminated = forms.NullBooleanField( + label=_('Unterminated'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) @@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'speed')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'speed')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (_('PoE'), ('poe_mode', 'poe_type')), (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) vdc_id = DynamicModelMultipleChoiceField( @@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'color')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Cable'), ('cabled', 'occupied')), ) model = FrontPort @@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'type', 'color')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Cable'), ('cabled', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'position')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) @@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): (None, ('q', 'filter_id', 'tag')), (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')), + (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index c01e656fd..acc4fcad9 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -160,6 +160,8 @@ class CableTraceSVG: elif instance._meta.model_name == 'circuit': labels[0] = f'Circuit {instance}' labels.append(instance.provider) + if instance.description: + labels.append(instance.description) elif instance._meta.model_name == 'circuittermination': if instance.xconnect_id: labels.append(f'{instance.xconnect_id}') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index dd5ff7bc2..1f3b557b5 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) Interface.objects.bulk_create(interfaces) @@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save() + # Cable for unterminated test + Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save() + def test_label(self): params = {'label': ['Cable 1', 'Cable 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): } self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_unterminated(self): + params = {'unterminated': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'unterminated': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerPanel.objects.all() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2f661e613..7c75dd26e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View) if form.is_valid(): with transaction.atomic(): - count = 0 + cable_ids = set() for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): - if obj.cable is None: - continue - obj.cable.delete() - count += 1 + if obj.cable: + cable_ids.add(obj.cable.pk) + count += 1 + for cable in Cable.objects.filter(pk__in=cable_ids): + cable.delete() - messages.success(request, "Disconnected {} {}".format( - count, self.queryset.model._meta.verbose_name_plural + messages.success(request, _("Disconnected {count} {type}").format( + count=count, + type=self.queryset.model._meta.verbose_name_plural )) return redirect(return_url) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index e6f339e5a..2bed464bb 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -232,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): return self.choice_set.choices return [] + def get_choice_label(self, value): + if not hasattr(self, '_choice_map'): + self._choice_map = dict(self.choices) + return self._choice_map.get(value, value) + def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 9b12065ca..cc279a49a 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) def get_module_and_report(module_name, report_name): module = ReportModule.objects.get(file_path=f'{module_name}.py') - report = module.reports.get(report_name) + report = module.reports.get(report_name)() return module, report diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9efcc02dc..55b73d29d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): }) +def get_report_module(module, request): + return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ReportView(ContentTypePermissionRequiredMixin, View): """ Display a single Report and its associated Job (if any). @@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_report'): return HttpResponseForbidden() - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled) @@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() return render(request, 'extras/report/source.html', { @@ -1062,7 +1066,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): }) +def get_script_module(module, request): + return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ScriptView(ContentTypePermissionRequiredMixin, View): def get_required_permission(self): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(initial=normalize_querydict(request.GET)) @@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_script'): return HttpResponseForbidden() - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(request.POST, request.FILES) @@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() return render(request, 'extras/script/source.html', { @@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index da6463e23..662b393de 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): # Normalize request data to a list of objects requested_objects = request.data if isinstance(request.data, list) else [request.data] + limit = len(requested_objects) # Serialize and validate the request data serializer = self.write_serializer_class(data=requested_objects, many=True, context={ @@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): - available_objects = self.get_available_objects(parent) + available_objects = self.get_available_objects(parent, limit) # Determine if the requested number of objects is available if not self.check_sufficient_available(serializer.validated_data, available_objects): @@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) # Prepare object data for deserialization - requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) + requested_objects = self.prep_object_data(requested_objects, available_objects, parent) # Initialize the serializer with a list or a single object depending on what was requested serializer_class = get_serializer_for_model(self.queryset.model) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index e4e967f81..aae62ca75 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), + (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')), (_('VRF'), ('vrf_id', 'present_in_vrf_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Device/VM'), ('device_id', 'virtual_machine_id')), @@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + dns_name = forms.CharField( + required=False, + label=_('DNS Name') + ) tag = TagFilterField(model) diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 5fe81b1f5..c6794bb61 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -3,6 +3,8 @@ import logging from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError +from django_pglocks import advisory_lock +from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -157,3 +159,22 @@ class NetBoxModelViewSet( logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) + + +class MPTTLockedMixin: + """ + Puts pglock on objects that derive from MPTTModel for parallel API calling. + Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS + """ + + def create(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().destroy(request, *args, **kwargs) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index d69edc69c..2f4ee8e6b 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low' # When adding a new key, pick something arbitrary and unique so that it is easily searchable in # query logs. ADVISORY_LOCK_KEYS = { + # Available object locks 'available-prefixes': 100100, 'available-ips': 100200, 'available-vlans': 100300, 'available-asns': 100400, + + # MPTT locks + 'region': 105100, + 'sitegroup': 105200, + 'location': 105300, + 'tenantgroup': 105400, + 'contactgroup': 105500, + 'wirelesslangroup': 105600, + 'inventoryitem': 105700, + 'inventoryitemtemplate': 105800, } diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 596357ea4..9d7696696 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): if ct_value and fk_value: klass = getattr(self, field.ct_field).model_class() - if not klass.objects.filter(pk=fk_value).exists(): + try: + obj = klass.objects.get(pk=fk_value) + except ObjectDoesNotExist: raise ValidationError({ field.fk_field: f"Related object not found using the provided value: {fk_value}." }) + # update the GFK field value + setattr(self, field.name, obj) + # # NetBox internal base models diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e483488fc..a5bbad85e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.3' +VERSION = '3.6.4' # Hostname HOSTNAME = platform.node() @@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) +DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) @@ -355,6 +356,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'django.forms', 'corsheaders', 'debug_toolbar', 'graphiql_debug_toolbar', @@ -430,6 +432,9 @@ TEMPLATES = [ }, ] +# This allows us to override Django's stock form widget templates +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' + # Set up authentication backends if type(REMOTE_AUTH_BACKEND) not in (list, tuple): REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND] diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9e348fb23..d2cd0a0d4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column): return mark_safe('') if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: return mark_safe(f'{escape(value)}') + if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT: + return self.customfield.get_choice_label(value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - return ', '.join(v for v in value) + return ', '.join(self.customfield.get_choice_label(v) for v in value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: return mark_safe(', '.join( self._linkify_item(obj) for obj in self.customfield.deserialize(value) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 7d16f9916..426302ea8 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 1797f57ce..077c4bcc0 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/clipboard.ts b/netbox/project-static/src/clipboard.ts index 46ca5e36c..ddcb7b96e 100644 --- a/netbox/project-static/src/clipboard.ts +++ b/netbox/project-static/src/clipboard.ts @@ -2,7 +2,7 @@ import Clipboard from 'clipboard'; import { getElements } from './util'; export function initClipboard(): void { - for (const element of getElements('a.copy-content')) { + for (const element of getElements('.copy-content')) { new Clipboard(element); } } diff --git a/netbox/templates/account/token.html b/netbox/templates/account/token.html index d83e13ff5..57d1de3f4 100644 --- a/netbox/templates/account/token.html +++ b/netbox/templates/account/token.html @@ -15,11 +15,6 @@ {% block content %}